Compare commits

...

6 Commits

Author SHA1 Message Date
Abhi Kumar
67aa8d16dd fix(charts): stop tooltip labels breaking mid-word
`overflow-wrap: anywhere` dropped the label's min-content width to one
character, so it was the only row item that could give way to a wide
value. Charts whose legend names are short also got the narrowest
tooltip, which is where it showed worst.

Assisted-by: Claude Opus 5
2026-09-17 22:00:19 +05:30
Nikhil Soni
9f03fea0f3 fix(querybuilder): prefer resource over any context for ambiguous filter keys (#12888)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- A logs filter on a bare key that lives in **both** resource and
another context (body or scope) ANDed the two: the resource candidate
built the `__resource_filter` fingerprint CTE while the other candidate
landed as a required main-query term, so the query matched almost
nothing.
- `ResolveLogicalFields` only preferred resource over `attribute`.
Generalized it to prefer resource over **any** other context (attribute,
body, scope, …); other contexts stay reachable via their qualified names
(e.g. `body.service.name`).

#### Issues closed by this PR

Closes SigNoz/engineering-pod#6086
Part of https://github.com/SigNoz/platform-pod/issues/3158

#### Additional Information

Generalized rather than special-casing body/scope, since any future
context would hit the same fingerprint-CTE trap.
2026-09-17 14:45:45 +00:00
Naman Verma
d1a382945c fix: read Bearer/bearer/BEARER properly in v2 for webhook notification channels (#12890)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Webhook notification channels with a bearer token authorisation work in
v1 with all three spellings `Bearer/bearer/BEARER`, but v2 API was not
accepting anything other than `Bearer`. This PR changes the conversion
from receiver -> gettable flow.

Also, error messages are made better in 2 places.
2026-09-17 11:57:11 +00:00
Nikhil Soni
59af5e0367 refactor(telemetrystore): drop app-side bulk-filtering override (#12871)
#### Description

- Stop managing `secondary_indices_enable_bulk_filtering` from the app.
It was hardcoded to `false` in the query hook as a workaround for
[ClickHouse#82283](https://github.com/ClickHouse/ClickHouse/issues/82283)
(`CANNOT_READ_ALL_DATA` with SET-type skip indexes).
- That bug is fixed
([ClickHouse#87817](https://github.com/ClickHouse/ClickHouse/pull/87817),
backported to 25.7/25.8/25.9) and prod runs 25.12. Verified on a local
25.12.5 container against `signoz_index_v3` that the crash no longer
reproduces with bulk filtering enabled.
- Removes the hook override plus the now-unused config field and
`example.yaml` entry. The setting reverts to being controlled
server-side via ClickHouse profiles (the charts change tracked in the
same issue).

#### Issues closed by this PR

Closes SigNoz/engineering-pod#5915

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-17 09:40:26 +00:00
Pandey
8286e787b2 fix(tracefunnel): quote step names in slow and error trace queries (#12886)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- `#12593` moved the n-step trace-funnel query builders onto
`clickhousesql.StringLiteral`, but the two-step `slow-traces` and
`error-traces` builders still interpolated `service_name`/`span_name`
into the SQL string literal raw.
- Route those four values through the same helper, so every funnel query
builder quotes step names consistently.

#### Additional Information

- No behaviour change for ordinary names; the
`slow-traces`/`error-traces` funnel queries now handle names containing
a quote the same way the rest of the module already does.
2026-09-17 07:01:21 +00:00
Gaurav Tewari
e504c4081e feat/add support for fetch AI fieldKeys in order by and options (#12859)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Adds **Order by** and **Options** (column picker) to the AI
observability trace view.
- Both get their field list from one shared hook,
`useFieldKeysSuggestion`. The hook only fetches the keys. It does not
add or change anything.
- `FieldsSelector` and `ListViewOrderBy` now take one prop,
`useFieldApis`. It holds the API params and the static fields.
- Static fields are the ones the API never sends back, like `timestamp`
and `last_activity_time`. The component adds them to the list on its
own.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

Stacked on #12846. Please review and merge that one first.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-17 05:10:20 +00:00
38 changed files with 1524 additions and 196 deletions

View File

@@ -202,7 +202,6 @@ telemetrystore:
max_bytes_to_read: 0
max_result_rows: 0
ignore_data_skipping_indices: ""
secondary_indices_enable_bulk_filtering: false
##################### Prometheus #####################
prometheus:

View File

@@ -37,12 +37,13 @@ describe('getFieldKeySuggestions', () => {
const response = keysResponse();
mockedAIKeys.mockResolvedValue(response);
const filterConfig = { searchText: 'llm' };
const fieldKeysConfig = { searchText: 'llm' };
const abortSignal = new AbortController().signal;
await expect(
getFieldKeySuggestions(filterConfig, 'builder_ai_query'),
getFieldKeySuggestions(fieldKeysConfig, 'builder_ai_query', abortSignal),
).resolves.toBe(response);
expect(mockedAIKeys).toHaveBeenCalledWith(filterConfig);
expect(mockedAIKeys).toHaveBeenCalledWith(fieldKeysConfig, abortSignal);
expect(mockedGenericKeys).not.toHaveBeenCalled();
});
@@ -58,15 +59,16 @@ describe('getFieldKeySuggestions', () => {
const response = keysResponse();
mockedGenericKeys.mockResolvedValue(response);
const filterConfig = {
const fieldKeysConfig = {
signal: TelemetrytypesSignalDTO.traces,
searchText: 'svc',
};
const abortSignal = new AbortController().signal;
await expect(
getFieldKeySuggestions(filterConfig, builderQueryType),
getFieldKeySuggestions(fieldKeysConfig, builderQueryType, abortSignal),
).resolves.toBe(response);
expect(mockedGenericKeys).toHaveBeenCalledWith(filterConfig);
expect(mockedGenericKeys).toHaveBeenCalledWith(fieldKeysConfig, abortSignal);
expect(mockedAIKeys).not.toHaveBeenCalled();
});
});

View File

@@ -34,12 +34,13 @@ describe('getFieldValueSuggestions', () => {
const response = valuesResponse();
mockedAIValues.mockResolvedValue(response);
const filterConfig = { name: 'gen_ai.request.model', searchText: 'gpt' };
const fieldValuesConfig = { name: 'gen_ai.request.model', searchText: 'gpt' };
const abortSignal = new AbortController().signal;
await expect(
getFieldValueSuggestions(filterConfig, 'builder_ai_query'),
getFieldValueSuggestions(fieldValuesConfig, 'builder_ai_query', abortSignal),
).resolves.toBe(response);
expect(mockedAIValues).toHaveBeenCalledWith(filterConfig);
expect(mockedAIValues).toHaveBeenCalledWith(fieldValuesConfig, abortSignal);
expect(mockedGenericValues).not.toHaveBeenCalled();
});
@@ -55,16 +56,20 @@ describe('getFieldValueSuggestions', () => {
const response = valuesResponse();
mockedGenericValues.mockResolvedValue(response);
const filterConfig = {
const fieldValuesConfig = {
signal: TelemetrytypesSignalDTO.traces,
name: 'service.name',
searchText: 'front',
};
const abortSignal = new AbortController().signal;
await expect(
getFieldValueSuggestions(filterConfig, builderQueryType),
getFieldValueSuggestions(fieldValuesConfig, builderQueryType, abortSignal),
).resolves.toBe(response);
expect(mockedGenericValues).toHaveBeenCalledWith(filterConfig);
expect(mockedGenericValues).toHaveBeenCalledWith(
fieldValuesConfig,
abortSignal,
);
expect(mockedAIValues).not.toHaveBeenCalled();
});
});

View File

@@ -2,12 +2,13 @@ import { getAIObservabilityFieldsKeys } from 'api/generated/services/ai-observab
import { getFieldsKeys } from 'api/generated/services/fields';
import type { BuilderQueryType } from 'types/api/v5/queryRange';
import { FieldKeysFilterConfig, FieldKeysResponse } from './types';
import { FieldKeysConfig, FieldKeysResponse } from './types';
export const getFieldKeySuggestions = (
filterConfig: FieldKeysFilterConfig,
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
abortSignal?: AbortSignal,
): Promise<FieldKeysResponse> =>
builderQueryType === 'builder_ai_query'
? getAIObservabilityFieldsKeys(filterConfig)
: getFieldsKeys(filterConfig);
? getAIObservabilityFieldsKeys(fieldKeysConfig, abortSignal)
: getFieldsKeys(fieldKeysConfig, abortSignal);

View File

@@ -2,12 +2,13 @@ import { getAIObservabilityFieldsValues } from 'api/generated/services/ai-observ
import { getFieldsValues } from 'api/generated/services/fields';
import type { BuilderQueryType } from 'types/api/v5/queryRange';
import { FieldValuesFilterConfig, FieldValuesResponse } from './types';
import { FieldValuesConfig, FieldValuesResponse } from './types';
export const getFieldValueSuggestions = (
filterConfig: FieldValuesFilterConfig,
fieldValuesConfig: FieldValuesConfig,
builderQueryType?: BuilderQueryType,
abortSignal?: AbortSignal,
): Promise<FieldValuesResponse> =>
builderQueryType === 'builder_ai_query'
? getAIObservabilityFieldsValues(filterConfig)
: getFieldsValues(filterConfig);
? getAIObservabilityFieldsValues(fieldValuesConfig, abortSignal)
: getFieldsValues(fieldValuesConfig, abortSignal);

View File

@@ -1,7 +1,7 @@
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValuesParams,
GetFieldsKeys200,
GetFieldsKeysParams,
@@ -9,14 +9,19 @@ import type {
GetFieldsValuesParams,
} from 'api/generated/services/sigNoz.schemas';
export type FieldKeysFilterConfig =
export type FieldKeysConfig =
| GetFieldsKeysParams
| GetAIObservabilityFieldsKeysParams;
export type FieldValuesFilterConfig =
export type FieldValuesConfig =
| GetFieldsValuesParams
| GetAIObservabilityFieldsValuesParams;
export type FieldKeysConfigProp = Omit<
FieldKeysConfig,
'signal' | 'searchText'
>;
export type FieldKeysResponse =
| GetFieldsKeys200
| GetAIObservabilityFieldsKeys200;

View File

@@ -6,7 +6,8 @@ import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import AddedFields from './AddedFields';
@@ -31,6 +32,9 @@ interface FieldsSelectorProps {
// Lets users add a free-typed field which
// does not show up in the suggestions
allowCustomFields?: boolean;
fieldKeysConfig?: FieldKeysConfigProp;
builderQueryType?: BuilderQueryType;
extraFields?: TelemetryFieldKey[];
width?: number;
height?: number;
defaultPosition?: { x: number; y: number };
@@ -50,6 +54,9 @@ function FieldsSelectorContent({
maxFields,
requiredFields,
allowCustomFields,
fieldKeysConfig,
builderQueryType,
extraFields,
width = DEFAULT_PANEL_WIDTH,
height,
defaultPosition,
@@ -158,6 +165,9 @@ function FieldsSelectorContent({
onAdd={handleAdd}
isAtLimit={isAtLimit}
allowCustomFields={allowCustomFields}
fieldKeysConfig={fieldKeysConfig}
builderQueryType={builderQueryType}
extraFields={extraFields}
/>
{hasUnsavedChanges && (

View File

@@ -3,18 +3,22 @@ import { Button } from '@signozhq/ui/button';
import { Skeleton } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import {
BuilderQueryType,
FieldContext,
SignalType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import { mergeExtraFields } from 'utils/extraFields';
import styles from './FieldsSelector.module.scss';
const EMPTY_EXTRA_FIELDS: TelemetryFieldKey[] = [];
interface OtherFieldsProps {
signal: DataSource;
debouncedInputValue: string;
@@ -22,6 +26,9 @@ interface OtherFieldsProps {
onAdd: (field: TelemetryFieldKey) => void;
isAtLimit: boolean;
allowCustomFields?: boolean;
fieldKeysConfig?: FieldKeysConfigProp;
builderQueryType?: BuilderQueryType;
extraFields?: TelemetryFieldKey[];
}
function OtherFields({
@@ -31,26 +38,26 @@ function OtherFields({
onAdd,
isAtLimit,
allowCustomFields,
fieldKeysConfig,
builderQueryType,
extraFields = EMPTY_EXTRA_FIELDS,
}: OtherFieldsProps): JSX.Element {
const { data, isFetching } = useGetQueryKeySuggestions(
const { data: fetchedFields, isFetching } = useFieldKeysSuggestion(
{
signal,
...fieldKeysConfig,
signal: DATA_SOURCE_TO_SIGNAL[signal],
searchText: debouncedInputValue,
},
{
queryKey: [
REACT_QUERY_KEY.GET_FIELDS_SELECTOR_SUGGESTIONS,
signal,
debouncedInputValue,
],
enabled: true,
},
builderQueryType,
);
const otherFields = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data.data.keys || {}).flat();
const search = debouncedInputValue.trim().toLowerCase();
// Normalize: synthesize `key` once so downstream reads can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
const suggestions: TelemetryFieldKey[] = mergeExtraFields(
extraFields.filter((field) => field.name.toLowerCase().includes(search)),
fetchedFields ?? [],
).map((attr) => ({
...attr,
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
signal: attr.signal as SignalType,
@@ -87,7 +94,13 @@ function OtherFields({
key: buildCompositeKey(typed, ''),
};
return [customField, ...available];
}, [data, addedFields, allowCustomFields, debouncedInputValue]);
}, [
extraFields,
fetchedFields,
addedFields,
allowCustomFields,
debouncedInputValue,
]);
if (isFetching) {
return (

View File

@@ -1,11 +1,17 @@
import { act, fireEvent, render, screen } from 'tests/test-utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import FieldsSelector from '../FieldsSelector';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
jest.mock('hooks/querySuggestions/useFieldKeysSuggestion', () => ({
useFieldKeysSuggestion: jest.fn(() => ({
data: undefined,
isFetching: false,
isFetched: true,
})),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
@@ -21,22 +27,15 @@ jest.mock('periscope/components/FloatingPanel', () => ({
}));
const mockSuggestions = (names: string[]): void => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
},
},
},
},
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
data: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
isFetching: false,
isFetched: true,
});
};

View File

@@ -1,29 +1,30 @@
import { fireEvent, render, screen } from 'tests/test-utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import OtherFields from '../OtherFields';
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
jest.mock('hooks/querySuggestions/useFieldKeysSuggestion', () => ({
useFieldKeysSuggestion: jest.fn(() => ({
data: undefined,
isFetching: false,
isFetched: true,
})),
}));
const mockSuggestions = (names: string[]): void => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
},
},
},
},
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
data: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
isFetching: false,
isFetched: true,
});
};
@@ -82,7 +83,6 @@ describe('OtherFields — custom (free-typed) option', () => {
mockSuggestions(['orderId']);
renderOtherFields({ debouncedInputValue: 'orderid' });
// the real suggestion shows, the lowercased custom name does not
expect(screen.getByText('orderId')).toBeInTheDocument();
expect(screen.queryByText('orderid')).not.toBeInTheDocument();
});
@@ -116,10 +116,126 @@ describe('OtherFields — custom (free-typed) option', () => {
it('shows the custom option at the field limit but hides its Add button', () => {
renderOtherFields({ debouncedInputValue: 'unknown.a.b.c', isAtLimit: true });
// same as every other row at the limit: name shown, no Add button
expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /add/i }),
).not.toBeInTheDocument();
});
});
describe('OtherFields — field keys config', () => {
const pool: TelemetryFieldKey[] = [
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
{ name: 'llm_call_count', fieldContext: 'trace', fieldDataType: 'float64' },
];
const fieldKeysConfig: FieldKeysConfigProp = {
fieldContext: TelemetrytypesFieldContextDTO.trace,
};
const builderQueryType: BuilderQueryType = 'builder_ai_query';
const mockPool = (fields: TelemetryFieldKey[]): void => {
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
data: fields,
isFetching: false,
isFetched: true,
});
};
beforeEach(() => {
mockPool(pool);
});
it('lists the pool it is handed', () => {
renderOtherFields({
fieldKeysConfig,
builderQueryType,
allowCustomFields: false,
});
expect(screen.getByText('total_tokens')).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
it('forwards the fetch params and search to the shared keys hook', () => {
renderOtherFields({
fieldKeysConfig,
builderQueryType,
allowCustomFields: false,
debouncedInputValue: 'llm',
});
expect(useFieldKeysSuggestion).toHaveBeenCalledWith(
{
...fieldKeysConfig,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.LOGS],
searchText: 'llm',
},
builderQueryType,
);
});
it('lists extra fields the keys endpoint never returns', () => {
mockPool([{ name: 'total_tokens' } as TelemetryFieldKey]);
renderOtherFields({
fieldKeysConfig,
builderQueryType,
extraFields: [{ name: 'last_activity_time' } as TelemetryFieldKey],
allowCustomFields: false,
});
expect(screen.getByText('last_activity_time')).toBeInTheDocument();
expect(screen.getByText('total_tokens')).toBeInTheDocument();
});
it('filters extra fields by search text', () => {
mockPool([]);
renderOtherFields({
fieldKeysConfig,
builderQueryType,
extraFields: [
{ name: 'last_activity_time' } as TelemetryFieldKey,
{ name: 'timestamp' } as TelemetryFieldKey,
],
debouncedInputValue: 'activity',
allowCustomFields: false,
});
expect(screen.getByText('last_activity_time')).toBeInTheDocument();
expect(screen.queryByText('timestamp')).not.toBeInTheDocument();
});
it('keeps a fetched key whose name does not contain the search text', () => {
mockPool([
{ name: 'service.name', fieldContext: 'resource' } as TelemetryFieldKey,
]);
renderOtherFields({
debouncedInputValue: 'resource.service',
allowCustomFields: false,
});
expect(screen.getByText('service.name')).toBeInTheDocument();
});
it('omits pool fields that are already added', () => {
renderOtherFields({
fieldKeysConfig,
builderQueryType,
allowCustomFields: false,
addedFields: [
{
name: 'total_tokens',
fieldContext: 'trace',
fieldDataType: 'float64',
key: 'trace:total_tokens:float64',
},
],
});
expect(screen.queryByText('total_tokens')).not.toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
});

View File

@@ -1,16 +1,23 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { Select, Spin } from 'antd';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import './ListViewOrderBy.styles.scss';
const DEFAULT_EXTRA_FIELDS: TelemetryFieldKey[] = [
{ name: 'timestamp' } as TelemetryFieldKey,
];
interface ListViewOrderByProps {
value: string;
onChange: (value: string) => void;
dataSource: DataSource;
fieldKeysConfig?: FieldKeysConfigProp;
builderQueryType?: BuilderQueryType;
extraFields?: TelemetryFieldKey[];
}
// Loader component for the dropdown when loading or no results
@@ -26,6 +33,9 @@ function ListViewOrderBy({
value,
onChange,
dataSource,
fieldKeysConfig,
builderQueryType,
extraFields = DEFAULT_EXTRA_FIELDS,
}: ListViewOrderByProps): JSX.Element {
const [searchInput, setSearchInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState('');
@@ -34,17 +44,14 @@ function ListViewOrderBy({
>([]);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Fetch key suggestions based on debounced input
const { data, isLoading } = useQuery({
queryKey: ['orderByKeySuggestions', dataSource, debouncedInput],
queryFn: async () => {
const response = await getKeySuggestions({
signal: dataSource,
searchText: debouncedInput,
});
return response.data;
const { data, isLoading } = useFieldKeysSuggestion(
{
...fieldKeysConfig,
signal: DATA_SOURCE_TO_SIGNAL[dataSource],
searchText: debouncedInput,
},
});
builderQueryType,
);
useEffect(
() => (): void => {
@@ -55,24 +62,24 @@ function ListViewOrderBy({
[],
);
const extraKeysSignature = extraFields.map((field) => field.name).join(',');
// Update options when API data changes
useEffect(() => {
const rawKeys: QueryKeyDataSuggestionsProps[] = data?.data?.keys
? Object.values(data.data?.keys).flat()
: [];
const keyNames = (data ?? []).map((field) => field.name);
const search = searchInput.trim().toLowerCase();
const extraMatches = extraKeysSignature
.split(',')
.filter((key) => key.length > 0 && key.toLowerCase().includes(search));
const uniqueKeys = [...new Set([...extraMatches, ...keyNames])];
const keyNames = rawKeys.map((key) => key.name);
const uniqueKeys = [
...new Set(searchInput ? keyNames : ['timestamp', ...keyNames]),
];
const updatedOptions = uniqueKeys.flatMap((key) => [
{ label: `${key} (desc)`, value: `${key}:desc` },
{ label: `${key} (asc)`, value: `${key}:asc` },
]);
setSelectOptions(updatedOptions);
}, [data, searchInput]);
setSelectOptions(
uniqueKeys.flatMap((key) => [
{ label: `${key} (desc)`, value: `${key}:desc` },
{ label: `${key} (asc)`, value: `${key}:asc` },
]),
);
}, [data, searchInput, extraKeysSignature]);
// Handle search input with debounce
const handleSearch = (input: string): void => {

View File

@@ -0,0 +1,169 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { ENVIRONMENT } from 'constants/env';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_FIELD_KEYS,
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
} from 'container/LLMObservability/Explorer/constants';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import ListViewOrderBy from '../ListViewOrderBy';
const seenAI: URLSearchParams[] = [];
const seenGeneric: URLSearchParams[] = [];
const mockAIKeys = (names: string[]): void => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(req, res, ctx) => {
seenAI.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
},
}),
);
},
),
);
};
const mockGenericKeys = (names: string[]): void => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (req, res, ctx) => {
seenGeneric.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
},
}),
);
}),
);
};
const openDropdown = (): void => {
fireEvent.mouseDown(screen.getByRole('combobox'));
};
const getOptionLabels = (): string[] =>
Array.from(document.querySelectorAll('.ant-select-item-option-content')).map(
(node) => node.textContent ?? '',
);
describe('ListViewOrderBy', () => {
beforeEach(() => {
seenAI.length = 0;
seenGeneric.length = 0;
});
it('reads the ai_observability trace context for an AI query', async () => {
mockAIKeys(['total_tokens']);
render(
<ListViewOrderBy
value="last_activity_time:desc"
onChange={jest.fn()}
dataSource={DataSource.TRACES}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
/>,
);
await waitFor(() => {
expect(seenAI).toHaveLength(1);
});
expect(seenAI[0]?.get('searchText')).toBe('');
expect(seenAI[0]?.get('fieldContext')).toBe(
TelemetrytypesFieldContextDTO.trace,
);
expect(seenGeneric).toHaveLength(0);
});
it('offers the extra keys alongside the ones the endpoint reports', async () => {
mockAIKeys(['total_tokens']);
render(
<ListViewOrderBy
value="last_activity_time:desc"
onChange={jest.fn()}
dataSource={DataSource.TRACES}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
/>,
);
openDropdown();
await waitFor(() => {
expect(getOptionLabels()).toContain('total_tokens (desc)');
});
expect(getOptionLabels()).toContain('last_activity_time (asc)');
});
it('keeps a matching extra key while searching', async () => {
mockAIKeys([]);
render(
<ListViewOrderBy
value="last_activity_time:desc"
onChange={jest.fn()}
dataSource={DataSource.TRACES}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
/>,
);
await waitFor(() => {
expect(seenAI.length).toBeGreaterThan(0);
});
openDropdown();
fireEvent.change(screen.getByRole('combobox'), {
target: { value: 'activity' },
});
await waitFor(() => {
expect(getOptionLabels()).toContain('last_activity_time (desc)');
});
});
it('defaults to timestamp and the generic endpoint', async () => {
mockGenericKeys(['service.name']);
render(
<ListViewOrderBy
value="timestamp:desc"
onChange={jest.fn()}
dataSource={DataSource.TRACES}
/>,
);
await waitFor(() => {
expect(seenGeneric).toHaveLength(1);
});
expect(seenGeneric[0]?.get('signal')).toBe(DataSource.TRACES);
expect(seenGeneric[0]?.get('searchText')).toBe('');
openDropdown();
await waitFor(() => {
expect(getOptionLabels()).toContain('timestamp (desc)');
});
});
});

