mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-27 09:22:12 +00:00
Compare commits
2 Commits
update-PR-
...
SIG-3496
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d228d8c1e0 | ||
|
|
10ba210d2a |
14
.github/pull_request_template.md
vendored
14
.github/pull_request_template.md
vendored
@@ -11,20 +11,6 @@
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
> Fill this only if the change affects users, APIs, UI, or documented behavior.
|
||||
Mention as N/A for internal refactors or non-user-visible changes.
|
||||
|
||||
**Deployment Type:** Cloud / OSS / Enterprise
|
||||
|
||||
**Type:** Feature / Bug Fix / Maintenance
|
||||
|
||||
**Description:** Short, user-facing summary of the change
|
||||
|
||||
---
|
||||
|
||||
## 🏷️ Required: Add Relevant Labels
|
||||
|
||||
> ⚠️ **Manually add appropriate labels in the PR sidebar**
|
||||
|
||||
@@ -853,7 +853,7 @@ paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoints promotes and indexes paths
|
||||
operationId: ListPromotedAndIndexedPaths
|
||||
operationId: PromotePaths
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -883,11 +883,13 @@ paths:
|
||||
description: Internal Server Error
|
||||
summary: Promote and index paths
|
||||
tags:
|
||||
- promoted_paths
|
||||
- logs
|
||||
- json_logs
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoints promotes and indexes paths
|
||||
operationId: HandlePromoteAndIndexPaths
|
||||
operationId: PromotePaths
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
@@ -913,7 +915,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
summary: Promote and index paths
|
||||
tags:
|
||||
- promoted_paths
|
||||
- logs
|
||||
- json_logs
|
||||
/api/v1/org/preferences:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
29
frontend/src/api/metricsExplorer/v2/getMetricAlerts.ts
Normal file
29
frontend/src/api/metricsExplorer/v2/getMetricAlerts.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetMetricAlertsResponse } from 'types/api/metricsExplorer/v2';
|
||||
|
||||
export const getMetricAlerts = async (
|
||||
metricName: string,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponseV2<GetMetricAlertsResponse>> => {
|
||||
try {
|
||||
const encodedMetricName = encodeURIComponent(metricName);
|
||||
const response = await axios.get('/metric/alerts', {
|
||||
params: {
|
||||
metricName: encodedMetricName,
|
||||
},
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
37
frontend/src/api/metricsExplorer/v2/getMetricAttributes.ts
Normal file
37
frontend/src/api/metricsExplorer/v2/getMetricAttributes.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
GetMetricAttributesRequest,
|
||||
GetMetricAttributesResponse,
|
||||
} from 'types/api/metricsExplorer/v2';
|
||||
|
||||
export const getMetricAttributes = async (
|
||||
{ metricName, start, end }: GetMetricAttributesRequest,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponseV2<GetMetricAttributesResponse>> => {
|
||||
try {
|
||||
const encodedMetricName = encodeURIComponent(metricName);
|
||||
const response = await axios.post(
|
||||
'/metrics/attributes',
|
||||
{
|
||||
metricName: encodedMetricName,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
{
|
||||
signal,
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
29
frontend/src/api/metricsExplorer/v2/getMetricDashboards.ts
Normal file
29
frontend/src/api/metricsExplorer/v2/getMetricDashboards.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetMetricDashboardsResponse } from 'types/api/metricsExplorer/v2';
|
||||
|
||||
export const getMetricDashboards = async (
|
||||
metricName: string,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponseV2<GetMetricDashboardsResponse>> => {
|
||||
try {
|
||||
const encodedMetricName = encodeURIComponent(metricName);
|
||||
const response = await axios.get('/metric/dashboards', {
|
||||
params: {
|
||||
metricName: encodedMetricName,
|
||||
},
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
29
frontend/src/api/metricsExplorer/v2/getMetricHighlights.ts
Normal file
29
frontend/src/api/metricsExplorer/v2/getMetricHighlights.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetMetricHighlightsResponse } from 'types/api/metricsExplorer/v2';
|
||||
|
||||
export const getMetricHighlights = async (
|
||||
metricName: string,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponseV2<GetMetricHighlightsResponse>> => {
|
||||
try {
|
||||
const encodedMetricName = encodeURIComponent(metricName);
|
||||
const response = await axios.get('/metric/highlights', {
|
||||
params: {
|
||||
metricName: encodedMetricName,
|
||||
},
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
29
frontend/src/api/metricsExplorer/v2/getMetricMetadata.ts
Normal file
29
frontend/src/api/metricsExplorer/v2/getMetricMetadata.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetMetricMetadataResponse } from 'types/api/metricsExplorer/v2';
|
||||
|
||||
export const getMetricMetadata = async (
|
||||
metricName: string,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponseV2<GetMetricMetadataResponse>> => {
|
||||
try {
|
||||
const encodedMetricName = encodeURIComponent(metricName);
|
||||
const response = await axios.get('/metrics/metadata', {
|
||||
params: {
|
||||
metricName: encodedMetricName,
|
||||
},
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
28
frontend/src/api/metricsExplorer/v2/updateMetricMetadata.ts
Normal file
28
frontend/src/api/metricsExplorer/v2/updateMetricMetadata.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
UpdateMetricMetadataRequest,
|
||||
UpdateMetricMetadataResponse,
|
||||
} from 'types/api/metricsExplorer/v2';
|
||||
|
||||
const updateMetricMetadata = async (
|
||||
metricName: string,
|
||||
props: UpdateMetricMetadataRequest,
|
||||
): Promise<SuccessResponseV2<UpdateMetricMetadataResponse>> => {
|
||||
try {
|
||||
const response = await axios.post(`/metrics/${metricName}/metadata`, {
|
||||
...props,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default updateMetricMetadata;
|
||||
@@ -32,7 +32,6 @@ const ADD_ONS_KEYS = {
|
||||
ORDER_BY: 'order_by',
|
||||
LIMIT: 'limit',
|
||||
LEGEND_FORMAT: 'legend_format',
|
||||
REDUCE_TO: 'reduce_to',
|
||||
};
|
||||
|
||||
const ADD_ONS_KEYS_TO_QUERY_PATH = {
|
||||
@@ -41,14 +40,13 @@ const ADD_ONS_KEYS_TO_QUERY_PATH = {
|
||||
[ADD_ONS_KEYS.ORDER_BY]: 'orderBy',
|
||||
[ADD_ONS_KEYS.LIMIT]: 'limit',
|
||||
[ADD_ONS_KEYS.LEGEND_FORMAT]: 'legend',
|
||||
[ADD_ONS_KEYS.REDUCE_TO]: 'reduceTo',
|
||||
};
|
||||
|
||||
const ADD_ONS = [
|
||||
{
|
||||
icon: <BarChart2 size={14} />,
|
||||
label: 'Group By',
|
||||
key: ADD_ONS_KEYS.GROUP_BY,
|
||||
key: 'group_by',
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
|
||||
@@ -56,7 +54,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Having',
|
||||
key: ADD_ONS_KEYS.HAVING,
|
||||
key: 'having',
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
@@ -65,7 +63,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Order By',
|
||||
key: ADD_ONS_KEYS.ORDER_BY,
|
||||
key: 'order_by',
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
@@ -74,7 +72,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Limit',
|
||||
key: ADD_ONS_KEYS.LIMIT,
|
||||
key: 'limit',
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
@@ -83,7 +81,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Legend format',
|
||||
key: ADD_ONS_KEYS.LEGEND_FORMAT,
|
||||
key: 'legend_format',
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
@@ -94,7 +92,7 @@ const ADD_ONS = [
|
||||
const REDUCE_TO = {
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Reduce to',
|
||||
key: ADD_ONS_KEYS.REDUCE_TO,
|
||||
key: 'reduce_to',
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
@@ -220,9 +218,10 @@ function QueryAddOns({
|
||||
);
|
||||
|
||||
const availableAddOnKeys = new Set(filteredAddOns.map((addOn) => addOn.key));
|
||||
|
||||
// Filter and set selected views: add-ons that are both active and available
|
||||
setSelectedViews(
|
||||
filteredAddOns.filter(
|
||||
ADD_ONS.filter(
|
||||
(addOn) =>
|
||||
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
|
||||
),
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
/* eslint-disable */
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -61,7 +55,16 @@ jest.mock('../QueryV2/QueryAddOns/HavingFilter/HavingFilter', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// ReduceToFilter is not mocked - we test the actual Ant Design Select component
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter',
|
||||
() => ({
|
||||
ReduceToFilter: ({ onChange }: any) => (
|
||||
<button data-testid="reduce-to" onClick={() => onChange('sum')}>
|
||||
ReduceToFilter
|
||||
</button>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
function baseQuery(overrides: Partial<any> = {}): any {
|
||||
return {
|
||||
@@ -137,7 +140,7 @@ describe('QueryAddOns', () => {
|
||||
expect(screen.getByTestId('order-by-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('limit input auto-opens when limit is set and changing it calls handler', async () => {
|
||||
it('limit input auto-opens when limit is set and changing it calls handler', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ limit: 5 })}
|
||||
@@ -180,88 +183,4 @@ describe('QueryAddOns', () => {
|
||||
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
|
||||
expect(limitInput.value).toBe('7');
|
||||
});
|
||||
|
||||
it('shows reduce-to add-on when showReduceTo is true', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('query-add-on-reduce_to')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('auto-opens reduce-to content when reduceTo is set', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ reduceTo: 'sum' })}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('reduce-to-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleSetQueryData when reduce-to value changes', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const query = baseQuery({
|
||||
reduceTo: 'avg',
|
||||
aggregations: [{ id: 'a', operator: 'count', reduceTo: 'avg' }],
|
||||
});
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for the reduce-to content section to be visible (it auto-opens when reduceTo is set)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reduce-to-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Get the Select component by its role (combobox)
|
||||
// The Select is within the reduce-to-content section
|
||||
const reduceToContent = screen.getByTestId('reduce-to-content');
|
||||
const selectCombobox = within(reduceToContent).getByRole('combobox');
|
||||
|
||||
// Open the dropdown by clicking on the combobox
|
||||
await user.click(selectCombobox);
|
||||
|
||||
// Wait for the dropdown listbox to appear
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
// Find and click the "Sum" option
|
||||
const sumOption = await screen.findByText('Sum of values in timeframe');
|
||||
await user.click(sumOption);
|
||||
|
||||
// Verify the handler was called with the correct value
|
||||
await waitFor(() => {
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(0, {
|
||||
...query,
|
||||
aggregations: [
|
||||
{
|
||||
...(query.aggregations?.[0] as any),
|
||||
reduceTo: 'sum',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,13 @@ export const REACT_QUERY_KEY = {
|
||||
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
|
||||
GET_INSPECT_METRICS_DETAILS: 'GET_INSPECT_METRICS_DETAILS',
|
||||
|
||||
// Metrics Explorer V2 Query Keys
|
||||
GET_METRIC_HIGHLIGHTS: 'GET_METRIC_HIGHLIGHTS',
|
||||
GET_METRIC_METADATA: 'GET_METRIC_METADATA',
|
||||
GET_METRIC_ATTRIBUTES: 'GET_METRIC_ATTRIBUTES',
|
||||
GET_METRIC_ALERTS: 'GET_METRIC_ALERTS',
|
||||
GET_METRIC_DASHBOARDS: 'GET_METRIC_DASHBOARDS',
|
||||
|
||||
// Traces Funnels Query Keys
|
||||
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',
|
||||
GET_DOMAIN_METRICS_DATA: 'GET_DOMAIN_METRICS_DATA',
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Button, Collapse, Input, Menu, Popover, Typography } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Input,
|
||||
Menu,
|
||||
Popover,
|
||||
Skeleton,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { useGetMetricAttributes } from 'hooks/metricsExplorer/v2/useGetMetricAttributes';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Compass, Copy, Search } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
@@ -13,7 +22,9 @@ import ROUTES from '../../../constants/routes';
|
||||
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import { AllAttributesProps, AllAttributesValueProps } from './types';
|
||||
import { getMetricDetailsQuery } from './utils';
|
||||
import { getMetricDetailsQuery, transformMetricAttributes } from './utils';
|
||||
|
||||
const ALL_ATTRIBUTES_KEY = 'all-attributes';
|
||||
|
||||
export function AllAttributesValue({
|
||||
filterKey,
|
||||
@@ -110,13 +121,20 @@ export function AllAttributesValue({
|
||||
|
||||
function AllAttributes({
|
||||
metricName,
|
||||
attributes,
|
||||
metricType,
|
||||
}: AllAttributesProps): JSX.Element {
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const [activeKey, setActiveKey] = useState<string | string[]>(
|
||||
'all-attributes',
|
||||
);
|
||||
const [activeKey, setActiveKey] = useState<string[]>([ALL_ATTRIBUTES_KEY]);
|
||||
|
||||
const {
|
||||
data: attributesData,
|
||||
isLoading: isLoadingAttributes,
|
||||
isError: isErrorAttributes,
|
||||
} = useGetMetricAttributes({
|
||||
metricName,
|
||||
});
|
||||
|
||||
const { attributes } = transformMetricAttributes(attributesData);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
@@ -178,7 +196,7 @@ function AllAttributes({
|
||||
attributes.filter(
|
||||
(attribute) =>
|
||||
attribute.key.toLowerCase().includes(searchString.toLowerCase()) ||
|
||||
attribute.value.some((value) =>
|
||||
attribute.values.some((value) =>
|
||||
value.toLowerCase().includes(searchString.toLowerCase()),
|
||||
),
|
||||
),
|
||||
@@ -195,7 +213,7 @@ function AllAttributes({
|
||||
},
|
||||
value: {
|
||||
key: attribute.key,
|
||||
value: attribute.value,
|
||||
value: attribute.values,
|
||||
},
|
||||
}))
|
||||
: [],
|
||||
@@ -252,8 +270,38 @@ function AllAttributes({
|
||||
],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
() => [
|
||||
const emptyText = useMemo(
|
||||
() =>
|
||||
isErrorAttributes ? 'Error fetching attributes' : 'No attributes found',
|
||||
[isErrorAttributes],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
let children;
|
||||
if (isLoadingAttributes) {
|
||||
children = (
|
||||
<div className="all-attributes-skeleton-container">
|
||||
<Skeleton active title={false} paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
children = (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
loading={isLoadingAttributes}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
className="metrics-accordion-content all-attributes-content"
|
||||
scroll={{ y: 600 }}
|
||||
locale={{
|
||||
emptyText,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: (
|
||||
<div className="metrics-accordion-header">
|
||||
@@ -270,32 +318,22 @@ function AllAttributes({
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isLoadingAttributes}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
key: 'all-attributes',
|
||||
children: (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
className="metrics-accordion-content all-attributes-content"
|
||||
scroll={{ y: 600 }}
|
||||
/>
|
||||
),
|
||||
children,
|
||||
},
|
||||
],
|
||||
[columns, tableData, searchString],
|
||||
);
|
||||
];
|
||||
}, [searchString, columns, isLoadingAttributes, tableData, emptyText]);
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered
|
||||
className="metrics-accordion metrics-metadata-accordion"
|
||||
className="metrics-accordion metrics-all-attributes-accordion"
|
||||
activeKey={activeKey}
|
||||
onChange={(keys): void => setActiveKey(keys)}
|
||||
onChange={(keys): void => setActiveKey(keys as string[])}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,37 +1,56 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Dropdown, Typography } from 'antd';
|
||||
import { Skeleton } from 'antd/lib';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetMetricAlerts } from 'hooks/metricsExplorer/v2/useGetMetricAlerts';
|
||||
import { useGetMetricDashboards } from 'hooks/metricsExplorer/v2/useGetMetricDashboards';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import history from 'lib/history';
|
||||
import { Bell, Grid } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
import { DashboardsAndAlertsPopoverProps } from './types';
|
||||
import { transformMetricAlerts, transformMetricDashboards } from './utils';
|
||||
|
||||
function DashboardsAndAlertsPopover({
|
||||
alerts,
|
||||
dashboards,
|
||||
metricName,
|
||||
}: DashboardsAndAlertsPopoverProps): JSX.Element | null {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const params = useUrlQuery();
|
||||
|
||||
const {
|
||||
data: alertsData,
|
||||
isLoading: isLoadingAlerts,
|
||||
isError: isErrorAlerts,
|
||||
} = useGetMetricAlerts(metricName);
|
||||
|
||||
const {
|
||||
data: dashboardsData,
|
||||
isLoading: isLoadingDashboards,
|
||||
isError: isErrorDashboards,
|
||||
} = useGetMetricDashboards(metricName);
|
||||
|
||||
const alerts = transformMetricAlerts(alertsData);
|
||||
const dashboards = transformMetricDashboards(dashboardsData);
|
||||
|
||||
const alertsPopoverContent = useMemo(() => {
|
||||
if (alerts && alerts.length > 0) {
|
||||
return alerts.map((alert) => ({
|
||||
key: alert.alert_id,
|
||||
key: alert.alertId,
|
||||
label: (
|
||||
<Typography.Link
|
||||
key={alert.alert_id}
|
||||
key={alert.alertId}
|
||||
onClick={(): void => {
|
||||
params.set(QueryParams.ruleId, alert.alert_id);
|
||||
params.set(QueryParams.ruleId, alert.alertId);
|
||||
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
}}
|
||||
className="dashboards-popover-content-item"
|
||||
>
|
||||
{alert.alert_name || alert.alert_id}
|
||||
{alert.alertName || alert.alertId}
|
||||
</Typography.Link>
|
||||
),
|
||||
}));
|
||||
@@ -39,41 +58,44 @@ function DashboardsAndAlertsPopover({
|
||||
return null;
|
||||
}, [alerts, params]);
|
||||
|
||||
const uniqueDashboards = useMemo(
|
||||
() =>
|
||||
dashboards?.filter(
|
||||
(item, index, self) =>
|
||||
index === self.findIndex((t) => t.dashboard_id === item.dashboard_id),
|
||||
),
|
||||
[dashboards],
|
||||
);
|
||||
|
||||
const dashboardsPopoverContent = useMemo(() => {
|
||||
if (uniqueDashboards && uniqueDashboards.length > 0) {
|
||||
return uniqueDashboards.map((dashboard) => ({
|
||||
key: dashboard.dashboard_id,
|
||||
if (dashboards && dashboards.length > 0) {
|
||||
return dashboards.map((dashboard) => ({
|
||||
key: dashboard.dashboardId,
|
||||
label: (
|
||||
<Typography.Link
|
||||
key={dashboard.dashboard_id}
|
||||
key={dashboard.dashboardId}
|
||||
onClick={(): void => {
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, {
|
||||
dashboardId: dashboard.dashboard_id,
|
||||
dashboardId: dashboard.dashboardId,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className="dashboards-popover-content-item"
|
||||
>
|
||||
{dashboard.dashboard_name || dashboard.dashboard_id}
|
||||
{dashboard.dashboardName || dashboard.dashboardId}
|
||||
</Typography.Link>
|
||||
),
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
}, [uniqueDashboards, safeNavigate]);
|
||||
}, [dashboards, safeNavigate]);
|
||||
|
||||
if (!dashboardsPopoverContent && !alertsPopoverContent) {
|
||||
return null;
|
||||
if (isLoadingAlerts || isLoadingDashboards) {
|
||||
return (
|
||||
<div className="dashboards-and-alerts-popover-container">
|
||||
<Skeleton title={false} paragraph={{ rows: 1 }} active />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If there are no dashboards or alerts or both have errors, don't show the popover
|
||||
const hidePopover =
|
||||
(!dashboardsPopoverContent && !alertsPopoverContent) ||
|
||||
(isErrorAlerts && isErrorDashboards);
|
||||
if (hidePopover) {
|
||||
return <div className="dashboards-and-alerts-popover-container" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -92,8 +114,7 @@ function DashboardsAndAlertsPopover({
|
||||
>
|
||||
<Grid size={12} color={Color.BG_SIENNA_500} />
|
||||
<Typography.Text>
|
||||
{uniqueDashboards?.length} dashboard
|
||||
{uniqueDashboards?.length === 1 ? '' : 's'}
|
||||
{pluralize(dashboards.length, 'dashboard', 'dashboards')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
@@ -112,7 +133,7 @@ function DashboardsAndAlertsPopover({
|
||||
>
|
||||
<Bell size={12} color={Color.BG_SAKURA_500} />
|
||||
<Typography.Text>
|
||||
{alerts?.length} alert {alerts?.length === 1 ? 'rule' : 'rules'}
|
||||
{pluralize(alerts.length, 'alert rule', 'alert rules')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { useGetMetricHighlights } from 'hooks/metricsExplorer/v2/useGetMetricHighlights';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
|
||||
import { HighlightsProps } from './types';
|
||||
import {
|
||||
formatNumberToCompactFormat,
|
||||
formatTimestampToReadableDate,
|
||||
transformMetricHighlights,
|
||||
} from './utils';
|
||||
|
||||
function Highlights({ metricName }: HighlightsProps): JSX.Element {
|
||||
const {
|
||||
data: metricHighlightsData,
|
||||
isLoading: isLoadingMetricHighlights,
|
||||
isError: isErrorMetricHighlights,
|
||||
} = useGetMetricHighlights(metricName ?? '', {
|
||||
enabled: !!metricName,
|
||||
});
|
||||
|
||||
const metricHighlights = transformMetricHighlights(metricHighlightsData);
|
||||
|
||||
const dataPoints = useMemo(() => {
|
||||
if (!metricHighlights) return null;
|
||||
if (isErrorMetricHighlights) {
|
||||
return (
|
||||
<Typography.Text className="metric-details-grid-value">-</Typography.Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography.Text className="metric-details-grid-value">
|
||||
<Tooltip title={metricHighlights?.dataPoints.toLocaleString()}>
|
||||
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
);
|
||||
}, [metricHighlights, isErrorMetricHighlights]);
|
||||
|
||||
const timeSeries = useMemo(() => {
|
||||
if (!metricHighlights) return null;
|
||||
if (isErrorMetricHighlights) {
|
||||
return (
|
||||
<Typography.Text className="metric-details-grid-value">-</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
const timeSeriesActive = formatNumberToCompactFormat(
|
||||
metricHighlights.activeTimeSeries,
|
||||
);
|
||||
const timeSeriesTotal = formatNumberToCompactFormat(
|
||||
metricHighlights.totalTimeSeries,
|
||||
);
|
||||
|
||||
return (
|
||||
<Typography.Text className="metric-details-grid-value">
|
||||
<Tooltip
|
||||
title="Active time series are those that have received data points in the last 1
|
||||
hour."
|
||||
placement="top"
|
||||
>
|
||||
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
);
|
||||
}, [metricHighlights, isErrorMetricHighlights]);
|
||||
|
||||
const lastReceived = useMemo(() => {
|
||||
if (!metricHighlights) return null;
|
||||
if (isErrorMetricHighlights) {
|
||||
return (
|
||||
<Typography.Text className="metric-details-grid-value">-</Typography.Text>
|
||||
);
|
||||
}
|
||||
const displayText = formatTimestampToReadableDate(
|
||||
metricHighlights.lastReceived,
|
||||
);
|
||||
return (
|
||||
<Typography.Text className="metric-details-grid-value">
|
||||
<Tooltip title={displayText}>{displayText}</Tooltip>
|
||||
</Typography.Text>
|
||||
);
|
||||
}, [metricHighlights, isErrorMetricHighlights]);
|
||||
|
||||
if (isLoadingMetricHighlights) {
|
||||
return (
|
||||
<div className="metric-details-content-grid">
|
||||
<Skeleton title={false} paragraph={{ rows: 2 }} active />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="metric-details-content-grid">
|
||||
<div className="labels-row">
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
SAMPLES
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
TIME SERIES
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
LAST RECEIVED
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="values-row">
|
||||
{dataPoints}
|
||||
{timeSeries}
|
||||
{lastReceived}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Highlights;
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Button, Collapse, Input, Select, Typography } from 'antd';
|
||||
import { Button, Collapse, Input, Select, Skeleton, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { useUpdateMetricMetadata } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import { useUpdateMetricMetadata } from 'hooks/metricsExplorer/v2/useUpdateMetricMetadata';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Edit2, Save, X } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
@@ -23,23 +23,22 @@ import {
|
||||
} from '../Summary/constants';
|
||||
import { MetricTypeRenderer } from '../Summary/utils';
|
||||
import { METRIC_METADATA_KEYS } from './constants';
|
||||
import { MetadataProps } from './types';
|
||||
import { determineIsMonotonic } from './utils';
|
||||
import { MetadataProps, MetricMetadataState, TableFields } from './types';
|
||||
import { transformUpdateMetricMetadataRequest } from './utils';
|
||||
|
||||
function Metadata({
|
||||
metricName,
|
||||
metadata,
|
||||
refetchMetricDetails,
|
||||
isErrorMetricMetadata,
|
||||
isLoadingMetricMetadata,
|
||||
}: MetadataProps): JSX.Element {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [
|
||||
metricMetadata,
|
||||
setMetricMetadata,
|
||||
] = useState<UpdateMetricMetadataProps>({
|
||||
metricType: metadata?.metric_type || MetricType.SUM,
|
||||
description: metadata?.description || '',
|
||||
temporality: metadata?.temporality,
|
||||
unit: metadata?.unit,
|
||||
|
||||
const [metricMetadata, setMetricMetadata] = useState<MetricMetadataState>({
|
||||
metricType: MetricType.SUM,
|
||||
description: '',
|
||||
temporality: undefined,
|
||||
unit: undefined,
|
||||
});
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
@@ -51,6 +50,18 @@ function Metadata({
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Initialize state from metadata api data
|
||||
useEffect(() => {
|
||||
if (metadata) {
|
||||
setMetricMetadata({
|
||||
metricType: metadata.metricType,
|
||||
description: metadata.description,
|
||||
temporality: metadata.temporality,
|
||||
unit: metadata.unit,
|
||||
});
|
||||
}
|
||||
}, [metadata]);
|
||||
|
||||
const tableData = useMemo(
|
||||
() =>
|
||||
metadata
|
||||
@@ -59,7 +70,7 @@ function Metadata({
|
||||
temporality: metadata?.temporality,
|
||||
})
|
||||
// Filter out monotonic as user input is not required
|
||||
.filter((key) => key !== 'monotonic')
|
||||
.filter((key) => key !== TableFields.IS_MONOTONIC)
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: {
|
||||
@@ -72,30 +83,37 @@ function Metadata({
|
||||
);
|
||||
|
||||
// Render un-editable field value
|
||||
const renderUneditableField = useCallback((key: string, value: string) => {
|
||||
if (key === 'metric_type') {
|
||||
return <MetricTypeRenderer type={value as MetricType} />;
|
||||
}
|
||||
let fieldValue = value;
|
||||
if (key === 'unit') {
|
||||
fieldValue = getUniversalNameFromMetricUnit(value);
|
||||
}
|
||||
return <FieldRenderer field={fieldValue || '-'} />;
|
||||
}, []);
|
||||
const renderUneditableField = useCallback(
|
||||
(key: keyof MetricMetadataState, value: string) => {
|
||||
if (isErrorMetricMetadata) {
|
||||
return <FieldRenderer field="-" />;
|
||||
}
|
||||
if (key === TableFields.METRIC_TYPE) {
|
||||
return <MetricTypeRenderer type={value as MetricType} />;
|
||||
}
|
||||
let fieldValue = value;
|
||||
if (key === TableFields.UNIT) {
|
||||
fieldValue = getUniversalNameFromMetricUnit(value);
|
||||
}
|
||||
return <FieldRenderer field={fieldValue || '-'} />;
|
||||
},
|
||||
[isErrorMetricMetadata],
|
||||
);
|
||||
|
||||
const renderColumnValue = useCallback(
|
||||
(field: { value: string; key: string }): JSX.Element => {
|
||||
(field: { value: string; key: keyof MetricMetadataState }): JSX.Element => {
|
||||
if (!isEditing) {
|
||||
return renderUneditableField(field.key, field.value);
|
||||
}
|
||||
|
||||
// Don't allow editing of unit if it's already set
|
||||
const metricUnitAlreadySet = field.key === 'unit' && Boolean(metadata?.unit);
|
||||
const metricUnitAlreadySet =
|
||||
field.key === TableFields.UNIT && Boolean(metadata?.unit);
|
||||
if (metricUnitAlreadySet) {
|
||||
return renderUneditableField(field.key, field.value);
|
||||
}
|
||||
|
||||
if (field.key === 'metric_type') {
|
||||
if (field.key === TableFields.METRIC_TYPE) {
|
||||
return (
|
||||
<Select
|
||||
data-testid="metric-type-select"
|
||||
@@ -113,7 +131,7 @@ function Metadata({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'unit') {
|
||||
if (field.key === TableFields.UNIT) {
|
||||
return (
|
||||
<YAxisUnitSelector
|
||||
value={metricMetadata.unit}
|
||||
@@ -125,7 +143,7 @@ function Metadata({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'temporality') {
|
||||
if (field.key === TableFields.Temporality) {
|
||||
return (
|
||||
<Select
|
||||
data-testid="temporality-select"
|
||||
@@ -143,16 +161,12 @@ function Metadata({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'description') {
|
||||
if (field.key === TableFields.DESCRIPTION) {
|
||||
return (
|
||||
<Input
|
||||
data-testid="description-input"
|
||||
name={field.key}
|
||||
defaultValue={
|
||||
metricMetadata[
|
||||
field.key as Exclude<keyof UpdateMetricMetadataProps, 'isMonotonic'>
|
||||
]
|
||||
}
|
||||
defaultValue={metricMetadata.description}
|
||||
onChange={(e): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
@@ -202,17 +216,11 @@ function Metadata({
|
||||
updateMetricMetadata(
|
||||
{
|
||||
metricName,
|
||||
payload: {
|
||||
...metricMetadata,
|
||||
isMonotonic: determineIsMonotonic(
|
||||
metricMetadata.metricType,
|
||||
metricMetadata.temporality,
|
||||
),
|
||||
},
|
||||
payload: transformUpdateMetricMetadataRequest(metricMetadata),
|
||||
},
|
||||
{
|
||||
onSuccess: (response): void => {
|
||||
if (response?.statusCode === 200) {
|
||||
if (response?.httpStatusCode === 200) {
|
||||
logEvent(MetricsExplorerEvents.MetricMetadataUpdated, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
@@ -221,9 +229,12 @@ function Metadata({
|
||||
notifications.success({
|
||||
message: 'Metadata updated successfully',
|
||||
});
|
||||
refetchMetricDetails();
|
||||
setIsEditing(false);
|
||||
queryClient.invalidateQueries(['metricsList']);
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_METRICS_LIST]);
|
||||
queryClient.invalidateQueries([
|
||||
REACT_QUERY_KEY.GET_METRIC_METADATA,
|
||||
metricName,
|
||||
]);
|
||||
} else {
|
||||
notifications.error({
|
||||
message:
|
||||
@@ -243,21 +254,36 @@ function Metadata({
|
||||
metricName,
|
||||
metricMetadata,
|
||||
notifications,
|
||||
refetchMetricDetails,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const cancelEdit = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement, MouseEvent>): void => {
|
||||
e.stopPropagation();
|
||||
if (metadata) {
|
||||
setMetricMetadata({
|
||||
metricType: metadata.metricType,
|
||||
description: metadata.description,
|
||||
temporality: metadata.temporality,
|
||||
unit: metadata.unit,
|
||||
});
|
||||
}
|
||||
setIsEditing(false);
|
||||
},
|
||||
[metadata],
|
||||
);
|
||||
|
||||
const actionButton = useMemo(() => {
|
||||
if (isLoadingMetricMetadata) {
|
||||
return null;
|
||||
}
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="action-menu">
|
||||
<Button
|
||||
className="action-button"
|
||||
type="text"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(false);
|
||||
}}
|
||||
onClick={cancelEdit}
|
||||
disabled={isUpdatingMetricsMetadata}
|
||||
>
|
||||
<X size={14} />
|
||||
@@ -294,10 +320,35 @@ function Metadata({
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}, [handleSave, isEditing, isUpdatingMetricsMetadata]);
|
||||
}, [
|
||||
isLoadingMetricMetadata,
|
||||
isEditing,
|
||||
isUpdatingMetricsMetadata,
|
||||
cancelEdit,
|
||||
handleSave,
|
||||
]);
|
||||
|
||||
const items = useMemo(
|
||||
() => [
|
||||
const items = useMemo(() => {
|
||||
let children;
|
||||
if (isLoadingMetricMetadata) {
|
||||
children = (
|
||||
<div className="metrics-metadata-skeleton-container">
|
||||
<Skeleton active title={false} paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
children = (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
className="metrics-accordion-content metrics-metadata-container"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: (
|
||||
<div className="metrics-accordion-header metrics-metadata-header">
|
||||
@@ -306,20 +357,10 @@ function Metadata({
|
||||
</div>
|
||||
),
|
||||
key: 'metric-metadata',
|
||||
children: (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
className="metrics-accordion-content metrics-metadata-container"
|
||||
/>
|
||||
),
|
||||
children,
|
||||
},
|
||||
],
|
||||
[actionButton, columns, tableData],
|
||||
);
|
||||
];
|
||||
}, [actionButton, columns, isLoadingMetricMetadata, tableData]);
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
gap: 12px;
|
||||
|
||||
.metric-details-content-grid {
|
||||
height: 50px;
|
||||
.labels-row,
|
||||
.values-row {
|
||||
display: grid;
|
||||
@@ -72,6 +73,7 @@
|
||||
.dashboards-and-alerts-popover-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
height: 32px;
|
||||
|
||||
.dashboards-and-alerts-popover {
|
||||
border-radius: 20px;
|
||||
@@ -148,7 +150,6 @@
|
||||
|
||||
.all-attributes-search-input {
|
||||
width: 300px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +162,7 @@
|
||||
.ant-typography:first-child {
|
||||
font-family: 'Geist Mono';
|
||||
color: var(--bg-robin-400);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
.all-attributes-contribution {
|
||||
@@ -237,6 +239,7 @@
|
||||
}
|
||||
|
||||
.metric-metadata-value {
|
||||
height: 67px;
|
||||
background: rgba(22, 25, 34, 0.4);
|
||||
overflow-x: scroll;
|
||||
.field-renderer-container {
|
||||
@@ -266,6 +269,33 @@
|
||||
border-top-width: 0.5px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-metadata-accordion {
|
||||
.ant-collapse-item {
|
||||
.ant-collapse-content {
|
||||
height: 268px;
|
||||
|
||||
.ant-collapse-content-box {
|
||||
.metrics-metadata-skeleton-container {
|
||||
margin: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-all-attributes-accordion {
|
||||
.ant-collapse-item {
|
||||
.ant-collapse-content {
|
||||
height: 600px;
|
||||
.ant-collapse-content-box {
|
||||
.all-attributes-skeleton-container {
|
||||
margin: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
@@ -330,18 +360,26 @@
|
||||
.metric-details-content {
|
||||
.metrics-accordion {
|
||||
.metrics-accordion-header {
|
||||
.action-button {
|
||||
.ant-typography {
|
||||
color: var(--bg-slate-400);
|
||||
.action-menu {
|
||||
.action-button {
|
||||
.ant-typography {
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-accordion-content {
|
||||
.metric-metadata-key {
|
||||
.field-renderer-container {
|
||||
.label {
|
||||
color: var(--bg-slate-300);
|
||||
}
|
||||
}
|
||||
|
||||
.all-attributes-key {
|
||||
.ant-typography:last-child {
|
||||
color: var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-200);
|
||||
background-color: var(--bg-robin-300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,9 @@ import './MetricDetails.styles.scss';
|
||||
import '../Summary/Summary.styles.scss';
|
||||
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Drawer,
|
||||
Empty,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { Button, Divider, Drawer, Empty, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
|
||||
import { useGetMetricMetadata } from 'hooks/metricsExplorer/v2/useGetMetricMetadata';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Compass, Crosshair, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
@@ -22,16 +14,12 @@ import ROUTES from '../../../constants/routes';
|
||||
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import { isInspectEnabled } from '../Inspect/utils';
|
||||
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
|
||||
import AllAttributes from './AllAttributes';
|
||||
import DashboardsAndAlertsPopover from './DashboardsAndAlertsPopover';
|
||||
import Highlights from './Highlights';
|
||||
import Metadata from './Metadata';
|
||||
import { MetricDetailsProps } from './types';
|
||||
import {
|
||||
formatNumberToCompactFormat,
|
||||
formatTimestampToReadableDate,
|
||||
getMetricDetailsQuery,
|
||||
} from './utils';
|
||||
import { getMetricDetailsQuery, transformMetricMetadata } from './utils';
|
||||
|
||||
function MetricDetails({
|
||||
onClose,
|
||||
@@ -43,50 +31,25 @@ function MetricDetails({
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error: metricDetailsError,
|
||||
refetch: refetchMetricDetails,
|
||||
} = useGetMetricDetails(metricName ?? '', {
|
||||
data: metricMetadataResponse,
|
||||
isLoading: isLoadingMetricMetadata,
|
||||
isError: isErrorMetricMetadata,
|
||||
} = useGetMetricMetadata(metricName ?? '', {
|
||||
enabled: !!metricName,
|
||||
});
|
||||
|
||||
const metric = data?.payload?.data;
|
||||
|
||||
const lastReceived = useMemo(() => {
|
||||
if (!metric) return null;
|
||||
return formatTimestampToReadableDate(metric.lastReceived);
|
||||
}, [metric]);
|
||||
const metadata = transformMetricMetadata(metricMetadataResponse);
|
||||
|
||||
const showInspectFeature = useMemo(
|
||||
() => isInspectEnabled(metric?.metadata?.metric_type),
|
||||
[metric],
|
||||
() => isInspectEnabled(metadata?.metricType),
|
||||
[metadata],
|
||||
);
|
||||
|
||||
const isMetricDetailsLoading = isLoading || isFetching;
|
||||
|
||||
const timeSeries = useMemo(() => {
|
||||
if (!metric) return null;
|
||||
const timeSeriesActive = formatNumberToCompactFormat(metric.timeSeriesActive);
|
||||
const timeSeriesTotal = formatNumberToCompactFormat(metric.timeSeriesTotal);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title="Active time series are those that have received data points in the last 1
|
||||
hour."
|
||||
placement="top"
|
||||
>
|
||||
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [metric]);
|
||||
|
||||
const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
|
||||
if (metricName) {
|
||||
const compositeQuery = getMetricDetailsQuery(
|
||||
metricName,
|
||||
metric?.metadata?.metric_type,
|
||||
metadata?.metricType,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -103,9 +66,7 @@ function MetricDetails({
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
});
|
||||
}
|
||||
}, [metricName, handleExplorerTabChange, metric?.metadata?.metric_type]);
|
||||
|
||||
const isMetricDetailsError = metricDetailsError || !metric;
|
||||
}, [metricName, handleExplorerTabChange, metadata?.metricType]);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.ModalOpened, {
|
||||
@@ -113,6 +74,10 @@ function MetricDetails({
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!metricName) {
|
||||
return <Empty description="Metric not found" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="60%"
|
||||
@@ -120,7 +85,7 @@ function MetricDetails({
|
||||
<div className="metric-details-header">
|
||||
<div className="metric-details-title">
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text>{metric?.name}</Typography.Text>
|
||||
<Typography.Text>{metricName}</Typography.Text>
|
||||
</div>
|
||||
<div className="metric-details-header-buttons">
|
||||
<Button
|
||||
@@ -138,8 +103,8 @@ function MetricDetails({
|
||||
aria-label="Inspect Metric"
|
||||
icon={<Crosshair size={18} />}
|
||||
onClick={(): void => {
|
||||
if (metric?.name) {
|
||||
openInspectModal(metric.name);
|
||||
if (metricName) {
|
||||
openInspectModal(metricName);
|
||||
}
|
||||
}}
|
||||
data-testid="inspect-metric-button"
|
||||
@@ -159,60 +124,17 @@ function MetricDetails({
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} />}
|
||||
>
|
||||
{isMetricDetailsLoading && (
|
||||
<div data-testid="metric-details-skeleton">
|
||||
<Skeleton active />
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
SAMPLES
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
TIME SERIES
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
LAST RECEIVED
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="values-row">
|
||||
<Typography.Text className="metric-details-grid-value">
|
||||
<Tooltip title={metric?.samples.toLocaleString()}>
|
||||
{formatNumberIntoHumanReadableFormat(metric?.samples)}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="metric-details-grid-value">
|
||||
<Tooltip title={timeSeries}>{timeSeries}</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="metric-details-grid-value">
|
||||
<Tooltip title={lastReceived}>{lastReceived}</Tooltip>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardsAndAlertsPopover
|
||||
dashboards={metric.dashboards}
|
||||
alerts={metric.alerts}
|
||||
/>
|
||||
<Metadata
|
||||
metricName={metric?.name}
|
||||
metadata={metric.metadata}
|
||||
refetchMetricDetails={refetchMetricDetails}
|
||||
/>
|
||||
{metric.attributes && (
|
||||
<AllAttributes
|
||||
metricName={metric?.name}
|
||||
attributes={metric.attributes}
|
||||
metricType={metric?.metadata?.metric_type}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="metric-details-content">
|
||||
<Highlights metricName={metricName} />
|
||||
<DashboardsAndAlertsPopover metricName={metricName} />
|
||||
<Metadata
|
||||
metricName={metricName}
|
||||
metadata={metadata}
|
||||
isErrorMetricMetadata={isErrorMetricMetadata}
|
||||
isLoadingMetricMetadata={isLoadingMetricMetadata}
|
||||
/>
|
||||
<AllAttributes metricName={metricName} metricType={metadata?.metricType} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const METRIC_METADATA_KEYS = {
|
||||
description: 'Description',
|
||||
unit: 'Unit',
|
||||
metric_type: 'Metric Type',
|
||||
metricType: 'Metric Type',
|
||||
temporality: 'Temporality',
|
||||
};
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
MetricDetails,
|
||||
MetricDetailsAlert,
|
||||
MetricDetailsAttribute,
|
||||
MetricDetailsDashboard,
|
||||
} from 'api/metricsExplorer/getMetricDetails';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
|
||||
export interface MetricDetailsProps {
|
||||
@@ -14,19 +9,21 @@ export interface MetricDetailsProps {
|
||||
openInspectModal: (metricName: string) => void;
|
||||
}
|
||||
|
||||
export interface HighlightsProps {
|
||||
metricName: string;
|
||||
}
|
||||
export interface DashboardsAndAlertsPopoverProps {
|
||||
dashboards: MetricDetailsDashboard[] | null;
|
||||
alerts: MetricDetailsAlert[] | null;
|
||||
metricName: string;
|
||||
}
|
||||
|
||||
export interface MetadataProps {
|
||||
metricName: string;
|
||||
metadata: MetricDetails['metadata'] | undefined;
|
||||
refetchMetricDetails: () => void;
|
||||
metadata: MetricMetadata | null;
|
||||
isErrorMetricMetadata: boolean;
|
||||
isLoadingMetricMetadata: boolean;
|
||||
}
|
||||
|
||||
export interface AllAttributesProps {
|
||||
attributes: MetricDetailsAttribute[];
|
||||
metricName: string;
|
||||
metricType: MetricType | undefined;
|
||||
}
|
||||
@@ -36,3 +33,51 @@ export interface AllAttributesValueProps {
|
||||
filterValue: string[];
|
||||
goToMetricsExploreWithAppliedAttribute: (key: string, value: string) => void;
|
||||
}
|
||||
|
||||
export interface MetricHighlight {
|
||||
dataPoints: number;
|
||||
lastReceived: number;
|
||||
totalTimeSeries: number;
|
||||
activeTimeSeries: number;
|
||||
}
|
||||
|
||||
export interface MetricAlert {
|
||||
alertName: string;
|
||||
alertId: string;
|
||||
}
|
||||
|
||||
export interface MetricDashboard {
|
||||
dashboardName: string;
|
||||
dashboardId: string;
|
||||
widgetId: string;
|
||||
widgetName: string;
|
||||
}
|
||||
|
||||
export interface MetricMetadata {
|
||||
metricType: MetricType;
|
||||
description: string;
|
||||
unit: string;
|
||||
temporality: Temporality;
|
||||
isMonotonic: boolean;
|
||||
}
|
||||
|
||||
export interface MetricMetadataState {
|
||||
metricType: MetricType;
|
||||
description: string;
|
||||
temporality: Temporality | undefined;
|
||||
unit: string | undefined;
|
||||
}
|
||||
|
||||
export interface MetricAttribute {
|
||||
key: string;
|
||||
values: string[];
|
||||
valueCount: number;
|
||||
}
|
||||
|
||||
export enum TableFields {
|
||||
DESCRIPTION = 'description',
|
||||
UNIT = 'unit',
|
||||
METRIC_TYPE = 'metricType',
|
||||
Temporality = 'temporality',
|
||||
IS_MONOTONIC = 'isMonotonic',
|
||||
}
|
||||
|
||||
@@ -2,11 +2,29 @@ import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { SpaceAggregation, TimeAggregation } from 'api/v5/v5';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
GetMetricAlertsResponse,
|
||||
GetMetricAttributesResponse,
|
||||
GetMetricDashboardsResponse,
|
||||
GetMetricHighlightsResponse,
|
||||
GetMetricMetadataResponse,
|
||||
UpdateMetricMetadataRequest,
|
||||
} from 'types/api/metricsExplorer/v2';
|
||||
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 {
|
||||
import {
|
||||
MetricAlert,
|
||||
MetricAttribute,
|
||||
MetricDashboard,
|
||||
MetricHighlight,
|
||||
MetricMetadata,
|
||||
MetricMetadataState,
|
||||
} from './types';
|
||||
|
||||
export function formatTimestampToReadableDate(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
@@ -154,3 +172,149 @@ export function getMetricDetailsQuery(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function transformMetricHighlights(
|
||||
apiData: SuccessResponseV2<GetMetricHighlightsResponse> | undefined,
|
||||
): MetricHighlight | null {
|
||||
if (!apiData || !apiData.data || !apiData.data.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
dataPoints,
|
||||
lastReceived,
|
||||
totalTimeSeries,
|
||||
activeTimeSeries,
|
||||
} = apiData.data.data;
|
||||
|
||||
return {
|
||||
dataPoints,
|
||||
lastReceived,
|
||||
totalTimeSeries,
|
||||
activeTimeSeries,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformMetricAlerts(
|
||||
apiData: SuccessResponseV2<GetMetricAlertsResponse> | undefined,
|
||||
): MetricAlert[] {
|
||||
if (
|
||||
!apiData ||
|
||||
!apiData.data ||
|
||||
!apiData.data.data ||
|
||||
!apiData.data.data.alerts
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return apiData.data.data.alerts.map((alert) => ({
|
||||
alertName: alert.alertName,
|
||||
alertId: alert.alertId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function transformMetricDashboards(
|
||||
apiData: SuccessResponseV2<GetMetricDashboardsResponse> | undefined,
|
||||
): MetricDashboard[] {
|
||||
if (
|
||||
!apiData ||
|
||||
!apiData.data ||
|
||||
!apiData.data.data ||
|
||||
!apiData.data.data.dashboards
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const dashboards = apiData.data.data.dashboards.map((dashboard) => ({
|
||||
dashboardName: dashboard.dashboardName,
|
||||
dashboardId: dashboard.dashboardId,
|
||||
widgetId: dashboard.widgetId,
|
||||
widgetName: dashboard.widgetName,
|
||||
}));
|
||||
// Remove duplicate dashboards
|
||||
return dashboards.filter(
|
||||
(dashboard, index, self) =>
|
||||
index === self.findIndex((t) => t.dashboardId === dashboard.dashboardId),
|
||||
);
|
||||
}
|
||||
|
||||
export function transformTemporality(temporality: string): Temporality {
|
||||
switch (temporality) {
|
||||
case 'delta':
|
||||
return Temporality.DELTA;
|
||||
case 'cumulative':
|
||||
return Temporality.CUMULATIVE;
|
||||
default:
|
||||
return Temporality.DELTA;
|
||||
}
|
||||
}
|
||||
|
||||
export function transformMetricType(type: string): MetricType {
|
||||
switch (type) {
|
||||
case 'sum':
|
||||
return MetricType.SUM;
|
||||
case 'gauge':
|
||||
return MetricType.GAUGE;
|
||||
case 'summary':
|
||||
return MetricType.SUMMARY;
|
||||
case 'histogram':
|
||||
return MetricType.HISTOGRAM;
|
||||
case 'exponential_histogram':
|
||||
return MetricType.EXPONENTIAL_HISTOGRAM;
|
||||
default:
|
||||
return MetricType.SUM;
|
||||
}
|
||||
}
|
||||
|
||||
export function transformMetricMetadata(
|
||||
apiData: SuccessResponseV2<GetMetricMetadataResponse> | undefined,
|
||||
): MetricMetadata | null {
|
||||
if (!apiData || !apiData.data || !apiData.data.data) {
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
type,
|
||||
description,
|
||||
unit,
|
||||
temporality,
|
||||
isMonotonic,
|
||||
} = apiData.data.data;
|
||||
|
||||
return {
|
||||
metricType: transformMetricType(type),
|
||||
description,
|
||||
unit,
|
||||
temporality: transformTemporality(temporality),
|
||||
isMonotonic,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformUpdateMetricMetadataRequest(
|
||||
metricMetadata: MetricMetadataState,
|
||||
): UpdateMetricMetadataRequest {
|
||||
return {
|
||||
type: metricMetadata.metricType,
|
||||
description: metricMetadata.description,
|
||||
unit: metricMetadata.unit || '',
|
||||
temporality: metricMetadata.temporality || '',
|
||||
isMonotonic: determineIsMonotonic(
|
||||
metricMetadata.metricType,
|
||||
metricMetadata.temporality,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function transformMetricAttributes(
|
||||
apiData: SuccessResponseV2<GetMetricAttributesResponse> | undefined,
|
||||
): { attributes: MetricAttribute[]; totalKeys: number } {
|
||||
if (!apiData || !apiData.data || !apiData.data.data) {
|
||||
return { attributes: [], totalKeys: 0 };
|
||||
}
|
||||
const { attributes, totalKeys } = apiData.data.data;
|
||||
return {
|
||||
attributes: attributes.map((attribute) => ({
|
||||
key: attribute.key,
|
||||
values: attribute.values,
|
||||
valueCount: attribute.valueCount,
|
||||
})),
|
||||
totalKeys,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Select } from 'antd';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
|
||||
interface SpaceAggregationOptionsProps {
|
||||
panelType: PANEL_TYPES | null;
|
||||
@@ -20,13 +22,39 @@ export default function SpaceAggregationOptions({
|
||||
operators,
|
||||
qbVersion,
|
||||
}: SpaceAggregationOptionsProps): JSX.Element {
|
||||
const placeHolderText =
|
||||
panelType === PANEL_TYPES.VALUE || qbVersion === 'v3' ? 'Sum' : 'Sum By';
|
||||
const [defaultValue, setDefaultValue] = useState(
|
||||
selectedValue || placeHolderText,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedValue) {
|
||||
if (
|
||||
aggregatorAttributeType === ATTRIBUTE_TYPES.HISTOGRAM ||
|
||||
aggregatorAttributeType === ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM
|
||||
) {
|
||||
setDefaultValue(MetricAggregateOperator.P90);
|
||||
onSelect(MetricAggregateOperator.P90);
|
||||
} else if (aggregatorAttributeType === ATTRIBUTE_TYPES.SUM) {
|
||||
setDefaultValue(MetricAggregateOperator.SUM);
|
||||
onSelect(MetricAggregateOperator.SUM);
|
||||
} else if (aggregatorAttributeType === ATTRIBUTE_TYPES.GAUGE) {
|
||||
setDefaultValue(MetricAggregateOperator.AVG);
|
||||
onSelect(MetricAggregateOperator.AVG);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [aggregatorAttributeType]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="spaceAggregationOptionsContainer"
|
||||
key={aggregatorAttributeType}
|
||||
>
|
||||
<Select
|
||||
defaultValue={selectedValue}
|
||||
defaultValue={defaultValue}
|
||||
style={{ minWidth: '5.625rem' }}
|
||||
disabled={disabled}
|
||||
onChange={onSelect}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ReduceToFilter } from './ReduceToFilter';
|
||||
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
function baseQuery(overrides: Partial<IBuilderQuery> = {}): IBuilderQuery {
|
||||
return {
|
||||
dataSource: 'traces',
|
||||
aggregations: [],
|
||||
groupBy: [],
|
||||
orderBy: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
having: { expression: '' },
|
||||
...overrides,
|
||||
} as IBuilderQuery;
|
||||
}
|
||||
|
||||
describe('ReduceToFilter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initializes with default avg when no reduceTo is set', () => {
|
||||
render(<ReduceToFilter query={baseQuery()} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByTestId('reduce-to')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Average of values in timeframe'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes from query.aggregations[0].reduceTo', () => {
|
||||
render(
|
||||
<ReduceToFilter
|
||||
query={baseQuery({
|
||||
aggregations: [{ reduceTo: 'sum' } as any],
|
||||
aggregateAttribute: { key: 'test', type: MetricType.SUM },
|
||||
})}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Sum of values in timeframe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes from query.reduceTo when aggregations[0].reduceTo is not set', () => {
|
||||
render(
|
||||
<ReduceToFilter
|
||||
query={baseQuery({
|
||||
reduceTo: 'max',
|
||||
aggregateAttribute: { key: 'test', type: MetricType.GAUGE },
|
||||
})}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Max of values in timeframe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates to sum when aggregateAttribute.type is SUM', async () => {
|
||||
const { rerender } = render(
|
||||
<ReduceToFilter
|
||||
query={baseQuery({
|
||||
aggregateAttribute: { key: 'test', type: MetricType.GAUGE },
|
||||
})}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ReduceToFilter
|
||||
query={baseQuery({
|
||||
aggregateAttribute: { key: 'test2', type: MetricType.SUM },
|
||||
})}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const reduceToFilterText = (await screen.findByText(
|
||||
'Sum of values in timeframe',
|
||||
)) as HTMLElement;
|
||||
expect(reduceToFilterText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Select } from 'antd';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { REDUCE_TO_VALUES } from 'constants/queryBuilder';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
// ** Types
|
||||
import { ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -13,46 +12,19 @@ export const ReduceToFilter = memo(function ReduceToFilter({
|
||||
query,
|
||||
onChange,
|
||||
}: ReduceToFilterProps): JSX.Element {
|
||||
const isMounted = useRef<boolean>(false);
|
||||
const [currentValue, setCurrentValue] = useState<
|
||||
SelectOption<ReduceOperators, string>
|
||||
>(REDUCE_TO_VALUES[2]); // default to avg
|
||||
const reduceToValue =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.reduceTo || query.reduceTo;
|
||||
|
||||
const currentValue =
|
||||
REDUCE_TO_VALUES.find((option) => option.value === reduceToValue) ||
|
||||
REDUCE_TO_VALUES[0];
|
||||
|
||||
const handleChange = (
|
||||
newValue: SelectOption<ReduceOperators, string>,
|
||||
): void => {
|
||||
setCurrentValue(newValue);
|
||||
onChange(newValue.value);
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!isMounted.current) {
|
||||
const reduceToValue =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.reduceTo || query.reduceTo;
|
||||
|
||||
setCurrentValue(
|
||||
REDUCE_TO_VALUES.find((option) => option.value === reduceToValue) ||
|
||||
REDUCE_TO_VALUES[2],
|
||||
);
|
||||
isMounted.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const aggregationAttributeType = query.aggregateAttribute?.type as
|
||||
| MetricType
|
||||
| undefined;
|
||||
|
||||
if (aggregationAttributeType === MetricType.SUM) {
|
||||
handleChange(REDUCE_TO_VALUES[1]);
|
||||
} else {
|
||||
handleChange(REDUCE_TO_VALUES[2]);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[query.aggregateAttribute?.key],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder="Reduce to"
|
||||
|
||||
22
frontend/src/hooks/metricsExplorer/v2/useGetMetricAlerts.ts
Normal file
22
frontend/src/hooks/metricsExplorer/v2/useGetMetricAlerts.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getMetricAlerts } from 'api/metricsExplorer/v2/getMetricAlerts';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { GetMetricAlertsResponse } from 'types/api/metricsExplorer/v2';
|
||||
|
||||
type UseGetMetricAlerts = (
|
||||
metricName: string,
|
||||
options?: UseQueryOptions<SuccessResponseV2<GetMetricAlertsResponse>, Error>,
|
||||
headers?: Record<string, string>,
|
||||
) => UseQueryResult<SuccessResponseV2<GetMetricAlertsResponse>, Error>;
|
||||
|
||||
export const useGetMetricAlerts: UseGetMetricAlerts = (
|
||||
metricName,
|
||||
options,
|
||||
headers,
|
||||
) =>
|
||||
useQuery<SuccessResponseV2<GetMetricAlertsResponse>, Error>({
|
||||
queryFn: ({ signal }) => getMetricAlerts(metricName, signal, headers),
|
||||
...options,
|
||||
queryKey: [REACT_QUERY_KEY.GET_METRIC_ALERTS, metricName],
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getMetricAttributes } from 'api/metricsExplorer/v2/getMetricAttributes';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
GetMetricAttributesRequest,
|
||||
GetMetricAttributesResponse,
|
||||
} from 'types/api/metricsExplorer/v2';
|
||||
|
||||
type UseGetMetricAttributes = (
|
||||
requestData: GetMetricAttributesRequest,
|
||||
options?: UseQueryOptions<
|
||||
SuccessResponseV2<GetMetricAttributesResponse>,
|
||||
Error
|
||||
>,
|
||||
headers?: Record<string, string>,
|
||||
) => UseQueryResult<SuccessResponseV2<GetMetricAttributesResponse>, Error>;
|
||||
|
||||
export const useGetMetricAttributes: UseGetMetricAttributes = (
|
||||
requestData,
|
||||
options,
|
||||
headers,
|
||||
) => {
|
||||
const queryKey = [
|
||||
REACT_QUERY_KEY.GET_METRIC_ATTRIBUTES,
|
||||
requestData.metricName,
|
||||
requestData.start,
|
||||
requestData.end,
|
||||
];
|
||||
|
||||
return useQuery<SuccessResponseV2<GetMetricAttributesResponse>, Error>({
|
||||
queryFn: ({ signal }) => getMetricAttributes(requestData, signal, headers),
|
||||
...options,
|
||||
queryKey,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getMetricDashboards } from 'api/metricsExplorer/v2/getMetricDashboards';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { GetMetricDashboardsResponse } from 'types/api/metricsExplorer/v2';
|
||||
|
||||
type UseGetMetricDashboards = (
|
||||
metricName: string,
|
||||
options?: UseQueryOptions<
|
||||
SuccessResponseV2<GetMetricDashboardsResponse>,
|
||||
Error
|
||||
>,
|
||||
headers?: Record<string, string>,
|
||||
) => UseQueryResult<SuccessResponseV2<GetMetricDashboardsResponse>, Error>;
|
||||
|
||||
export const useGetMetricDashboards: UseGetMetricDashboards = (
|
||||
metricName,
|
||||
options,
|
||||
headers,
|
||||
) =>
|
||||
useQuery<SuccessResponseV2<GetMetricDashboardsResponse>, Error>({
|
||||
queryFn: ({ signal }) => getMetricDashboards(metricName, signal, headers),
|
||||
...options,
|
||||
queryKey: [REACT_QUERY_KEY.GET_METRIC_DASHBOARDS, metricName],
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getMetricHighlights } from 'api/metricsExplorer/v2/getMetricHighlights';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { GetMetricHighlightsResponse } from 'types/api/metricsExplorer/v2';
|
||||
|
||||
type UseGetMetricHighlights = (
|
||||
metricName: string,
|
||||
options?: UseQueryOptions<
|
||||
SuccessResponseV2<GetMetricHighlightsResponse>,
|
||||
Error
|
||||
>,
|
||||
headers?: Record<string, string>,
|
||||
) => UseQueryResult<SuccessResponseV2<GetMetricHighlightsResponse>, Error>;
|
||||
|
||||
export const useGetMetricHighlights: UseGetMetricHighlights = (
|
||||
metricName,
|
||||
options,
|
||||
headers,
|
||||
) =>
|
||||
useQuery<SuccessResponseV2<GetMetricHighlightsResponse>, Error>({
|
||||
queryFn: ({ signal }) => getMetricHighlights(metricName, signal, headers),
|
||||
...options,
|
||||
queryKey: [REACT_QUERY_KEY.GET_METRIC_HIGHLIGHTS, metricName],
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getMetricMetadata } from 'api/metricsExplorer/v2/getMetricMetadata';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { GetMetricMetadataResponse } from 'types/api/metricsExplorer/v2';
|
||||
|
||||
type UseGetMetricMetadata = (
|
||||
metricName: string,
|
||||
options?: UseQueryOptions<SuccessResponseV2<GetMetricMetadataResponse>, Error>,
|
||||
headers?: Record<string, string>,
|
||||
) => UseQueryResult<SuccessResponseV2<GetMetricMetadataResponse>, Error>;
|
||||
|
||||
export const useGetMetricMetadata: UseGetMetricMetadata = (
|
||||
metricName,
|
||||
options,
|
||||
headers,
|
||||
) =>
|
||||
useQuery<SuccessResponseV2<GetMetricMetadataResponse>, Error>({
|
||||
queryFn: ({ signal }) => getMetricMetadata(metricName, signal, headers),
|
||||
...options,
|
||||
queryKey: [REACT_QUERY_KEY.GET_METRIC_METADATA, metricName],
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import updateMetricMetadata from 'api/metricsExplorer/v2/updateMetricMetadata';
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
UpdateMetricMetadataResponse,
|
||||
UseUpdateMetricMetadataProps,
|
||||
} from 'types/api/metricsExplorer/v2';
|
||||
|
||||
export function useUpdateMetricMetadata(): UseMutationResult<
|
||||
SuccessResponseV2<UpdateMetricMetadataResponse>,
|
||||
Error,
|
||||
UseUpdateMetricMetadataProps
|
||||
> {
|
||||
return useMutation<
|
||||
SuccessResponseV2<UpdateMetricMetadataResponse>,
|
||||
Error,
|
||||
UseUpdateMetricMetadataProps
|
||||
>({
|
||||
mutationFn: ({ metricName, payload }) =>
|
||||
updateMetricMetadata(metricName, payload),
|
||||
});
|
||||
}
|
||||
@@ -188,7 +188,7 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
metricName: 'new_sum_metric',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -239,7 +239,7 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
metricName: 'new_sum_metric',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -315,7 +315,7 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: 'new_gauge',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.AVG,
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -317,7 +317,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
spaceAggregation: '',
|
||||
},
|
||||
];
|
||||
} else if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.GAUGE) {
|
||||
@@ -326,20 +326,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.AVG,
|
||||
},
|
||||
];
|
||||
} else if (
|
||||
newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.HISTOGRAM ||
|
||||
newQuery.aggregateAttribute?.type ===
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM
|
||||
) {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: '',
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.P90,
|
||||
spaceAggregation: '',
|
||||
},
|
||||
];
|
||||
} else {
|
||||
|
||||
@@ -15,7 +15,7 @@ function NoData(): JSX.Element {
|
||||
<Typography.Text className="not-found-text-1">
|
||||
Uh-oh! We cannot show the selected trace.
|
||||
<span className="not-found-text-2">
|
||||
This can happen in either of the two scenarios -
|
||||
This can happen in either of the two scenraios -
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
|
||||
77
frontend/src/types/api/metricsExplorer/v2.ts
Normal file
77
frontend/src/types/api/metricsExplorer/v2.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export interface GetMetricMetadataResponse {
|
||||
status: string;
|
||||
data: {
|
||||
description: string;
|
||||
type: string;
|
||||
unit: string;
|
||||
temporality: string;
|
||||
isMonotonic: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetMetricHighlightsResponse {
|
||||
status: string;
|
||||
data: {
|
||||
dataPoints: number;
|
||||
lastReceived: number;
|
||||
totalTimeSeries: number;
|
||||
activeTimeSeries: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetMetricAttributesRequest {
|
||||
metricName: string;
|
||||
start?: number;
|
||||
end?: number;
|
||||
}
|
||||
|
||||
export interface GetMetricAttributesResponse {
|
||||
status: string;
|
||||
data: {
|
||||
attributes: {
|
||||
key: string;
|
||||
values: string[];
|
||||
valueCount: number;
|
||||
}[];
|
||||
totalKeys: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetMetricAlertsResponse {
|
||||
status: string;
|
||||
data: {
|
||||
alerts: {
|
||||
alertName: string;
|
||||
alertId: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetMetricDashboardsResponse {
|
||||
status: string;
|
||||
data: {
|
||||
dashboards: {
|
||||
dashboardName: string;
|
||||
dashboardId: string;
|
||||
widgetId: string;
|
||||
widgetName: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateMetricMetadataRequest {
|
||||
type: string;
|
||||
description: string;
|
||||
temporality: string;
|
||||
unit: string;
|
||||
isMonotonic: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateMetricMetadataResponse {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface UseUpdateMetricMetadataProps {
|
||||
metricName: string;
|
||||
payload: UpdateMetricMetadataRequest;
|
||||
}
|
||||
10
frontend/src/utils/pluralize.ts
Normal file
10
frontend/src/utils/pluralize.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function pluralize(
|
||||
count: number,
|
||||
singular: string,
|
||||
plural: string,
|
||||
): string {
|
||||
if (count === 1) {
|
||||
return `${count} ${singular}`;
|
||||
}
|
||||
return `${count} ${plural}`;
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
func (provider *provider) addPromoteRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/logs/promote_paths", handler.New(provider.authZ.EditAccess(provider.promoteHandler.HandlePromoteAndIndexPaths), handler.OpenAPIDef{
|
||||
ID: "HandlePromoteAndIndexPaths",
|
||||
Tags: []string{"logs"},
|
||||
ID: "PromotePaths",
|
||||
Tags: []string{"promoted_paths", "logs", "json_logs"},
|
||||
Summary: "Promote and index paths",
|
||||
Description: "This endpoints promotes and indexes paths",
|
||||
Request: new([]*promotetypes.PromotePath),
|
||||
@@ -25,8 +25,8 @@ func (provider *provider) addPromoteRoutes(router *mux.Router) error {
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/logs/promote_paths", handler.New(provider.authZ.ViewAccess(provider.promoteHandler.ListPromotedAndIndexedPaths), handler.OpenAPIDef{
|
||||
ID: "ListPromotedAndIndexedPaths",
|
||||
Tags: []string{"logs"},
|
||||
ID: "PromotePaths",
|
||||
Tags: []string{"promoted_paths", "logs", "json_logs"},
|
||||
Summary: "Promote and index paths",
|
||||
Description: "This endpoints promotes and indexes paths",
|
||||
Request: nil,
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package telemetrylogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
var (
|
||||
CodePlanIndexOutOfBounds = errors.MustNewCode("plan_index_out_of_bounds")
|
||||
)
|
||||
|
||||
type JSONAccessPlanBuilder struct {
|
||||
key *telemetrytypes.TelemetryFieldKey
|
||||
value any
|
||||
op qbtypes.FilterOperator
|
||||
parts []string
|
||||
getTypes func(ctx context.Context, path string) ([]telemetrytypes.JSONDataType, error)
|
||||
isPromoted bool
|
||||
}
|
||||
|
||||
// buildPlan recursively builds the path plan tree
|
||||
func (pb *JSONAccessPlanBuilder) buildPlan(ctx context.Context, index int, parent *telemetrytypes.JSONAccessNode, isDynArrChild bool) (*telemetrytypes.JSONAccessNode, error) {
|
||||
if index >= len(pb.parts) {
|
||||
return nil, errors.NewInvalidInputf(CodePlanIndexOutOfBounds, "index is out of bounds")
|
||||
}
|
||||
|
||||
part := pb.parts[index]
|
||||
pathSoFar := strings.Join(pb.parts[:index+1], ArraySep)
|
||||
isTerminal := index == len(pb.parts)-1
|
||||
|
||||
// Calculate progression parameters based on parent's values
|
||||
var maxTypes, maxPaths int
|
||||
if isDynArrChild {
|
||||
// Child of Dynamic array - reset progression to base values (16, 256)
|
||||
// This happens when we switch from Array(Dynamic) to Array(JSON)
|
||||
maxTypes = 16
|
||||
maxPaths = 256
|
||||
} else if parent != nil {
|
||||
// Child of JSON array - use parent's progression divided by 2 and 4
|
||||
maxTypes = parent.MaxDynamicTypes / 2
|
||||
maxPaths = parent.MaxDynamicPaths / 4
|
||||
if maxTypes < 0 {
|
||||
maxTypes = 0
|
||||
}
|
||||
if maxPaths < 0 {
|
||||
maxPaths = 0
|
||||
}
|
||||
}
|
||||
|
||||
types, err := pb.getTypes(ctx, pathSoFar)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create node for this path segment
|
||||
node := &telemetrytypes.JSONAccessNode{
|
||||
Name: part,
|
||||
IsTerminal: isTerminal,
|
||||
AvailableTypes: types,
|
||||
Branches: make(map[telemetrytypes.JSONAccessBranchType]*telemetrytypes.JSONAccessNode),
|
||||
Parent: parent,
|
||||
MaxDynamicTypes: maxTypes,
|
||||
MaxDynamicPaths: maxPaths,
|
||||
}
|
||||
|
||||
hasJSON := slices.Contains(node.AvailableTypes, telemetrytypes.ArrayJSON)
|
||||
hasDynamic := slices.Contains(node.AvailableTypes, telemetrytypes.ArrayDynamic)
|
||||
|
||||
// Configure terminal if this is the last part
|
||||
if isTerminal {
|
||||
valueType, _ := inferDataType(pb.value, pb.op, pb.key)
|
||||
node.TerminalConfig = &telemetrytypes.TerminalConfig{
|
||||
Key: pb.key,
|
||||
ElemType: *pb.key.JSONDataType,
|
||||
ValueType: telemetrytypes.MappingFieldDataTypeToJSONDataType[valueType],
|
||||
}
|
||||
} else {
|
||||
if hasJSON {
|
||||
node.Branches[telemetrytypes.BranchJSON], err = pb.buildPlan(ctx, index+1, node, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if hasDynamic {
|
||||
node.Branches[telemetrytypes.BranchDynamic], err = pb.buildPlan(ctx, index+1, node, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// PlanJSON builds a tree structure representing the complete JSON path traversal
|
||||
// that precomputes all possible branches and their types
|
||||
func PlanJSON(ctx context.Context, key *telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator,
|
||||
value any,
|
||||
getTypes func(ctx context.Context, path string) ([]telemetrytypes.JSONDataType, error),
|
||||
) (telemetrytypes.JSONAccessPlan, error) {
|
||||
// if path is empty, return nil
|
||||
if key.Name == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "path is empty")
|
||||
}
|
||||
|
||||
// TODO: PlanJSON requires the Start and End of the Query to select correct column between promoted and body_json using
|
||||
// creation time in distributed_promoted_paths
|
||||
path := strings.ReplaceAll(key.Name, ArrayAnyIndex, ArraySep)
|
||||
parts := strings.Split(path, ArraySep)
|
||||
|
||||
pb := &JSONAccessPlanBuilder{
|
||||
key: key,
|
||||
op: op,
|
||||
value: value,
|
||||
parts: parts,
|
||||
getTypes: getTypes,
|
||||
isPromoted: key.Materialized,
|
||||
}
|
||||
plans := telemetrytypes.JSONAccessPlan{}
|
||||
|
||||
node, err := pb.buildPlan(ctx, 0,
|
||||
telemetrytypes.NewRootJSONAccessNode(LogsV2BodyJSONColumn,
|
||||
32, 0),
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plans = append(plans, node)
|
||||
|
||||
if pb.isPromoted {
|
||||
node, err := pb.buildPlan(ctx, 0,
|
||||
telemetrytypes.NewRootJSONAccessNode(LogsV2BodyPromotedColumn,
|
||||
32, 1024),
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plans = append(plans, node)
|
||||
}
|
||||
|
||||
return plans, nil
|
||||
}
|
||||
@@ -1,880 +0,0 @@
|
||||
package telemetrylogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions for Test Data Creation
|
||||
// ============================================================================
|
||||
|
||||
// makeKey creates a TelemetryFieldKey for testing
|
||||
func makeKey(name string, dataType telemetrytypes.JSONDataType, materialized bool) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
JSONDataType: &dataType,
|
||||
Materialized: materialized,
|
||||
}
|
||||
}
|
||||
|
||||
// makeGetTypes creates a getTypes function from a map of path -> types
|
||||
func makeGetTypes(typesMap map[string][]telemetrytypes.JSONDataType) func(ctx context.Context, path string) ([]telemetrytypes.JSONDataType, error) {
|
||||
return func(_ context.Context, path string) ([]telemetrytypes.JSONDataType, error) {
|
||||
return typesMap[path], nil
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions for Node Validation
|
||||
// ============================================================================
|
||||
|
||||
// jsonAccessTestNode is a test-only, YAML-friendly view of JSONAccessNode.
|
||||
// It intentionally omits Parent to avoid cycles and only keeps the fields
|
||||
// that are useful for understanding / asserting the plan structure.
|
||||
type jsonAccessTestNode struct {
|
||||
Name string `yaml:"name"`
|
||||
Column string `yaml:"column,omitempty"`
|
||||
IsTerminal bool `yaml:"isTerminal,omitempty"`
|
||||
MaxDynamicTypes int `yaml:"maxDynamicTypes,omitempty"`
|
||||
MaxDynamicPaths int `yaml:"maxDynamicPaths,omitempty"`
|
||||
ElemType string `yaml:"elemType,omitempty"`
|
||||
ValueType string `yaml:"valueType,omitempty"`
|
||||
AvailableTypes []string `yaml:"availableTypes,omitempty"`
|
||||
Branches map[string]*jsonAccessTestNode `yaml:"branches,omitempty"`
|
||||
}
|
||||
|
||||
// toTestNode converts a JSONAccessNode tree into jsonAccessTestNode so that
|
||||
// it can be serialized to YAML for easy visual comparison in tests.
|
||||
func toTestNode(n *telemetrytypes.JSONAccessNode) *jsonAccessTestNode {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := &jsonAccessTestNode{
|
||||
Name: n.Name,
|
||||
IsTerminal: n.IsTerminal,
|
||||
MaxDynamicTypes: n.MaxDynamicTypes,
|
||||
MaxDynamicPaths: n.MaxDynamicPaths,
|
||||
}
|
||||
|
||||
// Column information for top-level plan nodes: their parent is the root,
|
||||
// whose parent is nil.
|
||||
if n.Parent != nil && n.Parent.Parent == nil {
|
||||
out.Column = n.Parent.Name
|
||||
}
|
||||
|
||||
// AvailableTypes as strings (using StringValue for stable representation)
|
||||
if len(n.AvailableTypes) > 0 {
|
||||
out.AvailableTypes = make([]string, 0, len(n.AvailableTypes))
|
||||
for _, t := range n.AvailableTypes {
|
||||
out.AvailableTypes = append(out.AvailableTypes, t.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal config
|
||||
if n.TerminalConfig != nil {
|
||||
out.ElemType = n.TerminalConfig.ElemType.StringValue()
|
||||
out.ValueType = n.TerminalConfig.ValueType.StringValue()
|
||||
}
|
||||
|
||||
// Branches
|
||||
if len(n.Branches) > 0 {
|
||||
out.Branches = make(map[string]*jsonAccessTestNode, len(n.Branches))
|
||||
for bt, child := range n.Branches {
|
||||
out.Branches[bt.StringValue()] = toTestNode(child)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// plansToYAML converts a slice of JSONAccessNode plans to a YAML string that
|
||||
// can be compared against a per-test expectedTree.
|
||||
func plansToYAML(t *testing.T, plans []*telemetrytypes.JSONAccessNode) string {
|
||||
t.Helper()
|
||||
|
||||
testNodes := make([]*jsonAccessTestNode, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
testNodes = append(testNodes, toTestNode(p))
|
||||
}
|
||||
|
||||
got, err := yaml.Marshal(testNodes)
|
||||
require.NoError(t, err)
|
||||
return string(got)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Cases for Node Methods
|
||||
// ============================================================================
|
||||
|
||||
func TestNode_Alias(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
node *telemetrytypes.JSONAccessNode
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Root node returns name as-is",
|
||||
node: telemetrytypes.NewRootJSONAccessNode(LogsV2BodyJSONColumn, 32, 0),
|
||||
expected: LogsV2BodyJSONColumn,
|
||||
},
|
||||
{
|
||||
name: "Node without parent returns backticked name",
|
||||
node: &telemetrytypes.JSONAccessNode{
|
||||
Name: "user",
|
||||
Parent: nil,
|
||||
},
|
||||
expected: "`user`",
|
||||
},
|
||||
{
|
||||
name: "Node with root parent uses dot separator",
|
||||
node: &telemetrytypes.JSONAccessNode{
|
||||
Name: "age",
|
||||
Parent: telemetrytypes.NewRootJSONAccessNode(LogsV2BodyJSONColumn, 32, 0),
|
||||
},
|
||||
expected: "`" + LogsV2BodyJSONColumn + ".age`",
|
||||
},
|
||||
{
|
||||
name: "Node with non-root parent uses array separator",
|
||||
node: &telemetrytypes.JSONAccessNode{
|
||||
Name: "name",
|
||||
Parent: &telemetrytypes.JSONAccessNode{
|
||||
Name: "education",
|
||||
Parent: telemetrytypes.NewRootJSONAccessNode(LogsV2BodyJSONColumn, 32, 0),
|
||||
},
|
||||
},
|
||||
expected: "`" + LogsV2BodyJSONColumn + ".education[].name`",
|
||||
},
|
||||
{
|
||||
name: "Nested array path with multiple levels",
|
||||
node: &telemetrytypes.JSONAccessNode{
|
||||
Name: "type",
|
||||
Parent: &telemetrytypes.JSONAccessNode{
|
||||
Name: "awards",
|
||||
Parent: &telemetrytypes.JSONAccessNode{
|
||||
Name: "education",
|
||||
Parent: telemetrytypes.NewRootJSONAccessNode(LogsV2BodyJSONColumn, 32, 0),
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "`" + LogsV2BodyJSONColumn + ".education[].awards[].type`",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.node.Alias()
|
||||
require.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNode_FieldPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
node *telemetrytypes.JSONAccessNode
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Simple field path from root",
|
||||
node: &telemetrytypes.JSONAccessNode{
|
||||
Name: "user",
|
||||
Parent: telemetrytypes.NewRootJSONAccessNode(LogsV2BodyJSONColumn, 32, 0),
|
||||
},
|
||||
// FieldPath() always wraps the field name in backticks
|
||||
expected: LogsV2BodyJSONColumn + ".`user`",
|
||||
},
|
||||
{
|
||||
name: "Field path with backtick-required key",
|
||||
node: &telemetrytypes.JSONAccessNode{
|
||||
Name: "user-name", // requires backtick
|
||||
Parent: telemetrytypes.NewRootJSONAccessNode(LogsV2BodyJSONColumn, 32, 0),
|
||||
},
|
||||
expected: LogsV2BodyJSONColumn + ".`user-name`",
|
||||
},
|
||||
{
|
||||
name: "Nested field path",
|
||||
node: &telemetrytypes.JSONAccessNode{
|
||||
Name: "age",
|
||||
Parent: &telemetrytypes.JSONAccessNode{
|
||||
Name: "user",
|
||||
Parent: telemetrytypes.NewRootJSONAccessNode(LogsV2BodyJSONColumn, 32, 0),
|
||||
},
|
||||
},
|
||||
// FieldPath() always wraps the field name in backticks
|
||||
expected: "`" + LogsV2BodyJSONColumn + ".user`.`age`",
|
||||
},
|
||||
{
|
||||
name: "Array element field path",
|
||||
node: &telemetrytypes.JSONAccessNode{
|
||||
Name: "name",
|
||||
Parent: &telemetrytypes.JSONAccessNode{
|
||||
Name: "education",
|
||||
Parent: telemetrytypes.NewRootJSONAccessNode(LogsV2BodyJSONColumn, 32, 0),
|
||||
},
|
||||
},
|
||||
// FieldPath() always wraps the field name in backticks
|
||||
expected: "`" + LogsV2BodyJSONColumn + ".education`.`name`",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.node.FieldPath()
|
||||
require.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Cases for PlanJSON
|
||||
// ============================================================================
|
||||
|
||||
func TestPlanJSON_BasicStructure(t *testing.T) {
|
||||
_, getTypes := testTypeSet()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key *telemetrytypes.TelemetryFieldKey
|
||||
expectErr bool
|
||||
expectedYAML string
|
||||
}{
|
||||
{
|
||||
name: "Simple path not promoted",
|
||||
key: makeKey("user.name", telemetrytypes.String, false),
|
||||
expectedYAML: `
|
||||
- name: user.name
|
||||
column: body_json
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Simple path promoted",
|
||||
key: makeKey("user.name", telemetrytypes.String, true),
|
||||
expectedYAML: `
|
||||
- name: user.name
|
||||
column: body_json
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
- name: user.name
|
||||
column: body_json_promoted
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Empty path returns error",
|
||||
key: makeKey("", telemetrytypes.String, false),
|
||||
expectErr: true,
|
||||
expectedYAML: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plans, err := PlanJSON(context.Background(), tt.key, qbtypes.FilterOperatorEqual, "John", getTypes)
|
||||
if tt.expectErr {
|
||||
require.Error(t, err)
|
||||
require.Nil(t, plans)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
got := plansToYAML(t, plans)
|
||||
require.YAMLEq(t, tt.expectedYAML, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanJSON_ArrayPaths(t *testing.T) {
|
||||
_, getTypes := testTypeSet()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedYAML string
|
||||
}{
|
||||
{
|
||||
name: "Single array level - JSON branch only",
|
||||
path: "education[].name",
|
||||
expectedYAML: `
|
||||
- name: education
|
||||
column: body_json
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: name
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 8
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Single array level - both JSON and Dynamic branches",
|
||||
path: "education[].awards[].type",
|
||||
expectedYAML: `
|
||||
- name: education
|
||||
column: body_json
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
dynamic:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Deeply nested array path",
|
||||
path: "interests[].entities[].reviews[].entries[].metadata[].positions[].name",
|
||||
expectedYAML: `
|
||||
- name: interests
|
||||
column: body_json
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: entities
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: reviews
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 4
|
||||
branches:
|
||||
json:
|
||||
name: entries
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 2
|
||||
branches:
|
||||
json:
|
||||
name: metadata
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 1
|
||||
branches:
|
||||
json:
|
||||
name: positions
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
branches:
|
||||
json:
|
||||
name: name
|
||||
availableTypes:
|
||||
- String
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "ArrayAnyIndex replacement [*] to []",
|
||||
path: "education[*].name",
|
||||
expectedYAML: `
|
||||
- name: education
|
||||
column: body_json
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: name
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 8
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
key := makeKey(tt.path, telemetrytypes.String, false)
|
||||
plans, err := PlanJSON(context.Background(), key, qbtypes.FilterOperatorEqual, "John", getTypes)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, plans)
|
||||
require.Len(t, plans, 1)
|
||||
got := plansToYAML(t, plans)
|
||||
require.YAMLEq(t, tt.expectedYAML, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
|
||||
_, getTypes := testTypeSet()
|
||||
path := "education[].awards[].type"
|
||||
value := "sports"
|
||||
|
||||
t.Run("Non-promoted plan", func(t *testing.T) {
|
||||
key := makeKey(path, telemetrytypes.String, false)
|
||||
plans, err := PlanJSON(context.Background(), key, qbtypes.FilterOperatorEqual, value, getTypes)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plans, 1)
|
||||
|
||||
expectedYAML := `
|
||||
- name: education
|
||||
column: body_json
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
dynamic:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
`
|
||||
got := plansToYAML(t, plans)
|
||||
require.YAMLEq(t, expectedYAML, got)
|
||||
})
|
||||
|
||||
t.Run("Promoted plan", func(t *testing.T) {
|
||||
key := makeKey(path, telemetrytypes.String, true)
|
||||
plans, err := PlanJSON(context.Background(), key, qbtypes.FilterOperatorEqual, value, getTypes)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plans, 2)
|
||||
|
||||
expectedYAML := `
|
||||
- name: education
|
||||
column: body_json
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
dynamic:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
- name: education
|
||||
column: body_json_promoted
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
maxDynamicPaths: 64
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
maxDynamicPaths: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
dynamic:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
`
|
||||
got := plansToYAML(t, plans)
|
||||
require.YAMLEq(t, expectedYAML, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
_, getTypes := testTypeSet()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
value any
|
||||
expectedYAML string
|
||||
}{
|
||||
{
|
||||
name: "Path with no available types",
|
||||
path: "unknown.path",
|
||||
value: "test",
|
||||
expectedYAML: `
|
||||
- name: unknown.path
|
||||
column: body_json
|
||||
maxDynamicTypes: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Very deep nesting - validates progression doesn't go negative",
|
||||
path: "interests[].entities[].reviews[].entries[].metadata[].positions[].name",
|
||||
value: "Engineer",
|
||||
expectedYAML: `
|
||||
- name: interests
|
||||
column: body_json
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: entities
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: reviews
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 4
|
||||
branches:
|
||||
json:
|
||||
name: entries
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 2
|
||||
branches:
|
||||
json:
|
||||
name: metadata
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 1
|
||||
branches:
|
||||
json:
|
||||
name: positions
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
branches:
|
||||
json:
|
||||
name: name
|
||||
availableTypes:
|
||||
- String
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Path with mixed scalar and array types",
|
||||
path: "education[].type",
|
||||
value: "high_school",
|
||||
expectedYAML: `
|
||||
- name: education
|
||||
column: body_json
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
- Int64
|
||||
maxDynamicTypes: 8
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Exists with only array types available",
|
||||
path: "education",
|
||||
value: nil,
|
||||
expectedYAML: `
|
||||
- name: education
|
||||
column: body_json
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
isTerminal: true
|
||||
elemType: Array(JSON)
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Choose key type based on path; operator does not affect the tree shape asserted here.
|
||||
keyType := telemetrytypes.String
|
||||
switch tt.path {
|
||||
case "education":
|
||||
keyType = telemetrytypes.ArrayJSON
|
||||
case "education[].type":
|
||||
keyType = telemetrytypes.String
|
||||
}
|
||||
key := makeKey(tt.path, keyType, false)
|
||||
plans, err := PlanJSON(context.Background(), key, qbtypes.FilterOperatorEqual, tt.value, getTypes)
|
||||
require.NoError(t, err)
|
||||
got := plansToYAML(t, plans)
|
||||
require.YAMLEq(t, tt.expectedYAML, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanJSON_TreeStructure(t *testing.T) {
|
||||
_, getTypes := testTypeSet()
|
||||
path := "education[].awards[].participated[].team[].branch"
|
||||
key := makeKey(path, telemetrytypes.String, false)
|
||||
plans, err := PlanJSON(context.Background(), key, qbtypes.FilterOperatorEqual, "John", getTypes)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plans, 1)
|
||||
|
||||
expectedYAML := `
|
||||
- name: education
|
||||
column: body_json
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: participated
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 4
|
||||
branches:
|
||||
json:
|
||||
name: team
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 2
|
||||
branches:
|
||||
json:
|
||||
name: branch
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 1
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
dynamic:
|
||||
name: team
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
branches:
|
||||
json:
|
||||
name: branch
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 8
|
||||
maxDynamicPaths: 64
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
dynamic:
|
||||
name: participated
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
branches:
|
||||
json:
|
||||
name: team
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
maxDynamicPaths: 64
|
||||
branches:
|
||||
json:
|
||||
name: branch
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
maxDynamicPaths: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
dynamic:
|
||||
name: team
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
branches:
|
||||
json:
|
||||
name: branch
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 8
|
||||
maxDynamicPaths: 64
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
valueType: String
|
||||
`
|
||||
|
||||
got := plansToYAML(t, plans)
|
||||
require.YAMLEq(t, expectedYAML, got)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Setup
|
||||
// ============================================================================
|
||||
|
||||
// testTypeSet returns a map of path->types and a getTypes function for testing
|
||||
// This represents the type information available in the test JSON structure
|
||||
//
|
||||
// TODO(Piyush): Remove this unparam nolint
|
||||
// nolint:unparam
|
||||
func testTypeSet() (map[string][]telemetrytypes.JSONDataType, func(ctx context.Context, path string) ([]telemetrytypes.JSONDataType, error)) {
|
||||
types := map[string][]telemetrytypes.JSONDataType{
|
||||
"user.name": {telemetrytypes.String},
|
||||
"user.age": {telemetrytypes.Int64, telemetrytypes.String},
|
||||
"user.height": {telemetrytypes.Float64},
|
||||
"education": {telemetrytypes.ArrayJSON},
|
||||
"education[].name": {telemetrytypes.String},
|
||||
"education[].type": {telemetrytypes.String, telemetrytypes.Int64},
|
||||
"education[].internal_type": {telemetrytypes.String},
|
||||
"education[].metadata.location": {telemetrytypes.String},
|
||||
"education[].parameters": {telemetrytypes.ArrayFloat64, telemetrytypes.ArrayDynamic},
|
||||
"education[].duration": {telemetrytypes.String},
|
||||
"education[].mode": {telemetrytypes.String},
|
||||
"education[].year": {telemetrytypes.Int64},
|
||||
"education[].field": {telemetrytypes.String},
|
||||
"education[].awards": {telemetrytypes.ArrayDynamic, telemetrytypes.ArrayJSON},
|
||||
"education[].awards[].name": {telemetrytypes.String},
|
||||
"education[].awards[].rank": {telemetrytypes.Int64},
|
||||
"education[].awards[].medal": {telemetrytypes.String},
|
||||
"education[].awards[].type": {telemetrytypes.String},
|
||||
"education[].awards[].semester": {telemetrytypes.Int64},
|
||||
"education[].awards[].participated": {telemetrytypes.ArrayDynamic, telemetrytypes.ArrayJSON},
|
||||
"education[].awards[].participated[].type": {telemetrytypes.String},
|
||||
"education[].awards[].participated[].field": {telemetrytypes.String},
|
||||
"education[].awards[].participated[].project_type": {telemetrytypes.String},
|
||||
"education[].awards[].participated[].project_name": {telemetrytypes.String},
|
||||
"education[].awards[].participated[].race_type": {telemetrytypes.String},
|
||||
"education[].awards[].participated[].team_based": {telemetrytypes.Bool},
|
||||
"education[].awards[].participated[].team_name": {telemetrytypes.String},
|
||||
"education[].awards[].participated[].team": {telemetrytypes.ArrayJSON},
|
||||
"education[].awards[].participated[].team[].name": {telemetrytypes.String},
|
||||
"education[].awards[].participated[].team[].branch": {telemetrytypes.String},
|
||||
"education[].awards[].participated[].team[].semester": {telemetrytypes.Int64},
|
||||
"interests": {telemetrytypes.ArrayJSON},
|
||||
"interests[].type": {telemetrytypes.String},
|
||||
"interests[].entities": {telemetrytypes.ArrayJSON},
|
||||
"interests[].entities.application_date": {telemetrytypes.String},
|
||||
"interests[].entities[].reviews": {telemetrytypes.ArrayJSON},
|
||||
"interests[].entities[].reviews[].given_by": {telemetrytypes.String},
|
||||
"interests[].entities[].reviews[].remarks": {telemetrytypes.String},
|
||||
"interests[].entities[].reviews[].weight": {telemetrytypes.Float64},
|
||||
"interests[].entities[].reviews[].passed": {telemetrytypes.Bool},
|
||||
"interests[].entities[].reviews[].type": {telemetrytypes.String},
|
||||
"interests[].entities[].reviews[].analysis_type": {telemetrytypes.Int64},
|
||||
"interests[].entities[].reviews[].entries": {telemetrytypes.ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].subject": {telemetrytypes.String},
|
||||
"interests[].entities[].reviews[].entries[].status": {telemetrytypes.String},
|
||||
"interests[].entities[].reviews[].entries[].metadata": {telemetrytypes.ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].company": {telemetrytypes.String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].experience": {telemetrytypes.Int64},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].unit": {telemetrytypes.String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions": {telemetrytypes.ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].name": {telemetrytypes.String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].duration": {telemetrytypes.Int64, telemetrytypes.Float64},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].unit": {telemetrytypes.String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {telemetrytypes.ArrayInt64, telemetrytypes.ArrayString},
|
||||
"message": {telemetrytypes.String},
|
||||
}
|
||||
|
||||
return types, makeGetTypes(types)
|
||||
}
|
||||
@@ -245,7 +245,7 @@ func (t *telemetryMetaStore) ListLogsJSONIndexes(ctx context.Context, filters ..
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
indexes := make(map[string][]schemamigrator.Index)
|
||||
indexesMap := make(map[string][]schemamigrator.Index)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var typeFull string
|
||||
@@ -254,7 +254,7 @@ func (t *telemetryMetaStore) ListLogsJSONIndexes(ctx context.Context, filters ..
|
||||
if err := rows.Scan(&name, &typeFull, &expr, &granularity); err != nil {
|
||||
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to scan string indexed column")
|
||||
}
|
||||
indexes[name] = append(indexes[name], schemamigrator.Index{
|
||||
indexesMap[name] = append(indexesMap[name], schemamigrator.Index{
|
||||
Name: name,
|
||||
Type: typeFull,
|
||||
Expression: expr,
|
||||
@@ -262,7 +262,7 @@ func (t *telemetryMetaStore) ListLogsJSONIndexes(ctx context.Context, filters ..
|
||||
})
|
||||
}
|
||||
|
||||
return indexes, nil
|
||||
return indexesMap, nil
|
||||
}
|
||||
|
||||
func (t *telemetryMetaStore) ListPromotedPaths(ctx context.Context, paths ...string) (map[string]struct{}, error) {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package telemetrytypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz-otel-collector/exporter/jsontypeexporter"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type JSONAccessBranchType struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
BranchJSON = JSONAccessBranchType{valuer.NewString("json")}
|
||||
BranchDynamic = JSONAccessBranchType{valuer.NewString("dynamic")}
|
||||
)
|
||||
|
||||
type JSONAccessPlan = []*JSONAccessNode
|
||||
|
||||
type TerminalConfig struct {
|
||||
Key *TelemetryFieldKey
|
||||
ElemType JSONDataType
|
||||
ValueType JSONDataType
|
||||
}
|
||||
|
||||
// Node is now a tree structure representing the complete JSON path traversal
|
||||
// that precomputes all possible branches and their types
|
||||
type JSONAccessNode struct {
|
||||
// Node information
|
||||
Name string
|
||||
IsTerminal bool
|
||||
isRoot bool // marked true for only body_json and body_json_promoted
|
||||
|
||||
// Precomputed type information (single source of truth)
|
||||
AvailableTypes []JSONDataType
|
||||
|
||||
// Array type branches (Array(JSON) vs Array(Dynamic))
|
||||
Branches map[JSONAccessBranchType]*JSONAccessNode
|
||||
|
||||
// Terminal configuration
|
||||
TerminalConfig *TerminalConfig
|
||||
|
||||
// Parent reference for traversal
|
||||
Parent *JSONAccessNode
|
||||
|
||||
// JSON progression parameters (precomputed during planning)
|
||||
MaxDynamicTypes int
|
||||
MaxDynamicPaths int
|
||||
}
|
||||
|
||||
func NewRootJSONAccessNode(name string, maxDynamicTypes, maxDynamicPaths int) *JSONAccessNode {
|
||||
return &JSONAccessNode{
|
||||
Name: name,
|
||||
isRoot: true,
|
||||
MaxDynamicTypes: maxDynamicTypes,
|
||||
MaxDynamicPaths: maxDynamicPaths,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *JSONAccessNode) Alias() string {
|
||||
if n.isRoot {
|
||||
return n.Name
|
||||
} else if n.Parent == nil {
|
||||
return fmt.Sprintf("`%s`", n.Name)
|
||||
}
|
||||
|
||||
parentAlias := strings.TrimLeft(n.Parent.Alias(), "`")
|
||||
parentAlias = strings.TrimRight(parentAlias, "`")
|
||||
|
||||
sep := jsontypeexporter.ArraySeparator
|
||||
if n.Parent.isRoot {
|
||||
sep = "."
|
||||
}
|
||||
return fmt.Sprintf("`%s%s%s`", parentAlias, sep, n.Name)
|
||||
}
|
||||
|
||||
func (n *JSONAccessNode) FieldPath() string {
|
||||
key := "`" + n.Name + "`"
|
||||
return n.Parent.Alias() + "." + key
|
||||
}
|
||||
Reference in New Issue
Block a user