Compare commits

..

5 Commits

Author SHA1 Message Date
aks07
8bdc8ad742 test(traces): add MSW data-render tests for grouped and list views 2026-08-24 22:13:45 +05:30
aks07
4f0193c87b feat(traces): migrate grouped view to the shared TanStack table
Swaps ResizeTable for the shared TracesTable, so the grouped view gets
resizable and reorderable columns persisted per-device via
TRACES_VIEW_COLUMNS. Keeps prev/next pagination; the toolbar now always
renders so pagination doesn't disappear on an empty result.
2026-08-24 22:13:45 +05:30
aks07
afadfc6a12 feat(traces): render trace_id as a link and badge dotted status fields in the shared cell 2026-08-24 22:13:45 +05:30
Aditya Singh
fe68b8e8b7 feat(traces): table migration to tanstack for list view in traces explorer (#12667)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
<!--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
- moved list view from antd `ResizeTable` to Tanstack table.
functionalities kept same.
- pulled out a reusable trace table. new shared table + per field column
builder. This is added to keep the table renderer common for both
ListView and Trace View because they do not need to be different. Trace
view will integrate this component in following stacked PR.
- two new override vars on `TanStackTableView` (header height, first
column header padding)


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

Part of https://github.com/SigNoz/engineering-pod/issues/5052

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



https://github.com/user-attachments/assets/d3a75b38-7cf5-4ab0-a7b4-fce404a03e63



<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- Touches the shared `TanStackTableView` component.. two new override
vars, defaults unchanged for other tables. cc. @H4ad

<!--Please delete paragraphs that you did not use before submitting.-->
2026-08-24 16:32:44 +00:00
Aditya Singh
77fbf74092 feat(logs): allow adding free-typed columns in logs explorer (#12602)
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
<!--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
- Lets users add a free-typed column in the logs explorer "Edit columns"
panel, even if the key is not in the fields suggestions (e.g. nested
body json paths). Logs only.
- Shows the typed value as an addable option when it is not already a
suggestion or added. Exact, case-insensitive name match.
- Value shows via the existing body-first lookup. Nothing new is sent to
the backend for logs.
- Changed the column key separator from `.` to `:` so a typed dotted
name cannot clash with a context key (e.g. `resource.severity_text`).
Old saved keys self-heal, no migration.

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

Closes https://github.com/SigNoz/engineering-pod/issues/5877

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



https://github.com/user-attachments/assets/0e91bb00-4be5-4dc7-ad3e-0e005ee6eb6b



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

Value needs `use_json_body` on for nested body paths, else the cell is
empty. Array paths and a leading `body.` dont resolve on the frontend
for now.
<!--Please delete paragraphs that you did not use before submitting.-->
2026-08-24 05:53:28 +00:00
33 changed files with 1180 additions and 629 deletions

View File

@@ -28,6 +28,9 @@ interface FieldsSelectorProps {
signal: DataSource;
maxFields?: number;
requiredFields?: readonly string[];
// Lets users add a free-typed field which
// does not show up in the suggestions
allowCustomFields?: boolean;
width?: number;
height?: number;
defaultPosition?: { x: number; y: number };
@@ -46,6 +49,7 @@ function FieldsSelectorContent({
signal,
maxFields,
requiredFields,
allowCustomFields,
width = DEFAULT_PANEL_WIDTH,
height,
defaultPosition,
@@ -67,7 +71,7 @@ function FieldsSelectorContent({
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim().toLowerCase();
const value = e.target.value.trim();
setInputValue(value);
debouncedUpdate(value);
},
@@ -153,6 +157,7 @@ function FieldsSelectorContent({
addedFields={draftFields}
onAdd={handleAdd}
isAtLimit={isAtLimit}
allowCustomFields={allowCustomFields}
/>
{hasUnsavedChanges && (
@@ -192,7 +197,7 @@ function FieldsSelector({
() =>
fields.map((f) => ({
...f,
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
key: buildCompositeKey(f.name, f.fieldContext),
})),
[fields],
);

View File

@@ -21,6 +21,7 @@ interface OtherFieldsProps {
addedFields: TelemetryFieldKey[];
onAdd: (field: TelemetryFieldKey) => void;
isAtLimit: boolean;
allowCustomFields?: boolean;
}
function OtherFields({
@@ -29,6 +30,7 @@ function OtherFields({
addedFields,
onAdd,
isAtLimit,
allowCustomFields,
}: OtherFieldsProps): JSX.Element {
const { data, isFetching } = useGetQueryKeySuggestions(
{
@@ -45,25 +47,45 @@ function OtherFields({
},
);
const otherFields: TelemetryFieldKey[] = useMemo(() => {
const suggestions = Object.values(data?.data.data.keys || {}).flat();
const otherFields = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data.data.keys || {}).flat();
// Normalize: synthesize `key` once so downstream reads can trust it.
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
(attr) => ({
...attr,
key: buildCompositeKey(attr.name, attr.fieldContext as string),
signal: attr.signal as SignalType,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType,
}),
);
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
...attr,
key: buildCompositeKey(attr.name, attr.fieldContext as string),
signal: attr.signal as SignalType,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType,
}));
const addedIds = new Set(
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
addedFields.map((f) => buildCompositeKey(f.name, f.fieldContext)),
);
return normalizedSuggestions.filter(
const available = suggestions.filter(
(attr) => !addedIds.has(attr.key as string),
);
}, [data, addedFields]);
// Prepend the custom field when its name is not in suggestions and
// not already added.
const typed = debouncedInputValue.trim();
const nameMatches = (list: TelemetryFieldKey[]): boolean =>
list.some((f) => f.name.toLowerCase() === typed.toLowerCase());
const showCustom =
!!allowCustomFields &&
typed.length > 0 &&
!nameMatches(suggestions) &&
!nameMatches(addedFields);
if (!showCustom) {
return available;
}
const customField: TelemetryFieldKey = {
name: typed,
fieldContext: '',
fieldDataType: '',
key: buildCompositeKey(typed, ''),
};
return [customField, ...available];
}, [data, addedFields, allowCustomFields, debouncedInputValue]);
if (isFetching) {
return (

View File

@@ -11,7 +11,7 @@ const makeField = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({
signal: 'logs',
fieldContext: fieldContext as TelemetryFieldKey['fieldContext'],
fieldDataType: 'string',
key: `${fieldContext}.${name}`,
key: `${fieldContext}:${name}`,
});
describe('AddedFields — requiredFields', () => {
@@ -33,7 +33,7 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.a', 'log.c']}
requiredFields={['log:a', 'log:c']}
/>,
);
@@ -50,7 +50,7 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.a']}
requiredFields={['log:a']}
/>,
);
@@ -68,7 +68,7 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.body']}
requiredFields={['log:body']}
/>,
);
@@ -101,11 +101,11 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.body']}
requiredFields={['log:body']}
/>,
);
// 'log.body' locked, 'log.body_extra' removable.
// 'log:body' locked, 'log:body_extra' removable.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
});
});

View File

@@ -0,0 +1,188 @@
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';
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
// FloatingPanel is a react-rnd/portal shell — presentation only. Render its
// children directly so the test exercises the column-editing behavior.
jest.mock('periscope/components/FloatingPanel', () => ({
FloatingPanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
const mockSuggestions = (names: string[]): void => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
},
},
},
},
isFetching: false,
});
};
const field = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({
name,
signal: 'logs',
fieldContext: fieldContext as TelemetryFieldKey['fieldContext'],
fieldDataType: 'string',
});
const renderPanel = (
props: Partial<React.ComponentProps<typeof FieldsSelector>> = {},
): { onFieldsChange: jest.Mock } => {
const onFieldsChange = jest.fn();
render(
<FieldsSelector
isOpen
title="Edit columns"
fields={props.fields ?? []}
onFieldsChange={onFieldsChange}
onClose={jest.fn()}
signal={DataSource.LOGS}
allowCustomFields
{...props}
/>,
);
return { onFieldsChange };
};
// Type into the search box and flush the 400ms debounce so OtherFields (driven
// by the debounced value) recomputes.
const typeSearch = (value: string): void => {
const input = screen.getByPlaceholderText('Search for a field...');
act(() => {
fireEvent.change(input, { target: { value } });
});
act(() => {
jest.advanceTimersByTime(400);
});
};
describe('FieldsSelector — edit columns (integration)', () => {
beforeEach(() => {
jest.useFakeTimers();
mockSuggestions([]);
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('adds a free-typed field end to end and saves the synthesized key', () => {
const { onFieldsChange } = renderPanel({ fields: [field('body')] });
typeSearch('orderId');
// custom option surfaces in OTHER FIELDS (only Add button, no suggestions)
expect(screen.getByText('orderId')).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
});
// moved into ADDED FIELDS → OTHER FIELDS has nothing left to offer
expect(screen.getByText('No values found')).toBeInTheDocument();
// Save commits the draft
act(() => {
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
});
expect(onFieldsChange).toHaveBeenCalledTimes(1);
const saved = onFieldsChange.mock.calls[0][0] as TelemetryFieldKey[];
expect(saved).toStrictEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'orderId',
fieldContext: '',
fieldDataType: '',
key: 'orderId',
}),
]),
);
});
it('adds a suggested field: it moves from OTHER FIELDS into ADDED FIELDS', () => {
mockSuggestions(['service.name']);
const { onFieldsChange } = renderPanel({ fields: [] });
const addButton = screen.getByRole('button', { name: /^add$/i });
act(() => {
fireEvent.click(addButton);
});
// now removable in ADDED FIELDS, no longer offered in OTHER FIELDS
expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /^add$/i }),
).not.toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
});
const saved = onFieldsChange.mock.calls[0][0] as TelemetryFieldKey[];
expect(saved.map((f) => f.name)).toContain('service.name');
});
it('hides the custom option when the typed name is already added', () => {
renderPanel({ fields: [field('orderId')] });
typeSearch('ORDERID');
// exact name already added → nothing left to offer in OTHER FIELDS
expect(screen.queryByText('ORDERID')).not.toBeInTheDocument();
expect(screen.getByText('No values found')).toBeInTheDocument();
});
it('does not offer a custom option when allowCustomFields is off', () => {
renderPanel({ fields: [], allowCustomFields: false });
typeSearch('unknown.a.b.c');
// no custom row and nothing addable
expect(screen.queryByText('unknown.a.b.c')).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /^add$/i }),
).not.toBeInTheDocument();
});
it('discards an added field, reverting the draft', () => {
const { onFieldsChange } = renderPanel({ fields: [field('body')] });
typeSearch('orderId');
act(() => {
fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
});
// clear the search so the added list is not filtered
typeSearch('');
act(() => {
fireEvent.click(screen.getByRole('button', { name: /discard/i }));
});
expect(screen.queryByText('orderId')).not.toBeInTheDocument();
expect(onFieldsChange).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,125 @@
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 OtherFields from '../OtherFields';
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
const mockSuggestions = (names: string[]): void => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
},
},
},
},
isFetching: false,
});
};
const renderOtherFields = (
props: Partial<React.ComponentProps<typeof OtherFields>> = {},
): { onAdd: jest.Mock } => {
const onAdd = jest.fn();
render(
<OtherFields
signal={DataSource.LOGS}
debouncedInputValue=""
addedFields={[]}
onAdd={onAdd}
isAtLimit={false}
allowCustomFields
{...props}
/>,
);
return { onAdd };
};
const addedField = (name: string): TelemetryFieldKey => ({
name,
signal: 'logs',
fieldContext: '',
fieldDataType: '',
key: name,
});
describe('OtherFields — custom (free-typed) option', () => {
beforeEach(() => {
mockSuggestions([]);
});
it('shows a custom option for a typed name that is not a suggestion', () => {
renderOtherFields({ debouncedInputValue: 'unknown.a.b.c' });
expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add/i })).toBeInTheDocument();
});
it('synthesizes the field with raw name, empty context/type, on add', () => {
const { onAdd } = renderOtherFields({ debouncedInputValue: 'orderId' });
fireEvent.click(screen.getByRole('button', { name: /add/i }));
expect(onAdd).toHaveBeenCalledWith({
name: 'orderId',
fieldContext: '',
fieldDataType: '',
key: 'orderId',
});
});
it('hides the custom option when an exact suggestion exists (case-insensitive)', () => {
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();
});
it('hides the custom option when the name is already added (case-insensitive)', () => {
renderOtherFields({
debouncedInputValue: 'ORDERID',
addedFields: [addedField('orderId')],
});
expect(screen.queryByText('ORDERID')).not.toBeInTheDocument();
expect(screen.getByText('No values found')).toBeInTheDocument();
});
it('does not show the custom option when allowCustomFields is off', () => {
renderOtherFields({
debouncedInputValue: 'unknown.a.b.c',
allowCustomFields: false,
});
expect(screen.queryByText('unknown.a.b.c')).not.toBeInTheDocument();
expect(screen.getByText('No values found')).toBeInTheDocument();
});
it('does not show the custom option for an empty input', () => {
renderOtherFields({ debouncedInputValue: ' ' });
expect(screen.getByText('No values found')).toBeInTheDocument();
});
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();
});
});