View File

@@ -12,6 +12,8 @@ export enum LOCALSTORAGE {
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',

View File

@@ -106,8 +106,8 @@ export const REACT_QUERY_KEY = {
// Dashboard Grid Card Query Keys
DASHBOARD_GRID_CARD_QUERY_RANGE: 'DASHBOARD_GRID_CARD_QUERY_RANGE',
// Fields Selector Query Keys
GET_FIELDS_SELECTOR_SUGGESTIONS: 'GET_FIELDS_SELECTOR_SUGGESTIONS',
// Field Keys Suggestion Query Keys
FIELD_KEYS_SUGGESTION: 'FIELD_KEYS_SUGGESTION',
// AI Assistant Query Keys
AI_ASSISTANT_EMPTY_STATE_CHIPS: 'AI_ASSISTANT_EMPTY_STATE_CHIPS',

View File

@@ -1,10 +1,12 @@
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings } from '@signozhq/icons';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import FieldsSelector from 'components/FieldsSelector';
import Controls, { ControlsProps } from 'container/Controls';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import styles from './Controls.module.scss';
@@ -14,7 +16,10 @@ function TraceExplorerControls({
totalCount,
perPageOptions,
config,
showSizeChanger = true,
fieldKeysConfig,
builderQueryType,
extraFields,
requiredFields,
}: TraceExplorerControlsProps): JSX.Element | null {
const { t } = useTranslation(['trace']);
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
@@ -44,6 +49,10 @@ function TraceExplorerControls({
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
fieldKeysConfig={fieldKeysConfig}
builderQueryType={builderQueryType}
extraFields={extraFields}
requiredFields={requiredFields}
/>
</>
)}
@@ -57,26 +66,28 @@ function TraceExplorerControls({
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
handleNavigateNext={handleNavigateNext}
handleNavigatePrevious={handleNavigatePrevious}
showSizeChanger={showSizeChanger}
/>
</div>
);
}
TraceExplorerControls.defaultProps = {
config: null,
};
type TraceExplorerControlsProps = Pick<
ControlsProps,
'isLoading' | 'totalCount' | 'perPageOptions'
> & {
config?: OptionsMenuConfig | null;
showSizeChanger?: boolean;
fieldKeysConfig?: FieldKeysConfigProp;
builderQueryType?: BuilderQueryType;
extraFields?: TelemetryFieldKey[];
requiredFields?: readonly string[];
};
TraceExplorerControls.defaultProps = {
showSizeChanger: true,
config: null,
fieldKeysConfig: undefined,
builderQueryType: undefined,
extraFields: undefined,
requiredFields: undefined,
};
export default memo(TraceExplorerControls);

View File

@@ -10,6 +10,25 @@
.actionsContainer {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
}
.orderByContainer {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.orderByLabel {
color: var(--muted-foreground);
// Between --periscope-font-size-small (11px) and -base (13px), so literal.
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
display: flex;
align-items: center;
gap: var(--spacing-2);
}

View File

@@ -3,35 +3,45 @@ import {
memo,
MutableRefObject,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { getTraceLink } from '../ListView/utils';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import TraceExplorerControls from '../Controls';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_DEFAULT_ORDER_BY,
TRACE_VIEW_FIELD_KEYS,
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
} from '../constants';
import { getListViewQuery } from '../explorerUtils';
import { columns, PER_PAGE_OPTIONS } from './configs';
import { PER_PAGE_OPTIONS } from './configs';
import { useTraceViewColumns } from './useTraceViewColumns';
import styles from './TracesView.module.scss';
interface TracesViewProps {
@@ -49,6 +59,16 @@ function TracesView({
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const [orderBy, setOrderBy] = useState<string>(TRACE_VIEW_DEFAULT_ORDER_BY);
const {
columns,
selectedFields,
onFieldsChange,
requiredFields,
isLoading: isColumnsLoading,
} = useTraceViewColumns();
const {
selectedTime: globalSelectedTime,
maxTime,
@@ -60,8 +80,8 @@ function TracesView({
);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
[stagedQuery],
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
[stagedQuery, orderBy],
);
const queryKey = useMemo(
@@ -73,6 +93,7 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
[
globalSelectedTime,
@@ -81,6 +102,7 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
);
@@ -142,27 +164,43 @@ function TracesView({
}
}, [isLoading, isFetching, isError, rows.length]);
const handleOrderChange = useCallback((value: string): void => {
setOrderBy(value);
}, []);
const fieldsSelectorConfig = useMemo(
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
[selectedFields, onFieldsChange],
);
return (
<div className={styles.container}>
<div className={styles.actionsContainer}>
<Typography>
This tab only shows Root Spans. More details
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
{' '}
here
</Typography.Link>
</Typography>
<div className="trace-explorer-controls">
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<div className={styles.orderByContainer}>
<div className={styles.orderByLabel}>
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
<ListViewOrderBy
value={orderBy}
onChange={handleOrderChange}
dataSource={DataSource.TRACES}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
/>
</div>
<TraceExplorerControls
isLoading={isLoading}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
config={fieldsSelectorConfig}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_COLUMN_EXTRA_FIELDS}
requiredFields={requiredFields}
/>
</div>
</div>
@@ -170,10 +208,11 @@ function TracesView({
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading}
isLoading={isLoading || isColumnsLoading}
isFetching={isFetching}
isError={isError}
error={error}

View File

@@ -0,0 +1,219 @@
/* eslint-disable no-restricted-syntax */
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { useTraceViewColumns } from '../useTraceViewColumns';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const AGGREGATE_KEYS = [
'llm_call_count',
'tool_call_count',
'distinct_tool_count',
'input_tokens',
'output_tokens',
'total_tokens',
'estimated_total_cost',
'max_llm_duration_nano',
];
const fieldNames = (fields: TelemetryFieldKey[]): string[] =>
fields.map((field) => field.name);
const columnNames = (columns: { header?: unknown }[]): string[] =>
columns.map((column) => column.header as string);
function wrapper({ children }: { children: ReactNode }): JSX.Element {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
const seenAI: URLSearchParams[] = [];
const mockAggregateKeys = (names: string[]): void => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(req, res, ctx) => {
seenAI.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [
name,
[
{
name,
fieldContext: TelemetrytypesFieldContextDTO.trace,
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
},
],
]),
),
},
}),
);
},
),
);
};
const renderColumns = async (): Promise<
ReturnType<typeof renderHook<ReturnType<typeof useTraceViewColumns>, unknown>>
> => {
const rendered = renderHook(() => useTraceViewColumns(), { wrapper });
await waitFor(() => {
expect(rendered.result.current.isLoading).toBe(false);
});
return rendered;
};
describe('useTraceViewColumns', () => {
beforeEach(() => {
seenAI.length = 0;
useColumnStore.getState().tables = {};
localStorage.clear();
mockAggregateKeys(AGGREGATE_KEYS);
});
it('reads the aggregates from the trace context of the keys endpoint', async () => {
await renderColumns();
expect(seenAI).toHaveLength(1);
expect(seenAI[0]?.get('searchText')).toBe('');
expect(seenAI[0]?.get('fieldContext')).toBe(
TelemetrytypesFieldContextDTO.trace,
);
});
it('pools the hardcoded display-only columns with the endpoint aggregates', async () => {
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toStrictEqual([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'trace_id',
'start_time',
'end_time',
'error_count',
'input',
'output',
...AGGREGATE_KEYS,
]);
});
it('selects only the default-visible columns on first render', async () => {
const { result } = await renderColumns();
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'trace_id',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
]);
});
it('keeps a newly reported aggregate hidden until it is picked', async () => {
mockAggregateKeys(['brand_new_aggregate']);
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toContain('brand_new_aggregate');
expect(fieldNames(result.current.selectedFields)).not.toContain(
'brand_new_aggregate',
);
});
it('hides the columns dropped from the selection', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([
{ name: 'trace_id' },
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'total_tokens',
]);
});
it('shows a column added back from the pool', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }, { name: 'input' }]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'input',
]);
});
it('keeps the trace id column even when the selection drops it', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'span_count' }]);
});
expect(fieldNames(result.current.selectedFields)).toContain('trace_id');
expect(result.current.requiredFields).toStrictEqual(['trace_id']);
});
it('persists the selection order', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'total_tokens',
'trace_id',
'service.name',
]);
expect(
useColumnStore.getState().tables[STORAGE_KEY].columnOrder,
).toStrictEqual([
'trace:total_tokens:float64',
'trace_id',
'resource:service.name',
]);
});
});

