chore: metrics explorer improvements (#7285)

This commit is contained in:
Amlan Kumar Nandy 2025-03-13 10:43:02 +05:30 committed by GitHub
parent 9a3c49bce4
commit 86a888a6a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 301 additions and 111 deletions

View File

@ -16,15 +16,21 @@ export interface MetricDetails {
timeSeriesActive: number;
lastReceived: string;
attributes: MetricDetailsAttribute[];
metadata: {
metadata?: {
metric_type: MetricType;
description: string;
unit: string;
temporality: Temporality;
};
alerts: MetricDetailsAlert[] | null;
dashboards: MetricDetailsDashboard[] | null;
}
export enum Temporality {
CUMULATIVE = 'cumulative',
DELTA = 'delta',
}
export interface MetricDetailsAttribute {
key: string;
value: string[];

View File

@ -1,12 +1,15 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { Temporality } from './getMetricDetails';
import { MetricType } from './getMetricsList';
export interface UpdateMetricMetadataProps {
description: string;
unit: string;
type: MetricType;
metricType: MetricType;
temporality: Temporality;
isMonotonic?: boolean;
}
export interface UpdateMetricMetadataResponse {

View File

@ -26,4 +26,5 @@ export enum LOCALSTORAGE {
UNAUTHENTICATED_ROUTE_HIT = 'UNAUTHENTICATED_ROUTE_HIT',
CELERY_OVERVIEW_COLUMNS = 'CELERY_OVERVIEW_COLUMNS',
DONT_SHOW_SLOW_API_WARNING = 'DONT_SHOW_SLOW_API_WARNING',
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
}

View File

@ -67,7 +67,6 @@
.related-metrics-container {
width: 100%;
min-height: 300px;
max-height: 450px;
display: flex;
flex-direction: column;
gap: 10px;
@ -102,19 +101,23 @@
}
.related-metrics-body {
padding: 10px 0;
margin-top: 20px;
max-height: 650px;
overflow-y: scroll;
.related-metrics-card-container {
min-height: 300px;
margin-bottom: 25px;
margin-bottom: 20px;
min-height: 640px;
.related-metrics-card {
// height: 400px;
display: flex;
flex-direction: column;
gap: 16px;
.related-metrics-card-error {
padding-top: 10px;
height: fit-content;
width: fit-content;
}
}
}

View File

@ -38,8 +38,8 @@ function Explorer(): JSX.Element {
const { notifications } = useNotifications();
const { mutate: updateDashboard, isLoading } = useUpdateDashboard();
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
dataSource: DataSource.TRACES,
storageKey: LOCALSTORAGE.METRICS_LIST_OPTIONS,
dataSource: DataSource.METRICS,
aggregateOperator: 'noop',
});

View File

@ -1,9 +1,5 @@
import { Color } from '@signozhq/design-tokens';
import { Card, Col, Input, Row, Select, Skeleton } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { Card, Col, Empty, Input, Row, Select, Skeleton } from 'antd';
import { Gauge } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
@ -15,9 +11,7 @@ import { RelatedMetricsProps, RelatedMetricWithQueryResult } from './types';
import { useGetRelatedMetricsGraphs } from './useGetRelatedMetricsGraphs';
function RelatedMetrics({ metricNames }: RelatedMetricsProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
@ -41,13 +35,15 @@ function RelatedMetrics({ metricNames }: RelatedMetricsProps): JSX.Element {
}
}, [metricNames]);
const { relatedMetrics, isRelatedMetricsLoading } = useGetRelatedMetricsGraphs(
{
const {
relatedMetrics,
isRelatedMetricsLoading,
isRelatedMetricsError,
} = useGetRelatedMetricsGraphs({
selectedMetricName,
startMs,
endMs,
},
);
});
const metricNamesSelectOptions = useMemo(
() =>
@ -91,31 +87,6 @@ function RelatedMetrics({ metricNames }: RelatedMetricsProps): JSX.Element {
return filteredMetrics;
}, [relatedMetrics, selectedRelatedMetric, searchValue]);
const chartData = useMemo(
() =>
filteredRelatedMetrics.map(({ queryResult }) =>
getUPlotChartData(queryResult.data?.payload),
),
[filteredRelatedMetrics],
);
const options = useMemo(
() =>
filteredRelatedMetrics.map(({ queryResult }) =>
getUPlotChartOptions({
apiResponse: queryResult.data?.payload,
isDarkMode,
dimensions,
yAxisUnit: '',
softMax: null,
softMin: null,
minTimeScale: startMs,
maxTimeScale: endMs,
}),
),
[filteredRelatedMetrics, isDarkMode, dimensions, startMs, endMs],
);
return (
<div className="related-metrics-container">
<div className="related-metrics-header">
@ -145,20 +116,34 @@ function RelatedMetrics({ metricNames }: RelatedMetricsProps): JSX.Element {
</div>
<div className="related-metrics-body">
{isRelatedMetricsLoading && <Skeleton active />}
{isRelatedMetricsError && (
<Empty description="Error fetching related metrics" />
)}
{!isRelatedMetricsLoading &&
!isRelatedMetricsError &&
filteredRelatedMetrics.length === 0 && (
<Empty description="No related metrics found" />
)}
{!isRelatedMetricsLoading &&
!isRelatedMetricsError &&
filteredRelatedMetrics.length > 0 && (
<Row gutter={24}>
{filteredRelatedMetrics.map((relatedMetricWithQueryResult, index) => (
<Col span={8} key={relatedMetricWithQueryResult.name}>
<Card bordered ref={graphRef} className="related-metrics-card-container">
{filteredRelatedMetrics.map((relatedMetricWithQueryResult) => (
<Col span={12} key={relatedMetricWithQueryResult.name}>
<Card
bordered
ref={graphRef}
className="related-metrics-card-container"
>
<RelatedMetricsCard
key={relatedMetricWithQueryResult.name}
metric={relatedMetricWithQueryResult}
options={options[index]}
chartData={chartData[index]}
/>
</Card>
</Col>
))}
</Row>
)}
</div>
</div>
);

View File

@ -1,14 +1,11 @@
import { Skeleton, Typography } from 'antd';
import Uplot from 'components/Uplot';
import { Empty, Skeleton, Typography } from 'antd';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { DataSource } from 'types/common/queryBuilder';
import DashboardsAndAlertsPopover from '../MetricDetails/DashboardsAndAlertsPopover';
import { RelatedMetricsCardProps } from './types';
function RelatedMetricsCard({
metric,
options,
chartData,
}: RelatedMetricsCardProps): JSX.Element {
function RelatedMetricsCard({ metric }: RelatedMetricsCardProps): JSX.Element {
const { queryResult } = metric;
if (queryResult.isLoading) {
@ -27,13 +24,20 @@ function RelatedMetricsCard({
{metric.name}
</Typography.Text>
{queryResult.isLoading ? <Skeleton /> : null}
{queryResult.error ? (
{queryResult.isError ? (
<div className="related-metrics-card-error">
<Typography.Text>Something went wrong</Typography.Text>
<Empty description="Error fetching metric data" />
</div>
) : null}
{!queryResult.isLoading && !queryResult.error && (
<Uplot options={options} data={chartData} />
<TimeSeriesView
isFilterApplied={false}
isError={queryResult.isError}
isLoading={queryResult.isLoading}
data={queryResult.data}
yAxisUnit="ms"
dataSource={DataSource.METRICS}
/>
)}
<DashboardsAndAlertsPopover
dashboards={metric.dashboards}

View File

@ -18,8 +18,6 @@ export interface RelatedMetricsProps {
export interface RelatedMetricsCardProps {
metric: RelatedMetricWithQueryResult;
options: uPlot.Options;
chartData: any[];
}
export interface UseGetRelatedMetricsGraphsProps {

View File

@ -1,11 +1,14 @@
import { Button, Collapse, Input, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { ResizeTable } from 'components/ResizeTable';
import ROUTES from 'constants/routes';
import { DataType } from 'container/LogDetailedView/TableView';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Search } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { AllAttributesProps } from './types';
import { getMetricDetailsQuery } from './utils';
function AllAttributes({
attributes,
@ -16,12 +19,17 @@ function AllAttributes({
'all-attributes',
);
const { safeNavigate } = useSafeNavigate();
const goToMetricsExploreWithAppliedAttribute = useCallback(
(attribute: string) => {
// TODO: Implement this when explore page is ready
console.log(metricName, attribute);
(key: string, value: string) => {
const compositeQuery = getMetricDetailsQuery(metricName, { key, value });
const encodedCompositeQuery = JSON.stringify(compositeQuery);
safeNavigate(
`${ROUTES.METRICS_EXPLORER_EXPLORER}?compositeQuery=${encodedCompositeQuery}`,
);
},
[metricName],
[metricName, safeNavigate],
);
const filteredAttributes = useMemo(
@ -40,7 +48,10 @@ function AllAttributes({
label: attribute.key,
contribution: attribute.valueCount,
},
value: {
key: attribute.key,
value: attribute.value,
},
}))
: [],
[filteredAttributes],
@ -70,14 +81,14 @@ function AllAttributes({
align: 'left',
ellipsis: true,
className: 'metric-metadata-value',
render: (attributes: string[]): JSX.Element => (
render: (field: { key: string; value: string[] }): JSX.Element => (
<div className="all-attributes-value">
{attributes.map((attribute) => (
{field.value.map((attribute) => (
<Button
key={attribute}
type="text"
onClick={(): void => {
goToMetricsExploreWithAppliedAttribute(attribute);
goToMetricsExploreWithAppliedAttribute(field.key, attribute);
}}
>
<Typography.Text>{attribute}</Typography.Text>

View File

@ -1,5 +1,6 @@
import { Button, Collapse, Input, Select, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
import { ResizeTable } from 'components/ResizeTable';
@ -14,6 +15,7 @@ import { METRIC_TYPE_LABEL_MAP } from '../Summary/constants';
import { MetricTypeRenderer } from '../Summary/utils';
import { METRIC_METADATA_KEYS } from './constants';
import { MetadataProps } from './types';
import { determineIsMonotonic } from './utils';
function Metadata({
metricName,
@ -25,9 +27,10 @@ function Metadata({
metricMetadata,
setMetricMetadata,
] = useState<UpdateMetricMetadataProps>({
type: metadata.metric_type,
description: metadata.description,
unit: metadata.unit,
metricType: metadata?.metric_type || MetricType.SUM,
description: metadata?.description || '',
unit: metadata?.unit || '',
temporality: metadata?.temporality || Temporality.CUMULATIVE,
});
const { notifications } = useNotifications();
const {
@ -41,7 +44,10 @@ function Metadata({
const tableData = useMemo(
() =>
metadata
? Object.keys(metadata).map((key) => ({
? Object.keys(metadata)
// Filter out isMonotonic as user input is not required
.filter((key) => key !== 'isMonotonic')
.map((key) => ({
key,
value: {
value: metadata[key as keyof typeof metadata],
@ -93,11 +99,28 @@ function Metadata({
value: key,
label: value,
}))}
value={metricMetadata.type}
value={metricMetadata.metricType}
onChange={(value): void => {
setMetricMetadata({
...metricMetadata,
type: value as MetricType,
metricType: value as MetricType,
});
}}
/>
);
}
if (field.key === 'temporality') {
return (
<Select
options={Object.values(Temporality).map((key) => ({
value: key,
label: key,
}))}
value={metricMetadata.temporality}
onChange={(value): void => {
setMetricMetadata({
...metricMetadata,
temporality: value as Temporality,
});
}}
/>
@ -106,7 +129,11 @@ function Metadata({
return (
<Input
name={field.key}
value={metricMetadata[field.key as keyof UpdateMetricMetadataProps]}
value={
metricMetadata[
field.key as Exclude<keyof UpdateMetricMetadataProps, 'isMonotonic'>
]
}
onChange={(e): void => {
setMetricMetadata({ ...metricMetadata, [field.key]: e.target.value });
}}
@ -122,7 +149,13 @@ function Metadata({
updateMetricMetadata(
{
metricName,
payload: metricMetadata,
payload: {
...metricMetadata,
isMonotonic: determineIsMonotonic(
metricMetadata.metricType,
metricMetadata.temporality,
),
},
},
{
onSuccess: (response): void => {

View File

@ -2,9 +2,19 @@ import './MetricDetails.styles.scss';
import '../Summary/Summary.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, Skeleton, Tooltip, Typography } from 'antd';
import {
Button,
Divider,
Drawer,
Empty,
Skeleton,
Tooltip,
Typography,
} from 'antd';
import ROUTES from 'constants/routes';
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Compass, X } from 'lucide-react';
import { useCallback, useMemo } from 'react';
@ -16,6 +26,7 @@ import { MetricDetailsProps } from './types';
import {
formatNumberToCompactFormat,
formatTimestampToReadableDate,
getMetricDetailsQuery,
} from './utils';
function MetricDetails({
@ -24,10 +35,13 @@ function MetricDetails({
metricName,
}: MetricDetailsProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { safeNavigate } = useSafeNavigate();
const {
data,
isLoading,
isFetching,
error: metricDetailsError,
refetch: refetchMetricDetails,
} = useGetMetricDetails(metricName ?? '', {
enabled: !!metricName,
@ -40,7 +54,7 @@ function MetricDetails({
return formatTimestampToReadableDate(metric.lastReceived);
}, [metric]);
const isMetricDetailsLoading = isLoading || isFetching || !metric;
const isMetricDetailsLoading = isLoading || isFetching;
const timeSeries = useMemo(() => {
if (!metric) return null;
@ -50,21 +64,28 @@ function MetricDetails({
}, [metric]);
const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
// TODO: Implement this when explore page is ready
console.log(metricName);
}, [metricName]);
if (metricName) {
const compositeQuery = getMetricDetailsQuery(metricName);
const encodedCompositeQuery = JSON.stringify(compositeQuery);
safeNavigate(
`${ROUTES.METRICS_EXPLORER_EXPLORER}?compositeQuery=${encodedCompositeQuery}`,
);
}
}, [metricName, safeNavigate]);
const top5Attributes = useMemo(() => {
if (!metric) return [];
const totalSum =
metric?.attributes.reduce((acc, curr) => acc + curr.valueCount, 0) || 0;
if (!metric) return [];
return metric.attributes.slice(0, 5).map((attr) => ({
return metric?.attributes.slice(0, 5).map((attr) => ({
key: attr.key,
count: attr.valueCount,
percentage: totalSum === 0 ? 0 : (attr.valueCount / totalSum) * 100,
}));
}, [metric]);
const isMetricDetailsError = metricDetailsError || !metric;
return (
<Drawer
width="60%"
@ -77,6 +98,7 @@ function MetricDetails({
<Button
onClick={goToMetricsExplorerwithSelectedMetric}
icon={<Compass size={16} />}
disabled={!metricName}
>
Open in Explorer
</Button>
@ -93,9 +115,11 @@ function MetricDetails({
destroyOnClose
closeIcon={<X size={16} />}
>
{isMetricDetailsLoading ? (
<Skeleton active />
) : (
{isMetricDetailsLoading && <Skeleton active />}
{isMetricDetailsError && !isMetricDetailsLoading && (
<Empty description="Error fetching metric details" />
)}
{!isMetricDetailsLoading && !isMetricDetailsError && (
<div className="metric-details-content">
<div className="metric-details-content-grid">
<div className="labels-row">

View File

@ -2,4 +2,5 @@ export const METRIC_METADATA_KEYS = {
description: 'Description',
unit: 'Unit',
metric_type: 'Metric Type',
temporality: 'Temporality',
};

View File

@ -19,7 +19,7 @@ export interface DashboardsAndAlertsPopoverProps {
export interface MetadataProps {
metricName: string;
metadata: MetricDetails['metadata'];
metadata: MetricDetails['metadata'] | undefined;
refetchMetricDetails: () => void;
}

View File

@ -1,3 +1,10 @@
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { initialQueriesMap } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export function formatTimestampToReadableDate(timestamp: string): string {
const date = new Date(timestamp);
// Extracting date components
@ -19,3 +26,59 @@ export function formatNumberToCompactFormat(num: number): string {
maximumFractionDigits: 1,
}).format(num);
}
export function determineIsMonotonic(
metricType: MetricType,
temporality: Temporality,
): boolean {
if (metricType === MetricType.HISTOGRAM) {
return true;
}
if (metricType === MetricType.GAUGE || metricType === MetricType.SUMMARY) {
return false;
}
if (metricType === MetricType.SUM) {
return temporality === Temporality.CUMULATIVE;
}
return false;
}
export function getMetricDetailsQuery(
metricName: string,
filter?: { key: string; value: string },
): Query {
return {
...initialQueriesMap[DataSource.METRICS],
builder: {
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
key: metricName,
type: DataTypes.String,
id: `${metricName}----string--`,
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filters: {
op: 'AND',
items: filter
? [
{
op: '=',
id: filter.key,
value: filter.value,
key: {
key: filter.key,
type: DataTypes.String,
},
},
]
: [],
},
},
],
queryFormulas: [],
},
};
}

View File

@ -0,0 +1,19 @@
.loading-metrics {
padding: 24px 0;
height: 240px;
display: flex;
justify-content: center;
align-items: flex-start;
.loading-metrics-content {
display: flex;
align-items: flex-start;
flex-direction: column;
.loading-gif {
height: 72px;
margin-left: -24px;
}
}
}

View File

@ -0,0 +1,24 @@
import './MetricsLoading.styles.scss';
import { Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { DataSource } from 'types/common/queryBuilder';
export function MetricsLoading(): JSX.Element {
const { t } = useTranslation('common');
return (
<div className="loading-metrics">
<div className="loading-metrics-content">
<img
className="loading-gif"
src="/Icons/loading-plane.gif"
alt="wait-icon"
/>
<Typography>
{t('pending_data_placeholder', { dataSource: DataSource.METRICS })}
</Typography>
</div>
</div>
);
}

View File

@ -7,7 +7,7 @@ import { useGetMetricsTreeMap } from 'hooks/metricsExplorer/useGetMetricsTreeMap
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@ -99,6 +99,15 @@ function Summary(): JSX.Element {
enabled: !!metricsTreemapQuery,
});
// Reset the filters when the component mounts
useEffect(() => {
handleChangeQueryData('filters', {
op: 'AND',
items: [],
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleFilterChange = useCallback(
(value: TagFilter) => {
handleChangeQueryData('filters', value);

View File

@ -6,6 +6,7 @@ import { QueryParams } from 'constants/query';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import LogsError from 'container/LogsError/LogsError';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { MetricsLoading } from 'container/MetricsExplorer/MetricsLoading/MetricsLoading';
import NoLogs from 'container/NoLogs/NoLogs';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
@ -131,6 +132,10 @@ function TimeSeriesView({
logEvent('Logs Explorer: Data present', {
panelType: 'TIME_SERIES',
});
} else if (dataSource === DataSource.METRICS) {
logEvent('Metrics Explorer: Data present', {
panelType: 'TIME_SERIES',
});
}
}
}, [isLoading, isError, chartData, dataSource]);
@ -164,8 +169,9 @@ function TimeSeriesView({
ref={graphRef}
data-testid="time-series-graph"
>
{isLoading &&
(dataSource === DataSource.LOGS ? <LogsLoading /> : <TracesLoading />)}
{isLoading && dataSource === DataSource.LOGS && <LogsLoading />}
{isLoading && dataSource === DataSource.TRACES && <TracesLoading />}
{isLoading && dataSource === DataSource.METRICS && <MetricsLoading />}
{chartData &&
chartData[0] &&

View File

@ -5,12 +5,12 @@ import { TabRoutes } from 'components/RouteTab/types';
import history from 'lib/history';
import { useLocation } from 'react-use';
import { Explorer, Summary, Views } from './constants';
import { Explorer, Summary } from './constants';
function MetricsExplorerPage(): JSX.Element {
const { pathname } = useLocation();
const routes: TabRoutes[] = [Summary, Explorer, Views];
const routes: TabRoutes[] = [Summary, Explorer];
return (
<div className="metrics-explorer-page">