View File

@@ -51,13 +51,13 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
);
// body/timestamp appear where the caller placed them, keyed by their
// composite IDs ('log.*'); contextless user fields collapse to bare name.
// composite IDs ('log:*'); contextless user fields collapse to bare name.
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'service.name',
'log.body',
'log:body',
'request.id',
'log.timestamp',
'log:timestamp',
]);
});
@@ -70,14 +70,14 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
);
const byId = new Map(result.current.map((c) => [c.id, c]));
// Attribute variant is its own column, not a duplicate 'log.body'.
// Attribute variant is its own column, not a duplicate 'log:body'.
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'log.body',
'attribute.body',
'log:body',
'attribute:body',
]);
expect(byId.get('log.body')?.enableRemove).toBe(false);
expect(byId.get('attribute.body')?.enableRemove).toBe(true);
expect(byId.get('log:body')?.enableRemove).toBe(false);
expect(byId.get('attribute:body')?.enableRemove).toBe(true);
});
it('applies the same distinct-column treatment to timestamp variants', () => {
@@ -91,11 +91,11 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
const byId = new Map(result.current.map((c) => [c.id, c]));
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'log.timestamp',
'attribute.timestamp',
'log:timestamp',
'attribute:timestamp',
]);
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
expect(byId.get('attribute.timestamp')?.enableRemove).toBe(true);
expect(byId.get('log:timestamp')?.enableRemove).toBe(false);
expect(byId.get('attribute:timestamp')?.enableRemove).toBe(true);
});
it('skips the synthetic "id" field name', () => {
@@ -127,10 +127,10 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
const byId = new Map(result.current.map((c) => [c.id, c]));
// body + timestamp are locked from the table-X removal pathway.
expect(byId.get('log.body')?.canBeHidden).toBe(false);
expect(byId.get('log.body')?.enableRemove).toBe(false);
expect(byId.get('log.timestamp')?.canBeHidden).toBe(false);
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
expect(byId.get('log:body')?.canBeHidden).toBe(false);
expect(byId.get('log:body')?.enableRemove).toBe(false);
expect(byId.get('log:timestamp')?.canBeHidden).toBe(false);
expect(byId.get('log:timestamp')?.enableRemove).toBe(false);
// User-added fields stay removable. User field has type='' so composite
// collapses to bare name.
expect(byId.get('user_field')?.enableRemove).toBe(true);

View File

@@ -44,6 +44,13 @@
--tanstack-first-column-header-bg,
var(--tanstack-table-header-cell-bg, var(--l2-background))
) !important;
padding-left: var(
--tanstack-cell-header-padding-left-first-column,
var(
--tanstack-cell-header-padding-left-override,
var(--tanstack-cell-padding-left, 0.3rem)
)
);
}
}