View File

@@ -5,18 +5,29 @@ import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
const TRACE_FIELDS = [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name' },
{ name: 'duration_nano' },
{ name: 'span_count' },
{ name: 'trace_id' },
] as TelemetryFieldKey[];
/** Always visible: it is the row's link to the trace. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
(field) => ({
/** Everything else starts hidden, including any aggregate the endpoint adds later. */
const DEFAULT_VISIBLE_FIELDS = new Set([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
TRACE_ID_COLUMN_ID,
]);
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
): TableColumnDef<TracesTableRow>[] =>
fields.map((field) => ({
...getFieldColumn(field),
enableRemove: false,
canBeHidden: false,
}),
);
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: field.name !== TRACE_ID_COLUMN_ID,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,
canBeHidden: field.name !== TRACE_ID_COLUMN_ID,
}));

View File

@@ -0,0 +1,109 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { mergeExtraFields } from 'utils/extraFields';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
hideColumn,
initializeFromDefaults,
setColumnOrder,
showColumn,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_FIELD_KEYS,
} from '../constants';
import { buildTraceViewColumns, TRACE_ID_COLUMN_ID } from './configs';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
interface UseTraceViewColumns {
columns: TableColumnDef<TracesTableRow>[];
selectedFields: TelemetryFieldKey[];
onFieldsChange: (next: TelemetryFieldKey[]) => void;
requiredFields: readonly string[];
isLoading: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
export function useTraceViewColumns(): UseTraceViewColumns {
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
{
...TRACE_VIEW_FIELD_KEYS,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
},
TRACE_VIEW_BUILDER_QUERY_TYPE,
);
const availableFields = useMemo(
() => mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
[fetchedFields],
);
const columns = useMemo(
() => buildTraceViewColumns(availableFields),
[availableFields],
);
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isFetched) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isFetched, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
const selectedFields = useMemo(() => {
const hidden = new Set(hiddenColumnIds);
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
return availableFields
.filter((field) => !hidden.has(columnIdOf(field)))
.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
);
}, [availableFields, hiddenColumnIds, columnOrder]);
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
showColumn(STORAGE_KEY, column.id);
} else {
hideColumn(STORAGE_KEY, column.id);
}
});
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
},
[columns],
);
return {
columns,
selectedFields,
onFieldsChange,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
};
}

