Compare commits

..

1 Commits

Author SHA1 Message Date
amlannandy
10ba210d2a chore: add new v2 api func and hooks 2025-12-24 11:22:12 +07:00
27 changed files with 465 additions and 1380 deletions

View File

@@ -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**

View File

@@ -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

View File

@@ -0,0 +1,29 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, 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> | ErrorResponseV2> => {
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>);
}
};

View File

@@ -0,0 +1,39 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, 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> | ErrorResponseV2
> => {
try {
const encodedMetricName = encodeURIComponent(metricName);
const response = await axios.post(
'/metric/attributes',
{
metricName: encodedMetricName,
start,
end,
},
{
signal,
headers,
},
);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};

View File

@@ -0,0 +1,31 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, 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> | ErrorResponseV2
> => {
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>);
}
};

View File

@@ -0,0 +1,31 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, 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> | ErrorResponseV2
> => {
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>);
}
};

View File

@@ -0,0 +1,29 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, 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> | ErrorResponseV2> => {
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>);
}
};

View File

@@ -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),
),

View File

@@ -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',
},
],
});
});
});
});

View File

@@ -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',

View File

@@ -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}

View File

@@ -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();
});
});

View File

@@ -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"

View File

@@ -0,0 +1,28 @@
import { getMetricAlerts } from 'api/metricsExplorer/v2/getMetricAlerts';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
import { GetMetricAlertsResponse } from 'types/api/metricsExplorer/v2';
type UseGetMetricAlerts = (
metricName: string,
options?: UseQueryOptions<
SuccessResponseV2<GetMetricAlertsResponse> | ErrorResponseV2,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponseV2<GetMetricAlertsResponse> | ErrorResponseV2,
Error
>;
export const useGetMetricAlerts: UseGetMetricAlerts = (
metricName,
options,
headers,
) =>
useQuery<SuccessResponseV2<GetMetricAlertsResponse> | ErrorResponseV2, Error>({
queryFn: ({ signal }) => getMetricAlerts(metricName, signal, headers),
...options,
queryKey: [REACT_QUERY_KEY.GET_METRIC_ALERTS, metricName],
});

View File

@@ -0,0 +1,42 @@
import { getMetricAttributes } from 'api/metricsExplorer/v2/getMetricAttributes';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
import {
GetMetricAttributesRequest,
GetMetricAttributesResponse,
} from 'types/api/metricsExplorer/v2';
type UseGetMetricAttributes = (
requestData: GetMetricAttributesRequest,
options?: UseQueryOptions<
SuccessResponseV2<GetMetricAttributesResponse> | ErrorResponseV2,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponseV2<GetMetricAttributesResponse> | ErrorResponseV2,
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> | ErrorResponseV2,
Error
>({
queryFn: ({ signal }) => getMetricAttributes(requestData, signal, headers),
...options,
queryKey,
});
};

View File

@@ -0,0 +1,31 @@
import { getMetricDashboards } from 'api/metricsExplorer/v2/getMetricDashboards';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
import { GetMetricDashboardsResponse } from 'types/api/metricsExplorer/v2';
type UseGetMetricDashboards = (
metricName: string,
options?: UseQueryOptions<
SuccessResponseV2<GetMetricDashboardsResponse> | ErrorResponseV2,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponseV2<GetMetricDashboardsResponse> | ErrorResponseV2,
Error
>;
export const useGetMetricDashboards: UseGetMetricDashboards = (
metricName,
options,
headers,
) =>
useQuery<
SuccessResponseV2<GetMetricDashboardsResponse> | ErrorResponseV2,
Error
>({
queryFn: ({ signal }) => getMetricDashboards(metricName, signal, headers),
...options,
queryKey: [REACT_QUERY_KEY.GET_METRIC_DASHBOARDS, metricName],
});

View File

@@ -0,0 +1,31 @@
import { getMetricHighlights } from 'api/metricsExplorer/v2/getMetricHighlights';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
import { GetMetricHighlightsResponse } from 'types/api/metricsExplorer/v2';
type UseGetMetricHighlights = (
metricName: string,
options?: UseQueryOptions<
SuccessResponseV2<GetMetricHighlightsResponse> | ErrorResponseV2,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponseV2<GetMetricHighlightsResponse> | ErrorResponseV2,
Error
>;
export const useGetMetricHighlights: UseGetMetricHighlights = (
metricName,
options,
headers,
) =>
useQuery<
SuccessResponseV2<GetMetricHighlightsResponse> | ErrorResponseV2,
Error
>({
queryFn: ({ signal }) => getMetricHighlights(metricName, signal, headers),
...options,
queryKey: [REACT_QUERY_KEY.GET_METRIC_HIGHLIGHTS, metricName],
});

View File

@@ -0,0 +1,31 @@
import { getMetricMetadata } from 'api/metricsExplorer/v2/getMetricMetadata';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
import { GetMetricMetadataResponse } from 'types/api/metricsExplorer/v2';
type UseGetMetricMetadata = (
metricName: string,
options?: UseQueryOptions<
SuccessResponseV2<GetMetricMetadataResponse> | ErrorResponseV2,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponseV2<GetMetricMetadataResponse> | ErrorResponseV2,
Error
>;
export const useGetMetricMetadata: UseGetMetricMetadata = (
metricName,
options,
headers,
) =>
useQuery<
SuccessResponseV2<GetMetricMetadataResponse> | ErrorResponseV2,
Error
>({
queryFn: ({ signal }) => getMetricMetadata(metricName, signal, headers),
...options,
queryKey: [REACT_QUERY_KEY.GET_METRIC_METADATA, metricName],
});

View File

@@ -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: '',
},
],
}),

View File

@@ -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 {

View File

@@ -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>

View File

@@ -0,0 +1,60 @@
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;
}[];
};
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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
}