View File

@@ -161,7 +161,7 @@
.tableHeaderCell {
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
height: 36px;
height: var(--tanstack-table-header-height, 36px);
text-align: left;
font-size: 14px;
font-style: normal;

View File

@@ -11,6 +11,7 @@ export enum LOCALSTORAGE {
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_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

@@ -275,6 +275,7 @@ function LiveLogsContainer({
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.LOGS}
requiredFields={LOGS_REQUIRED_COLUMNS}
allowCustomFields
/>
)}
</div>

View File

@@ -113,6 +113,7 @@ function LogsActionsContainer({
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.LOGS}
requiredFields={LOGS_REQUIRED_COLUMNS}
allowCustomFields
/>
)}
</div>

View File

@@ -296,12 +296,12 @@ describe('useOptionsMenu', () => {
}),
);
// New order: [attribute.service.name, log.body, resource.service.name, log.timestamp]
// New order: [attribute:service.name, log:body, resource:service.name, log:timestamp]
result.current.config.addColumn?.onReorder([
'attribute.service.name',
'log.body',
'resource.service.name',
'log.timestamp',
'attribute:service.name',
'log:body',
'resource:service.name',
'log:timestamp',
]);
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
@@ -309,13 +309,13 @@ describe('useOptionsMenu', () => {
expect(
reordered.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}.${c.name}`,
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual([
'attribute.service.name',
'log.body',
'resource.service.name',
'log.timestamp',
'attribute:service.name',
'log:body',
'resource:service.name',
'log:timestamp',
]);
});
@@ -329,11 +329,11 @@ describe('useOptionsMenu', () => {
result.current.config.addColumn?.onReorder([
'state-indicator',
'log.timestamp',
'log:timestamp',
'unknown.composite',
'log.body',
'resource.service.name',
'attribute.service.name',
'log:body',
'resource:service.name',
'attribute:service.name',
]);
const reordered = mockUpdateColumns.mock.calls[0][0];
@@ -341,13 +341,13 @@ describe('useOptionsMenu', () => {
expect(
reordered.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}.${c.name}`,
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual([
'log.timestamp',
'log.body',
'resource.service.name',
'attribute.service.name',
'log:timestamp',
'log:body',
'resource:service.name',
'attribute:service.name',
]);
});
@@ -359,17 +359,17 @@ describe('useOptionsMenu', () => {
}),
);
// Removing 'resource.service.name' should drop ONLY the resource variant.
result.current.config.addColumn?.onRemove('resource.service.name');
// Removing 'resource:service.name' should drop ONLY the resource variant.
result.current.config.addColumn?.onRemove('resource:service.name');
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
const remaining = mockUpdateColumns.mock.calls[0][0];
expect(
remaining.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}.${c.name}`,
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual(['log.body', 'attribute.service.name', 'log.timestamp']);
).toStrictEqual(['log:body', 'attribute:service.name', 'log:timestamp']);
});
it('removing by a non-matching composite ID is a no-op (filter returns the full list)', () => {

View File

@@ -16,7 +16,7 @@ export const getOptionsFromKeys = (
};
// Composite identity for a column. Disambiguates same-name fields across
// different fieldContexts (e.g. resource.service.name vs attribute.service.name).
// different fieldContexts (e.g. resource:service.name vs attribute:service.name).
// Falls back to bare name when context is missing.
export const buildCompositeKey = (name: string, context?: string): string =>
context ? `${context}.${name}` : name;
context ? `${context}:${name}` : name;

View File

@@ -0,0 +1,8 @@
.container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: calc(100vh - 240px);
min-height: 400px;
}

View File

@@ -1,3 +1,4 @@
import type { TelemetryFieldKey } from 'api/v5/v5';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const defaultSelectedColumns: string[] = [
@@ -10,3 +11,9 @@ export const defaultSelectedColumns: string[] = [
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
// Pinned timestamp column
export const TIMESTAMP_FIELD = {
name: 'timestamp',
fieldContext: 'span',
} as TelemetryFieldKey;

View File

@@ -0,0 +1,133 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen } from 'tests/test-utils';
import ListView from './index';
// globalTime starts with loading:true, which gates the list query. Force just that
// slice's loading to false so the query fires; every other selector is untouched.
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
return {
...actual,
useSelector: (selector: (state: unknown) => unknown): unknown => {
const result = actual.useSelector(selector);
if (result && typeof result === 'object' && 'loading' in result) {
return { ...result, loading: false };
}
return result;
},
};
});
// List columns come from the options menu (server-synced preferences). Pin them
// so the query fires and the expected columns render, independent of that API.
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
__esModule: true,
default: (): unknown => ({
options: {
selectColumns: [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name', fieldContext: 'span' },
{ name: 'duration_nano', fieldContext: 'span' },
{ name: 'http_method', fieldContext: 'span' },
{ name: 'response_status_code', fieldContext: 'span' },
],
},
config: { addColumn: { onRemove: jest.fn() } },
}),
}));
const BASE_URL = ENVIRONMENT.baseURL;
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
const listRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'frontend',
name: 'HTTP GET',
duration_nano: 55306000,
http_method: 'GET',
response_status_code: '200',
span_id: '772c4d29dd9076ac',
trace_id: '0000000000000000344ded1387b08a7e',
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
'service.name': 'demo-app',
name: 'authenticate_check_db',
duration_nano: 790949390,
// empty status fields to assert the "-" cell
http_method: '',
response_status_code: '',
span_id: '5704353737b6778e',
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
},
},
];
const listResponse = (rows: unknown[]): Record<string, unknown> => ({
data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } },
});
const mockSuccess = (rows: unknown[] = listRows): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listResponse(rows))),
),
);
};
const renderListView = (): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<ListView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>
</VirtuosoMockContext.Provider>,
{},
{
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.LIST,
stagedQuery: initialQueriesMap.traces,
currentQuery: initialQueriesMap.traces,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
);
describe('Traces ListView - Data Loaded', () => {
afterEach(() => {
server.resetHandlers();
});
it('renders backend rows in FieldCell format', async () => {
mockSuccess();
renderListView();
// plain-text columns
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
expect(screen.getByText('authenticate_check_db')).toBeInTheDocument();
// duration_nano renders in milliseconds
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
// http_method / response_status_code render as badges
expect(screen.getAllByTestId('http_method')[0]).toHaveTextContent('GET');
expect(screen.getAllByTestId('response_status_code')[0]).toHaveTextContent(
'200',
);
// empty status fields render "-"
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -12,16 +12,18 @@ import {
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { ResizeTable } from 'components/ResizeTable';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
@@ -32,20 +34,22 @@ import { Pagination } from 'hooks/queryPagination';
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from './configs';
import { Container, tableStyles } from './styles';
import { getListColumns, transformDataWithDate } from './utils';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
TIMESTAMP_FIELD,
} from './configs';
import { getTraceLink, transformSpanRows } from './utils';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
interface ListViewProps {
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
@@ -93,7 +97,7 @@ function ListView({
[stagedQuery, orderBy],
);
// TEMP — remove after traces moves to TanStack table.
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
@@ -186,60 +190,42 @@ function ListView({
[queryTableDataResult],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns = useMemo(
() =>
getListColumns(
options?.selectColumns || [],
formatTimezoneAdjustedTimestamp,
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
const fields = [
TIMESTAMP_FIELD,
...(options?.selectColumns ?? []).filter(
(field) => field.name !== TIMESTAMP_FIELD.name,
),
[options?.selectColumns, formatTimezoneAdjustedTimestamp],
);
];
return fields.map((field) => getFieldColumn(field));
}, [options?.selectColumns]);
const transformedQueryTableData = useMemo(
() => transformDataWithDate(queryTableData) || [],
const rows = useMemo(
() => transformSpanRows(queryTableData),
[queryTableData],
);
const handleDragColumn = useCallback(
(fromIndex: number, toIndex: number): void => {
const reordered = [...columns];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
// `key` is the composite (fieldContext.name) — disambiguates same-name fields.
const orderedIds = reordered
.map((c) => String(c.key || ('dataIndex' in c && c.dataIndex) || ''))
.filter(Boolean);
config?.addColumn?.onReorder(orderedIds);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(cols.map((c) => c.id));
},
[columns, config],
[config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
const isDataAbsent =
!isLoading &&
!isFetching &&
!isError &&
transformedQueryTableData.length === 0;
useEffect(() => {
if (
!isLoading &&
!isFetching &&
!isError &&
transformedQueryTableData.length !== 0
) {
logEvent('Traces Explorer: Data present', {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
panelType,
});
}
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
}, [isLoading, isFetching, isError, rows, panelType]);
return (
<Container>
<div className={styles.container}>
<div className="trace-explorer-controls">
<div className="order-by-container">
<div className="order-by-label">
@@ -266,33 +252,21 @@ function ListView({
/>
</div>
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && transformedQueryTableData.length === 0)) && (
<TracesLoading />
)}
{isDataAbsent && !isFilterApplied && (
<NoLogs dataSource={DataSource.TRACES} />
)}
{isDataAbsent && isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
)}
{!isError && transformedQueryTableData.length !== 0 && (
<ResizeTable
tableLayout="fixed"
pagination={false}
scroll={{ x: 'max-content' }}
loading={isFetching}
style={tableStyles}
dataSource={transformedQueryTableData}
columns={columns}
onDragColumn={handleDragColumn}
/>
)}
</Container>
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import type { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
@@ -41,12 +42,23 @@ export const transformDataWithDate = (
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: RowData): string =>
`${ROUTES.TRACE}/${record.traceID || record.trace_id}${formUrlParams({
spanId: record.spanID || record.span_id,
export const getTraceLink = (record: Record<string, unknown>): string => {
function readId(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
return '';
}
const traceId = readId(record.traceID) || readId(record.trace_id);
const spanId = readId(record.spanID) || readId(record.span_id);
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
spanId,
levelUp: 0,
levelDown: 0,
})}`;
};
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
@@ -136,3 +148,21 @@ export const getListColumns = (
return [...initialColumns, ...columns];
};
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
// positional ids; `timestamp` is lifted from the wrapping ListItem.
export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => {
const list = data[0]?.list;
if (!list) {
return [];
}
return list.map((item) => {
const row = item.data as Record<string, unknown>;
return {
...row,
timestamp: item.timestamp,
id: row.span_id,
};
}) as TracesTableRow[];
};

View File

@@ -0,0 +1,77 @@
import { generatePath, Link } from 'react-router-dom';
import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useTimezone } from 'providers/Timezone';
import {
DURATION_FIELD_NAMES,
STATUS_FIELD_NAMES,
TIMESTAMP_FIELD_NAMES,
TRACE_ID_FIELD_NAMES,
} from './constants';
import { stringifyCellValue } from './utils';
type FieldCellProps = {
name: string;
value: unknown;
};
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
if (TIMESTAMP_FIELD_NAMES.has(name)) {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
const text = String(formatted);
return <TanStackTable.Text title={text}>{text}</TanStackTable.Text>;
}
if (value === '' || value == null) {
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
}
const text = stringifyCellValue(value);
if (TRACE_ID_FIELD_NAMES.has(name)) {
return (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
data-testid="trace-id"
onClick={(e): void => e.stopPropagation()}
>
{text}
</Link>
);
}
if (STATUS_FIELD_NAMES.has(name)) {
return (
<Badge data-testid={name} color="sakura" variant="outline">
{text}
</Badge>
);
}
if (DURATION_FIELD_NAMES.has(name)) {
return (
<TanStackTable.Text data-testid={name}>{getMs(text)}ms</TanStackTable.Text>
);
}
return (
<TanStackTable.Text data-testid={name} title={text}>
{text}
</TanStackTable.Text>
);
}
export default FieldCell;

View File

@@ -0,0 +1,26 @@
.tableWrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.tracesTable {
--tanstack-table-row-height: 54px;
--tanstack-table-header-height: 54px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-right-override: 15px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-header-padding-left-override: 5px;
--tanstack-cell-header-padding-left-first-column: 15px;
--tanstack-plain-body-line-clamp: 1;
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-header-cell-bg: var(--l1-background-hover);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
}

View File

@@ -0,0 +1,116 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import TanStackTable from 'components/TanStackTableView';
import type {
CellTypographySize,
TableColumnDef,
} from 'components/TanStackTableView/types';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import APIError from 'types/api/error';
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from 'utils/basePath';
import type { TracesTableRow } from './getFieldColumn';
import styles from './TracesTable.module.scss';
export type TracesTableProps = {
data: TracesTableRow[];
columns: TableColumnDef<TracesTableRow>[];
columnStorageKey?: string;
respectColumnOrder?: boolean;
panelType: PanelTypeKeys;
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
getRowHref: (row: TracesTableRow) => string;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: APIError | Error | null;
isFilterApplied: boolean;
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove?: (columnId: string) => void;
cellTypographySize?: CellTypographySize;
};
function TracesTable({
data,
columns,
columnStorageKey,
respectColumnOrder = false,
panelType,
getRowHref,
isLoading,
isFetching,
isError,
error,
isFilterApplied,
onColumnOrderChange,
onColumnRemove,
cellTypographySize = 'medium',
}: TracesTableProps): JSX.Element {
const history = useHistory();
const isDataAbsent =
!isLoading && !isFetching && !isError && data.length === 0;
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
history.push(getRowHref(row));
},
[history, getRowHref],
);
const handleRowClickNewTab = useCallback(
(row: TracesTableRow): void => {
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
},
[getRowHref],
);
return (
<>
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && data.length === 0)) && <TracesLoading />}
{isDataAbsent && !isFilterApplied && (
<NoLogs dataSource={DataSource.TRACES} />
)}
{isDataAbsent && isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
)}
{!isError && data.length !== 0 && (
<div className={styles.tableWrapper}>
<TanStackTable<TracesTableRow>
data={data}
columns={columns}
className={styles.tracesTable}
columnStorageKey={columnStorageKey}
respectColumnOrder={respectColumnOrder}
isLoading={isFetching}
cellTypographySize={cellTypographySize}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
getRowTestId={(row): string => `traces-table-row-${row.id}`}
/>
</div>
)}
</>
);
}
TracesTable.defaultProps = {
columnStorageKey: undefined,
respectColumnOrder: false,
onColumnOrderChange: undefined,
onColumnRemove: undefined,
cellTypographySize: 'medium',
};
export default TracesTable;

View File

@@ -0,0 +1,18 @@
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
'http_method',
'http.method',
'http.request.method',
'responseStatusCode',
'response_status_code',
'http.status_code',
'http.response.status_code',
]);
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);

View File

@@ -0,0 +1,26 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TIMESTAMP_FIELD_NAMES } from './constants';
import FieldCell from './FieldCell';
export type TracesTableRow = { id: string } & Record<string, unknown>;
export function getFieldColumn(
field: TelemetryFieldKey,
): TableColumnDef<TracesTableRow> {
const { name, fieldContext } = field;
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
return {
id: buildCompositeKey(name, fieldContext),
header: name,
accessorFn: (row): unknown => row[name],
enableMove: !isTimestamp,
enableRemove: !isTimestamp,
canBeHidden: !isTimestamp,
width: { min: 192 },
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
};
}

View File

@@ -0,0 +1,12 @@
export function stringifyCellValue(value: unknown): string {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}

View File

@@ -0,0 +1,15 @@
.container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
// Page chain isn't a flex column, so anchor the virtualized table against the viewport.
height: calc(100vh - 240px);
min-height: 400px;
}
.actionsContainer {
display: flex;
justify-content: space-between;
align-items: center;
}

View File

@@ -1,50 +1,25 @@
import { generatePath, Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ROUTES from 'constants/routes';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
import { ListItem } from 'types/api/widgets/getQuery';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
export const columns: ColumnsType<ListItem['data']> = [
{
title: 'Root Service Name',
dataIndex: 'service.name',
key: 'serviceName',
},
{
title: 'Root Operation Name',
dataIndex: 'name',
key: 'name',
},
{
title: 'Root Duration (in ms)',
dataIndex: 'duration_nano',
key: 'durationNano',
render: (duration: number): JSX.Element => (
<Typography>{getMs(String(duration))}ms</Typography>
),
},
{
title: 'No of Spans',
dataIndex: 'span_count',
key: 'span_count',
},
{
title: 'TraceID',
dataIndex: 'trace_id',
key: 'traceID',
render: (traceID: string): JSX.Element => (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, {
id: traceID,
})}
data-testid="trace-id"
>
{traceID}
</Link>
),
},
];
const TRACE_FIELDS = [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name' },
{ name: 'duration_nano' },
{ name: 'span_count' },
{ name: 'trace_id' },
] as TelemetryFieldKey[];
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
(field) => ({
...getFieldColumn(field),
enableRemove: false,
canBeHidden: false,
}),
);

View File

@@ -0,0 +1,136 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen, waitFor } from 'tests/test-utils';
import TracesView from './index';
const BASE_URL = ENVIRONMENT.baseURL;
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
const groupedRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'frontend',
name: 'HTTP GET',
duration_nano: 55306000,
span_count: 8,
trace_id: '0000000000000000344ded1387b08a7e',
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
'service.name': 'demo-app',
// intentionally empty to assert the "-" cell
name: '',
duration_nano: 790949390,
span_count: 3,
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
},
},
];
const groupedResponse = (rows: unknown[]): Record<string, unknown> => ({
data: { type: 'trace', data: { results: [{ queryName: 'A', rows }] } },
});
const mockSuccess = (rows: unknown[] = groupedRows): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(groupedResponse(rows))),
),
);
};
const mockError = (): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error', error: 'boom' })),
),
);
};
const renderTracesView = (
props: Record<string, unknown> = {},
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<TracesView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
{...props}
/>
</VirtuosoMockContext.Provider>,
{},
{
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.TRACE,
stagedQuery: initialQueriesMap.traces,
currentQuery: initialQueriesMap.traces,
} as any,
},
);
describe('TracesView (grouped root-span table)', () => {
afterEach(() => {
server.resetHandlers();
});
it('renders backend rows in FieldCell format', async () => {
mockSuccess();
renderTracesView();
// service.name + name render as plain text
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
expect(screen.getByText('HTTP GET')).toBeInTheDocument();
// duration_nano renders in milliseconds
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
// span_count renders as text
expect(screen.getByText('8')).toBeInTheDocument();
// empty field renders "-"
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
// trace_id renders as a link to the trace detail
const traceLinks = screen.getAllByTestId('trace-id');
expect(traceLinks[0]).toHaveAttribute(
'href',
expect.stringContaining('/trace/0000000000000000344ded1387b08a7e'),
);
});
it('shows the empty state and keeps the toolbar when there are no rows', async () => {
mockSuccess([]);
renderTracesView();
// toolbar (un-gated) stays visible regardless of data
expect(
screen.getByText(/This tab only shows Root Spans/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/No traces yet/i)).toBeInTheDocument();
});
});
it('keeps the toolbar visible on API error', async () => {
mockError();
renderTracesView();
expect(
screen.getByText(/This tab only shows Root Spans/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,3 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
Dispatch,
memo,
@@ -12,30 +11,29 @@ import { useSelector } from 'react-redux';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { ResizeTable } from 'components/ResizeTable';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import TraceExplorerControls from '../Controls';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import { columns, PER_PAGE_OPTIONS } from './configs';
import { ActionsContainer, Container } from './styles';
import styles from './TracesView.module.scss';
interface TracesViewProps {
isFilterApplied: boolean;
@@ -119,8 +117,13 @@ function TracesView({
}, [data?.payload, data?.warning]);
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
const tableData = useMemo(
() => responseData?.map((listItem) => listItem.data),
const rows = useMemo<TracesTableRow[]>(
() =>
(responseData ?? []).map((item) => {
const row = item.data;
return { ...row, id: row.trace_id };
}) as TracesTableRow[],
[responseData],
);
@@ -133,71 +136,52 @@ function TracesView({
}, [isLoading, isFetching, setIsLoadingQueries]);
useEffect(() => {
if (!isLoading && !isFetching && !isError && (tableData || []).length !== 0) {
logEvent('Traces Explorer: Data present', {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
panelType: 'TRACE',
});
}
}, [isLoading, isFetching, isError, panelType, tableData]);
}, [isLoading, isFetching, isError, rows.length]);
return (
<Container>
{(tableData || []).length !== 0 && (
<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={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="trace-explorer-controls">
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<TraceExplorerControls
isLoading={isLoading}
totalCount={responseData?.length || 0}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
</ActionsContainer>
)}
<TraceExplorerControls
isLoading={isLoading}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
</div>
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && (tableData || []).length === 0)) && (
<TracesLoading />
)}
{!isLoading &&
!isFetching &&
!isError &&
!isFilterApplied &&
(tableData || []).length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
{!isLoading &&
!isFetching &&
(tableData || []).length === 0 &&
!isError &&
isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
)}
{(tableData || []).length !== 0 && (
<ResizeTable
loading={isLoading}
columns={columns}
tableLayout="fixed"
dataSource={tableData}
scroll={{ x: true }}
pagination={false}
/>
)}
</Container>
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.TRACES_VIEW_COLUMNS}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
/>
</div>
);
}

View File

@@ -1,12 +0,0 @@
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
flex-direction: column;
`;
export const ActionsContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;

View File

@@ -428,24 +428,20 @@ func (b *StatementBuilder) buildTemporalAggDeltaFastPath(
sb.SelectMore(fmt.Sprintf("`%s`", GroupByColumnAlias(i, g.Name)))
}
var aggCol string
aggCol, err := metricstelemetryschema.AggregationColumnForSamplesTable(
samplesTable, query.Aggregations[0].Temporality, query.Aggregations[0].TimeAggregation,
)
if err != nil {
return "", nil, err
}
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
// TODO(srikanthccv): should it be step interval or use [start_time_unix_nano](https://github.com/open-telemetry/opentelemetry-proto/blob/d3fb76d70deb0874692bd0ebe03148580d85f3bb/opentelemetry/proto/metrics/v1/metrics.proto#L400C11-L400C31)?
aggCol = fmt.Sprintf("%s/%d", aggCol, stepSec)
}
if query.Aggregations[0].SpaceAggregation.IsPercentile() &&
query.Aggregations[0].Type == metrictypes.ExpHistogramType {
// merging sketches already spans every series in the step, so neither a
// samples-table value column nor the rate divisor applies
aggCol = fmt.Sprintf("quantilesDDMerge(0.01, %f)(sketch)[1]", query.Aggregations[0].SpaceAggregation.Percentile())
} else {
col, err := metricstelemetryschema.AggregationColumnForSamplesTable(
samplesTable, query.Aggregations[0].Temporality, query.Aggregations[0].TimeAggregation,
)
if err != nil {
return "", nil, err
}
aggCol = col
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
// TODO(srikanthccv): should it be step interval or use [start_time_unix_nano](https://github.com/open-telemetry/opentelemetry-proto/blob/d3fb76d70deb0874692bd0ebe03148580d85f3bb/opentelemetry/proto/metrics/v1/metrics.proto#L400C11-L400C31)?
aggCol = fmt.Sprintf("%s/%d", aggCol, stepSec)
}
}
sb.SelectMore(fmt.Sprintf("%s AS value", aggCol))