View File

@@ -1,3 +1,6 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const TOOLBAR_VIEWS = {
list: {
name: 'list',
@@ -34,3 +37,29 @@ export const TOOLBAR_VIEWS = {
key: 'clickhouse',
},
};
export const TRACE_VIEW_DEFAULT_ORDER_BY = 'last_activity_time:desc';
/** Display-only: ordering or filtering on one is an error, so the keys endpoint omits them. */
export const TRACE_VIEW_COLUMN_EXTRA_FIELDS: TelemetryFieldKey[] = [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'root_span_name' },
{ name: 'trace_duration_nano' },
{ name: 'span_count' },
{ name: 'trace_id' },
{ name: 'start_time' },
{ name: 'end_time' },
{ name: 'error_count' },
{ name: 'input' },
{ name: 'output' },
] as TelemetryFieldKey[];
export const TRACE_VIEW_FIELD_KEYS = {
fieldContext: TelemetrytypesFieldContextDTO.trace,
} as const;
export const TRACE_VIEW_BUILDER_QUERY_TYPE = 'builder_ai_query' as const;
export const TRACE_VIEW_ORDER_BY_EXTRA_FIELDS: TelemetryFieldKey[] = [
{ name: 'last_activity_time' } as TelemetryFieldKey,
];

View File

@@ -0,0 +1,185 @@
import { QueryClient } from 'react-query';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldKeysConfig, FieldKeysResponse } from 'api/querySuggestions/types';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import {
getFieldKeysQueryOptions,
toFieldKeys,
} from '../useFieldKeysSuggestion';
/** Drives the options object the way react-query does, without a client. */
const fetchKeys = async (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
): Promise<TelemetryFieldKey[]> => {
const { queryFn, select } = getFieldKeysQueryOptions(
fieldKeysConfig,
builderQueryType,
);
const response = await (
queryFn as (context: { signal: AbortSignal }) => Promise<FieldKeysResponse>
)({ signal: new AbortController().signal });
return select?.(response) ?? [];
};
const mockKeys = (
path: '/api/v1/ai_observability/fields/keys' | '/api/v1/fields/keys',
names: string[],
onRequest?: (params: URLSearchParams) => void,
): void => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}${path}`, (req, res, ctx) => {
onRequest?.(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
},
}),
);
}),
);
};
describe('useFieldKeysSuggestion', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
const seen: URLSearchParams[] = [];
mockKeys(
'/api/v1/ai_observability/fields/keys',
['total_tokens'],
(params) => {
seen.push(params);
},
);
const keys = await fetchKeys(
{
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: 'llm',
fieldContext: TelemetrytypesFieldContextDTO.trace,
},
'builder_ai_query',
);
expect(seen).toHaveLength(1);
expect(seen[0]?.get('searchText')).toBe('llm');
expect(seen[0]?.get('fieldContext')).toBe(
TelemetrytypesFieldContextDTO.trace,
);
expect(keys.map((key) => key.name)).toStrictEqual(['total_tokens']);
});
it('reads the generic endpoint for an unmarked query', async () => {
const seen: URLSearchParams[] = [];
mockKeys('/api/v1/fields/keys', ['service.name'], (params) => {
seen.push(params);
});
const keys = await fetchKeys({
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: 'svc',
});
expect(seen).toHaveLength(1);
expect(seen[0]?.get('signal')).toBe(DataSource.TRACES);
expect(seen[0]?.get('searchText')).toBe('svc');
expect(keys.map((key) => key.name)).toStrictEqual(['service.name']);
});
it('reads the trace context of the ai_observability endpoint', async () => {
const seen: URLSearchParams[] = [];
mockKeys(
'/api/v1/ai_observability/fields/keys',
['total_tokens'],
(params) => {
seen.push(params);
},
);
const keys = await fetchKeys(
{
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
fieldContext: TelemetrytypesFieldContextDTO.trace,
},
'builder_ai_query',
);
expect(seen[0]?.get('searchText')).toBe('');
expect(keys.map((key) => key.name)).toStrictEqual(['total_tokens']);
});
it('reuses the cached keys response for a second empty search', async () => {
const seen: URLSearchParams[] = [];
mockKeys(
'/api/v1/ai_observability/fields/keys',
['total_tokens'],
(params) => {
seen.push(params);
},
);
const fieldKeysConfig = {
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
fieldContext: TelemetrytypesFieldContextDTO.trace,
};
// Built twice: equal keys must resolve to one cache entry, not two requests.
await queryClient.fetchQuery(
getFieldKeysQueryOptions(fieldKeysConfig, 'builder_ai_query'),
);
await queryClient.fetchQuery(
getFieldKeysQueryOptions(fieldKeysConfig, 'builder_ai_query'),
);
expect(seen).toHaveLength(1);
});
it('hands the query signal to the fetcher so a superseded search aborts', async () => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_req, res, ctx) =>
res(ctx.delay(500), ctx.status(200), ctx.json({ status: 'success' })),
),
);
const controller = new AbortController();
const { queryFn } = getFieldKeysQueryOptions({
signal: DATA_SOURCE_TO_SIGNAL[DataSource.LOGS],
searchText: 'svc',
});
const pending = (
queryFn as (context: { signal: AbortSignal }) => Promise<unknown>
)({ signal: controller.signal });
controller.abort();
await expect(pending).rejects.toBeDefined();
});
it('treats a null keys map as empty', () => {
expect(
toFieldKeys({
status: 'success',
data: { complete: false, keys: null },
}),
).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,50 @@
import {
QueryKey,
useQuery,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { getFieldKeySuggestions } from 'api/querySuggestions/getFieldKeySuggestions';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { FieldKeysConfig, FieldKeysResponse } from 'api/querySuggestions/types';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
export type FieldKeysQueryOptions = UseQueryOptions<
FieldKeysResponse,
ErrorType<RenderErrorResponseDTO>,
TelemetryFieldKey[]
> & { queryKey: QueryKey };
export const toFieldKeys = (
res: FieldKeysResponse | undefined,
): TelemetryFieldKey[] =>
Object.values(res?.data?.keys ?? {})
.flat()
.map((key) => ({ ...key }) as TelemetryFieldKey);
export const getFieldKeysQueryOptions = (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
): FieldKeysQueryOptions => ({
queryKey: [
REACT_QUERY_KEY.FIELD_KEYS_SUGGESTION,
builderQueryType,
fieldKeysConfig,
],
queryFn: ({ signal }): Promise<FieldKeysResponse> =>
getFieldKeySuggestions(fieldKeysConfig, builderQueryType, signal),
select: toFieldKeys,
staleTime: FIELD_API_CACHE_TIME,
cacheTime: FIELD_API_CACHE_TIME,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
export const useFieldKeysSuggestion = (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
): UseQueryResult<TelemetryFieldKey[], ErrorType<RenderErrorResponseDTO>> =>
useQuery(getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType));

View File

@@ -25,6 +25,7 @@
.uplotTooltipItemContent {
width: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: var(--spacing-2);
@@ -40,16 +41,24 @@
}
.uplotTooltipItemLabel {
min-width: 0;
// Not `anywhere`, which drops min-content to one character and lets a wide
// value squeeze the label into a mid-word break.
white-space: normal;
overflow-wrap: anywhere;
overflow-wrap: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.uplotTooltipItemValue {
white-space: nowrap;
flex: 0 0 auto;
}
.uplotTooltipItemContentSeparator {
flex: 1;
flex: 1 1 24px;
border-width: 0.5px;
border-style: dashed;
min-width: 24px;

View File

@@ -34,7 +34,9 @@ export default function TooltipItem({
style={{ color: item.color }}
data-testid={contentTestId}
>
<span className={Styles.uplotTooltipItemLabel}>{item.label}</span>
<span className={Styles.uplotTooltipItemLabel} title={item.label}>
{item.label}
</span>
<span
className={Styles.uplotTooltipItemContentSeparator}
style={{ borderColor: item.color }}

View File

@@ -17,7 +17,8 @@ import { ChartWrapperProps } from 'lib/visualization/charts/types';
import { useChartStacking } from 'lib/visualization/charts/ChartWrapper/useChartStacking';
const TOOLTIP_WIDTH_PADDING = 120;
const TOOLTIP_MIN_WIDTH = 300;
// Holds a tooltip row's value column next to a legend-length label.
const TOOLTIP_MIN_WIDTH = 360;
export default function ChartWrapper({
legendConfig = { position: LegendPosition.BOTTOM },

View File

@@ -0,0 +1,44 @@
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { mergeExtraFields } from '../extraFields';
describe('mergeExtraFields', () => {
it('drops fetched keys that share a composite key with an extra field', () => {
expect(
mergeExtraFields(
[{ name: 'trace_id' } as TelemetryFieldKey],
[
{ name: 'trace_id' } as TelemetryFieldKey,
{ name: 'total_tokens' } as TelemetryFieldKey,
],
).map((key) => key.name),
).toStrictEqual(['trace_id', 'total_tokens']);
});
it('keeps extra and fetched keys that share a name but differ in context', () => {
expect(
mergeExtraFields(
[{ name: 'service.name', fieldContext: 'resource' } as TelemetryFieldKey],
[
{
name: 'service.name',
fieldContext: 'attribute',
} as TelemetryFieldKey,
{ name: 'total_tokens' } as TelemetryFieldKey,
],
),
).toStrictEqual([
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'service.name', fieldContext: 'attribute' },
{ name: 'total_tokens' },
]);
});
it('returns the fetched keys when there are no extra fields', () => {
expect(
mergeExtraFields(undefined, [
{ name: 'total_tokens' } as TelemetryFieldKey,
]).map((key) => key.name),
).toStrictEqual(['total_tokens']);
});
});

View File

@@ -0,0 +1,23 @@
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const mergeExtraFields = (
extra: TelemetryFieldKey[] = [],
fetched: TelemetryFieldKey[],
): TelemetryFieldKey[] => {
const extraKeys = new Set(
extra.map((field) =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType),
),
);
return [
...extra,
...fetched.filter(
(field) =>
!extraKeys.has(
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType),
),
),
];
};

View File

@@ -495,8 +495,8 @@ WITH
toDateTime64(%[3]d/1e9, 9) AS start_ts,
toDateTime64(%[4]d/1e9, 9) AS end_ts,
('%[5]s','%[6]s') AS step1,
('%[7]s','%[8]s') AS step2
(%[5]s,%[6]s) AS step1,
(%[7]s,%[8]s) AS step2
SELECT
trace_id,
@@ -527,10 +527,10 @@ LIMIT 5;
containsErrorT2,
startTs,
endTs,
serviceNameT1,
spanNameT1,
serviceNameT2,
spanNameT2,
clickhousesql.StringLiteral(serviceNameT1),
clickhousesql.StringLiteral(spanNameT1),
clickhousesql.StringLiteral(serviceNameT2),
clickhousesql.StringLiteral(spanNameT2),
clauseStep1,
clauseStep2,
t1TimeExpr,
@@ -571,8 +571,8 @@ WITH
toDateTime64(%[3]d/1e9, 9) AS start_ts,
toDateTime64(%[4]d/1e9, 9) AS end_ts,
('%[5]s','%[6]s') AS step1,
('%[7]s','%[8]s') AS step2
(%[5]s,%[6]s) AS step1,
(%[7]s,%[8]s) AS step2
SELECT
trace_id,
@@ -607,10 +607,10 @@ LIMIT 5;
containsErrorT2,
startTs,
endTs,
serviceNameT1,
spanNameT1,
serviceNameT2,
spanNameT2,
clickhousesql.StringLiteral(serviceNameT1),
clickhousesql.StringLiteral(spanNameT1),
clickhousesql.StringLiteral(serviceNameT2),
clickhousesql.StringLiteral(spanNameT2),
clauseStep1,
clauseStep2,
t1TimeExpr,

View File

@@ -25,8 +25,9 @@ const (
// ResolveLogicalFields picks which logical fields a filter term builds conditions
// for. With 0 or 1 field it returns the input unchanged and no warning. When a
// name is ambiguous (several logical fields — a family is one field and never
// ambiguous with itself) it returns a warning; a resource+attribute mix defaults
// to the resource fields (the common intent), noted in the warning.
// ambiguous with itself) it returns a warning; a resource + other-context mix
// (attribute, body, scope, …) defaults to the resource fields (the common
// intent), noted in the warning.
func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields []*telemetrytypes.LogicalField) ([]*telemetrytypes.LogicalField, string) {
if len(logicalFields) <= 1 {
return logicalFields, ""
@@ -39,18 +40,17 @@ func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields
logicalFields,
)
hasResource, hasAttribute := false, false
hasResource, hasOther := false, false
for _, item := range logicalFields {
switch item.FieldContext {
case telemetrytypes.FieldContextResource:
if item.FieldContext == telemetrytypes.FieldContextResource {
hasResource = true
case telemetrytypes.FieldContextAttribute:
hasAttribute = true
} else {
hasOther = true
}
}
// when there is both resource and attribute context, default to resource only
if hasResource && hasAttribute {
// with resource and any other context, default to resource only
if hasResource && hasOther {
filtered := make([]*telemetrytypes.LogicalField, 0, len(logicalFields))
for _, item := range logicalFields {
if item.FieldContext == telemetrytypes.FieldContextResource {
@@ -58,8 +58,8 @@ func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields
}
}
logicalFields = filtered
warning += " " + "Using `resource` context by default. To query attributes explicitly, " +
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s')", field.Name)
warning += " " + "Using `resource` context by default. To query another context explicitly, " +
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s' or 'body.%s')", field.Name, field.Name)
}
return logicalFields, warning

View File

@@ -175,6 +175,42 @@ func TestResolveLogicalFieldsKeepsFamilyThroughAmbiguity(t *testing.T) {
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, memberNames(resolved[0]))
}
// Resource wins over every other context, not just attribute: a bare key that
// also lives in body or scope must collapse to resource alone, so the surviving
// candidate does not AND against the resource fingerprint CTE.
func TestResolveLogicalFieldsResourceWinsOverOtherContexts(t *testing.T) {
testCases := []struct {
name string
other telemetrytypes.FieldContext
}{
{name: "ResourceOverBody", other: telemetrytypes.FieldContextBody},
{name: "ResourceOverScope", other: telemetrytypes.FieldContextScope},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
requested := &telemetrytypes.TelemetryFieldKey{Name: "service.name"}
fields := []*telemetrytypes.LogicalField{
telemetrytypes.SingleLogicalField("service.name", &telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}),
telemetrytypes.SingleLogicalField("service.name", &telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: testCase.other,
FieldDataType: telemetrytypes.FieldDataTypeString,
}),
}
resolved, warning := ResolveLogicalFields(requested, fields)
assert.NotEmpty(t, warning)
require.Len(t, resolved, 1)
assert.Equal(t, telemetrytypes.FieldContextResource, resolved[0].FieldContext)
})
}
}
// Members of a family with different data types never merge: the identity
// (signal, context, data type) separates them into distinct logical fields.
func TestMatchingLogicalFieldsNeverMergesAcrossDataTypes(t *testing.T) {

View File

@@ -0,0 +1,90 @@
package logsstatementbuilder
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
// A key present in both resource and body contexts must filter on resource only.
// The resource condition builds the fingerprint CTE, so a surviving body condition
// would AND against it and match almost nothing (engineering-pod#6086).
func TestStatementBuilderResourceBodyConflict(t *testing.T) {
store := telemetrytypestest.NewMockMetadataStore()
store.SetStaticFields(logstelemetryschema.IntrinsicFields)
store.SetKey(&telemetrytypes.TelemetryFieldKey{
Name: "service.name",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
})
bodyKey := &telemetrytypes.TelemetryFieldKey{
Name: "service.name",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
require.NoError(t, bodyKey.SetJSONAccessPlan(telemetrytypes.JSONColumnMetadata{
BaseColumn: logstelemetryschema.LogsV2BodyV2Column,
PromotedColumn: logstelemetryschema.LogsV2BodyPromotedColumn,
}, map[string][]telemetrytypes.FieldDataType{"service.name": {telemetrytypes.FieldDataTypeString}}))
store.SetKey(bodyKey)
fl := flaggertest.WithUseJSONBody(t, true)
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
store,
storage,
aggExprRewriter,
logstelemetryschema.DefaultFullTextColumn,
fl,
nil,
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: false, Threshold: 100000}},
)
testCases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
expected qbtypes.Statement
}{
{
name: "AmbiguousKeyFiltersResourceOnly",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "service.name = 'webapp'"},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"webapp", "%service.name%", "%service.name\":\"webapp%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{
"Key `service.name` is ambiguous, found 2 different combinations of field context / data type: [name=service.name,context=resource,datatype=string name=service.name,context=body,datatype=string]. Using `resource` context by default. To query another context explicitly, use the fully qualified name (e.g., 'attribute.service.name' or 'body.service.name')",
},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
require.NoError(t, err)
require.Equal(t, testCase.expected.Query, q.Query)
require.Equal(t, testCase.expected.Args, q.Args)
require.Equal(t, testCase.expected.Warnings, q.Warnings)
})
}
}

View File

@@ -46,7 +46,6 @@ type QuerySettings struct {
MaxBytesToRead int `mapstructure:"max_bytes_to_read"`
MaxResultRows int `mapstructure:"max_result_rows"`
IgnoreDataSkippingIndices string `mapstructure:"ignore_data_skipping_indices"`
SecondaryIndicesEnableBulkFiltering bool `mapstructure:"secondary_indices_enable_bulk_filtering"`
}
func NewConfigFactory() factory.ConfigFactory {

View File

@@ -72,10 +72,6 @@ func (h *provider) BeforeQuery(ctx context.Context, _ *telemetrystore.QueryEvent
settings["result_overflow_mode"] = ctx.Value("result_overflow_mode")
}
// TODO(srikanthccv): enable it when the "Cannot read all data" issue is fixed
// https://github.com/ClickHouse/ClickHouse/issues/82283
settings["secondary_indices_enable_bulk_filtering"] = false
ctx = clickhouse.Context(ctx, clickhouse.WithSettings(settings))
return ctx
}

View File

@@ -1014,7 +1014,7 @@ func rejectHTTPBasicAuthBeyondPassword(channelName string, httpConfig *commoncfg
basicAuth := httpConfig.BasicAuth
if *basicAuth != (commoncfg.BasicAuth{Username: basicAuth.Username, Password: basicAuth.Password}) {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth, which is not supported", channelName)
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth with fields other than username and password, which is not supported", channelName)
}
return nil
@@ -1026,8 +1026,8 @@ func rejectHTTPAuthorizationBeyondBearer(channelName string, httpConfig *commonc
}
authorization := httpConfig.Authorization
if *authorization != (commoncfg.Authorization{Type: bearerAuthorizationType, Credentials: authorization.Credentials}) {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", channelName)
if !strings.EqualFold(authorization.Type, bearerAuthorizationType) || *authorization != (commoncfg.Authorization{Type: authorization.Type, Credentials: authorization.Credentials}) {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization with fields other than a bearer token, which is not supported", channelName)
}
return nil

View File

@@ -542,3 +542,42 @@ func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
})
}
}
// The HTTP auth scheme is case-insensitive (RFC 7235) and Alertmanager sends
// the stored spelling verbatim, so a hand-written receiver may carry any casing.
func TestChannelToPostableChannelReadsWebhookBearerSchemeCaseInsensitively(t *testing.T) {
sendResolved := config.DefaultWebhookConfig.VSendResolved
testCases := []struct {
name string
storedChannelData string
expectedWebhookSpec *ChannelWebhookConfig
}{
{
name: "CanonicalBearer",
storedChannelData: `{"name":"hook","webhook_configs":[{"send_resolved":true,"url":"https://a","http_config":{"authorization":{"type":"Bearer","credentials":"tok"},"follow_redirects":true,"enable_http2":true}}]}`,
expectedWebhookSpec: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://a", BearerToken: "tok"},
},
{
name: "LowercaseBearer",
storedChannelData: `{"name":"hook","webhook_configs":[{"send_resolved":true,"url":"https://b","http_config":{"authorization":{"type":"bearer","credentials":"lower"},"follow_redirects":true,"enable_http2":true}}]}`,
expectedWebhookSpec: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://b", BearerToken: "lower"},
},
{
name: "UppercaseBearer",
storedChannelData: `{"name":"hook","webhook_configs":[{"send_resolved":true,"url":"https://c","http_config":{"authorization":{"type":"BEARER","credentials":"upper"},"follow_redirects":true,"enable_http2":true}}]}`,
expectedWebhookSpec: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://c", BearerToken: "upper"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
channel := Channel{DisplayName: "hook", Data: testCase.storedChannelData}
postable, err := channel.toPostableNotificationChannel()
require.NoError(t, err)
assert.Equal(t, ChannelKindWebhook, postable.Config.Kind)
assert.Equal(t, testCase.expectedWebhookSpec, postable.Config.Spec)
})
}
}

View File

@@ -0,0 +1,88 @@
import json
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import (
build_raw_query,
get_rows,
make_query_request,
)
def test_resource_body_conflict(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
export_json_types: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC)
start_ms = int((now - timedelta(seconds=10)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
# python's body carries service.name, making the bare key ambiguous across
# resource and body; java's body omits it, so ANDing body in would drop it.
logs_list = [
Logs(
timestamp=now - timedelta(seconds=2),
resources={"service.name": "java"},
body_v2=json.dumps({"msg": "hello"}),
body_promoted="",
),
Logs(
timestamp=now - timedelta(seconds=1),
resources={"service.name": "python"},
body_v2=json.dumps({"service.name": "python"}),
body_promoted="",
),
]
export_json_types(logs_list)
insert_logs(logs_list)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
cases = [
{
"name": "bare_key_resolves_to_resource",
"filter": "service.name = 'java'",
"expected_service_names": ["java"],
"expect_resource_warning": True,
},
{
"name": "qualified_body_key_targets_body",
"filter": "body.service.name = 'python'",
"expected_service_names": ["python"],
"expect_resource_warning": False,
},
]
for case in cases:
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
request_type="raw",
queries=[
build_raw_query(
name="A",
signal="logs",
filter_expression=case["filter"],
limit=100,
step_interval=60,
)
],
)
assert response.status_code == HTTPStatus.OK, f"{case['name']}: {response.text}"
rows = get_rows(response)
assert [row["data"]["resources_string"].get("service.name") for row in rows] == case["expected_service_names"], f"{case['name']}: {response.json()}"
warning = response.json()["data"].get("warning")
if case["expect_resource_warning"]:
assert warning is not None and "Using `resource` context by default" in warning["warnings"][0]["message"], f"{case['name']}: {warning}"
else:
assert warning is None, f"{case['name']}: {warning}"

View File

@@ -64,8 +64,8 @@ def test_resource_default_warning(
"Key `service.name` is ambiguous, found 2 different combinations of "
"field context / data type: [name=service.name,context=resource,datatype=string "
"name=service.name,context=attribute,datatype=string]. Using `resource` context "
"by default. To query attributes explicitly, use the fully qualified name "
"(e.g., 'attribute.service.name')"
"by default. To query another context explicitly, use the fully qualified name "
"(e.g., 'attribute.service.name' or 'body.service.name')"
)
assert warning["warnings"] == [
{"message": expected_service_name_warning},
@@ -237,8 +237,8 @@ def test_deduped_warnings_for_single_query(
"Key `service.name` is ambiguous, found 2 different combinations of "
"field context / data type: [name=service.name,context=resource,datatype=string "
"name=service.name,context=attribute,datatype=string]. Using `resource` context "
"by default. To query attributes explicitly, use the fully qualified name "
"(e.g., 'attribute.service.name')"
"by default. To query another context explicitly, use the fully qualified name "
"(e.g., 'attribute.service.name' or 'body.service.name')"
)
expected_status_code_warning = "Key `http.status_code` is ambiguous, found 2 different combinations of field context / data type: [name=http.status_code,context=attribute,datatype=number name=http.status_code,context=attribute,datatype=string]."
assert warning["warnings"] == [
@@ -328,8 +328,8 @@ def test_deduped_warnings_for_multiple_queries(
"Key `service.name` is ambiguous, found 2 different combinations of "
"field context / data type: [name=service.name,context=resource,datatype=string "
"name=service.name,context=attribute,datatype=string]. Using `resource` context "
"by default. To query attributes explicitly, use the fully qualified name "
"(e.g., 'attribute.service.name')"
"by default. To query another context explicitly, use the fully qualified name "
"(e.g., 'attribute.service.name' or 'body.service.name')"
)
expected_status_code_warning = "Key `http.status_code` is ambiguous, found 2 different combinations of field context / data type: [name=http.status_code,context=attribute,datatype=number name=http.status_code,context=attribute,datatype=string]."
assert warning["warnings"] == [