View File

@@ -126,64 +126,6 @@ func TestStatementBuilder(t *testing.T) {
},
expectedErr: nil,
},
{
name: "test_exp_histogram_percentile_delta",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "signoz_latency",
Type: metrictypes.ExpHistogramType,
Temporality: metrictypes.Delta,
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
},
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, quantilesDDMerge(0.01, 0.950000)(sketch)[1] AS value FROM signoz_metrics.distributed_exp_hist AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
},
expectedErr: nil,
},
{
// the sketch merge spans the whole step, so `rate` must not add a /step divisor
name: "test_exp_histogram_percentile_delta_rate_time_aggregation",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "signoz_latency",
Type: metrictypes.ExpHistogramType,
Temporality: metrictypes.Delta,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
},
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, quantilesDDMerge(0.01, 0.950000)(sketch)[1] AS value FROM signoz_metrics.distributed_exp_hist AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
},
expectedErr: nil,
},
{
name: "test_histogram_percentile1",
requestType: qbtypes.RequestTypeTimeSeries,

View File

@@ -132,12 +132,7 @@ class MetricsSample(ABC):
class MetricsExpHist(ABC):
"""Represents a row in the exp_hist table for exponential histograms.
Carries the raw observations rather than a serialized sketch: the `sketch`
column is an AggregateFunction state that only ClickHouse can build, so
`observations` is what gets folded into one on insert. Must be non-empty.
"""
"""Represents a row in the exp_hist table for exponential histograms."""
env: str
temporality: str
@@ -148,7 +143,7 @@ class MetricsExpHist(ABC):
sum: np.float64
min: np.float64
max: np.float64
observations: list[int]
sketch: bytes
flags: np.uint32
def __init__(
@@ -156,7 +151,11 @@ class MetricsExpHist(ABC):
metric_name: str,
fingerprint: np.uint64,
timestamp: datetime.datetime,
observations: list[int],
count: int,
sum_value: float,
min_value: float,
max_value: float,
sketch: bytes = b"",
temporality: str = "Unspecified",
env: str = "default",
flags: int = 0,
@@ -166,13 +165,28 @@ class MetricsExpHist(ABC):
self.metric_name = metric_name
self.fingerprint = fingerprint
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
self.observations = observations
self.count = np.uint64(len(observations))
self.sum = np.float64(sum(observations))
self.min = np.float64(min(observations))
self.max = np.float64(max(observations))
self.count = np.uint64(count)
self.sum = np.float64(sum_value)
self.min = np.float64(min_value)
self.max = np.float64(max_value)
self.sketch = sketch
self.flags = np.uint32(flags)
def to_row(self) -> list:
return [
self.env,
self.temporality,
self.metric_name,
self.fingerprint,
self.unix_milli,
self.count,
self.sum,
self.min,
self.max,
self.sketch,
self.flags,
]
class MetricsMetadata(ABC):
"""Represents a row in the metadata table for metric metadata."""
@@ -415,73 +429,6 @@ class Metrics(ABC):
return metrics
class ExpHistogramMetrics(ABC):
"""High-level exponential histogram representation. Produces both time series
and exp_hist entries."""
metric_name: str
labels: dict[str, str]
temporality: str
timestamp: datetime.datetime
observations: list[int]
@property
def time_series(self) -> MetricsTimeSeries:
return self._time_series
@property
def exp_hist(self) -> MetricsExpHist:
return self._exp_hist
def __init__(
self,
metric_name: str,
observations: list[int],
labels: dict[str, str] = {},
timestamp: datetime.datetime | None = None,
temporality: str = "Delta",
flags: int = 0,
description: str = "",
unit: str = "",
env: str = "default",
resource_attributes: dict[str, str] = {},
scope_attributes: dict[str, str] = {},
) -> None:
if timestamp is None:
timestamp = datetime.datetime.now()
self.metric_name = metric_name
self.labels = labels
self.temporality = temporality
self.timestamp = timestamp
self.observations = observations
self._time_series = MetricsTimeSeries(
metric_name=metric_name,
labels=labels,
timestamp=timestamp,
temporality=temporality,
description=description,
unit=unit,
# the querier resolves the metric type from this column, and only an
# ExponentialHistogram here routes the query to the sketch read
type_="ExponentialHistogram",
is_monotonic=False,
env=env,
resource_attrs=resource_attributes,
scope_attrs=scope_attributes,
)
self._exp_hist = MetricsExpHist(
metric_name=metric_name,
fingerprint=self._time_series.fingerprint,
timestamp=timestamp,
observations=observations,
temporality=temporality,
env=env,
flags=flags,
)
class MetricsReducedTimeSeries(ABC):
"""Represents a row in the time_series_v4_reduced table i.e what
the time_series_v4_reduced_mv materializes for a metric under a
@@ -906,86 +853,6 @@ def insert_metrics(
)
def insert_exp_histogram_metrics_to_clickhouse(conn, metrics: list[ExpHistogramMetrics]) -> None:
"""
Insert exponential histograms into ClickHouse tables.
Handles insertion into:
- distributed_time_series_v4 (time series metadata)
- distributed_exp_hist (per-point sketches)
"""
time_series_map: dict[tuple[int, int], MetricsTimeSeries] = {}
for metric in metrics:
fp = int(metric.time_series.fingerprint)
hour_bucket = int(metric.time_series.unix_milli) // 3_600_000
if (fp, hour_bucket) not in time_series_map:
metric.time_series.unix_milli = np.int64(hour_bucket * 3_600_000)
time_series_map[(fp, hour_bucket)] = metric.time_series
if len(time_series_map) > 0:
conn.insert(
database="signoz_metrics",
table="distributed_time_series_v4",
column_names=[
"env",
"temporality",
"metric_name",
"description",
"unit",
"type",
"is_monotonic",
"fingerprint",
"unix_milli",
"labels",
"attrs",
"scope_attrs",
"resource_attrs",
],
data=[ts.to_row() for ts in time_series_map.values()],
)
# `sketch` is AggregateFunction(quantilesDD(...), UInt64) — the state has to be
# folded server-side, it cannot be sent as a literal. The quantilesDDState
# parameters must match the column's exactly or the INSERT is rejected.
for metric in metrics:
hist = metric.exp_hist
conn.command(
"INSERT INTO signoz_metrics.distributed_exp_hist "
"(env, temporality, metric_name, fingerprint, unix_milli, count, sum, min, max, sketch, flags) "
"SELECT %(env)s, %(temporality)s, %(metric_name)s, %(fingerprint)s, %(unix_milli)s, "
"%(count)s, %(sum)s, %(min)s, %(max)s, "
"quantilesDDState(0.01, 0.5, 0.75, 0.9, 0.95, 0.99)(toUInt64(observation)), %(flags)s "
"FROM (SELECT arrayJoin(%(observations)s) AS observation)",
parameters={
"env": hist.env,
"temporality": hist.temporality,
"metric_name": hist.metric_name,
"fingerprint": int(hist.fingerprint),
"unix_milli": int(hist.unix_milli),
"count": int(hist.count),
"sum": float(hist.sum),
"min": float(hist.min),
"max": float(hist.max),
"observations": hist.observations,
"flags": int(hist.flags),
},
)
@pytest.fixture(name="insert_exp_histogram_metrics", scope="function")
def insert_exp_histogram_metrics(
clickhouse: types.TestContainerClickhouse,
) -> Generator[Callable[[list[ExpHistogramMetrics]], None], Any]:
def _insert_exp_histogram_metrics(metrics: list[ExpHistogramMetrics]) -> None:
insert_exp_histogram_metrics_to_clickhouse(clickhouse.conn, metrics)
yield _insert_exp_histogram_metrics
truncate_metrics_tables(
clickhouse.conn,
clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"],
)
def insert_reduced_metrics_to_clickhouse(
conn,
time_series: list[MetricsReducedTimeSeries],

View File

@@ -1,129 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import ExpHistogramMetrics
from fixtures.querier import (
build_builder_query,
get_all_series,
get_series_values,
make_query_request,
)
# quantilesDD carries 0.01 relative accuracy and the log-spaced observations put
# neighbouring ranks ~1.25% apart, so a percentile can land a few percent off
PERCENTILE_TOLERANCE = 0.05
@pytest.mark.parametrize(
"space_aggregation, frontend_first, frontend_last, backend_first, backend_last",
[
("p50", 118, 153, 711, 921),
("p95", 1108, 1435, 6651, 8613),
("p99", 1352, 1751, 8113, 10507),
],
)
@pytest.mark.parametrize("time_aggregation", ["", "rate"])
def test_exp_histogram_percentile_delta_grouped(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_exp_histogram_metrics: Callable[[list[ExpHistogramMetrics]], None],
time_aggregation: str,
space_aggregation: str,
frontend_first: float,
frontend_last: float,
backend_first: float,
backend_last: float,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
metric_name = "test_exp_histogram_latency"
insert_exp_histogram_metrics(
[
ExpHistogramMetrics(
metric_name=metric_name,
# log-spaced latencies with a long tail, drifting ~30% higher across
# the hour so each point carries a distinct distribution
observations=[round(base * 1.0125**rank * (1 + minute / 200)) for rank in range(400)],
labels={"service.name": service},
timestamp=now - timedelta(minutes=60 - minute),
temporality="Delta",
)
for service, base in (("frontend", 10), ("backend", 60))
for minute in range(60)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
time_aggregation,
space_aggregation,
temporality="delta",
group_by=["service.name"],
)
response = make_query_request(signoz, token, start_ms, end_ms, [query])
assert response.status_code == HTTPStatus.OK, response.text
all_series = get_all_series(response.json(), "A")
values_by_service = {series["labels"][0]["value"]: [point["value"] for point in sorted(series["values"], key=lambda point: point["timestamp"])] for series in all_series}
assert set(values_by_service.keys()) == {"frontend", "backend"}, f"got series {set(values_by_service.keys())}"
for service, first, last in (
("frontend", frontend_first, frontend_last),
("backend", backend_first, backend_last),
):
values = values_by_service[service]
assert len(values) >= 55, f"{service}: expected a point per minute, got {len(values)}"
assert values[0] == pytest.approx(first, rel=PERCENTILE_TOLERANCE), f"{service} {space_aggregation} at the oldest point: got {values[0]}, want ~{first}"
assert values[-1] == pytest.approx(last, rel=PERCENTILE_TOLERANCE), f"{service} {space_aggregation} at the newest point: got {values[-1]}, want ~{last}"
# every observation drifts up minute over minute, so the sketch must too
assert values == sorted(values), f"{service} {space_aggregation} is not non-decreasing: {values}"
def test_exp_histogram_percentile_delta_merges_across_series(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_exp_histogram_metrics: Callable[[list[ExpHistogramMetrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
metric_name = "test_exp_histogram_latency_merged"
insert_exp_histogram_metrics(
[
ExpHistogramMetrics(
metric_name=metric_name,
observations=[round(base * 1.0125**rank * (1 + minute / 200)) for rank in range(400)],
labels={"service.name": service},
timestamp=now - timedelta(minutes=60 - minute),
temporality="Delta",
)
for service, base in (("frontend", 10), ("backend", 60))
for minute in range(60)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query("A", metric_name, "", "p95", temporality="delta")
response = make_query_request(signoz, token, start_ms, end_ms, [query])
assert response.status_code == HTTPStatus.OK, response.text
# both services' sketches merge into one, so p95 sits well above the frontend's
# own p95 (~1108) and below the backend's (~6651)
values = [point["value"] for point in sorted(get_series_values(response.json(), "A"), key=lambda point: point["timestamp"])]
assert len(values) >= 55, f"expected a point per minute, got {len(values)}"
assert values[0] == pytest.approx(5188, rel=PERCENTILE_TOLERANCE), f"oldest point: got {values[0]}, want ~5188"
assert values[-1] == pytest.approx(6718, rel=PERCENTILE_TOLERANCE), f"newest point: got {values[-1]}, want ~6718"