Compare commits

..

15 Commits

Author SHA1 Message Date
Nikhil Mantri
80e0c2fe30 Merge branch 'main' into feat/google_chat_alert_integration_tests 2026-08-21 13:50:34 +05:30
nikhilmantri0902
5367f39b61 test(alertmanager): drop google chat threading assertions after threading removal 2026-08-19 01:08:08 +05:30
Nikhil Mantri
a86b20925b Merge branch 'main' into feat/google_chat_alert_integration_tests 2026-08-19 00:34:02 +05:30
nikhilmantri0902
8f89a1abf5 test: lower googlechat notification poll ceiling to 60s 2026-08-18 15:36:48 +05:30
nikhilmantri0902
7ecf6f4b60 test(googlechat): poll for org registration instead of a fixed sleep
wait_for_org_registration probes channels/test with a throwaway webhook
sentinel aimed at an unstubbed wiremock path: it 404s before reaching
any notifier until the org's alertmanager registers, and probe traffic
stays off the paths tests assert counts on. Replaces both sleep(12)s
in the firing-path suite; the poll returns instantly once registered.
2026-08-18 11:40:38 +05:30
nikhilmantri0902
709b1a6745 test(googlechat): split the retry scenario into its own test
The retry case needed name-keyed branching in the parametrized test
(stub lookup + a trailing post-check); as its own test both branches
go away and each test reads standalone.
2026-08-18 11:35:00 +05:30
Naman Verma
b11a633331 Merge branch 'main' into feat/google_chat_alert_integration_tests 2026-08-18 09:47:50 +05:30
nikhilmantri0902
e12d6f7a42 test(googlechat): address review feedback on assertions and flakiness
- switch testChannel calls to the non-deprecated /api/v1/channels/test
- pin failure cases to 500 + 'unexpected status code <N>' body instead
  of a weak != 204, distinguishing downstream 400 from 403
- replace the fixed sleep(10) with a poll: channels/test 404s until the
  org's alertmanager registers, without reaching the notifier, so the
  first non-404 response is the single authoritative delivery attempt
- parametrize testChannel cases via a NamedTuple with short ids
- firing path: exactly-once (count 1) guard on non-retry cases,
  threading params asserted on the retry delivery, and both retry
  attempts must share the same threadKey
2026-08-18 01:20:48 +05:30
nikhilmantri0902
aefe6e76ab test(googlechat): serve the wiremock mock over CA-issued TLS on 443
Adopt the integration-CA pattern from #12486: the notification_channel
wiremock now serves https on 443 as chat.googleapis.com with a cert
issued by the integration CA, replacing the self-signed 8443 +
insecure_skip_verify hack. Webhook urls mirror prod (implicit 443).

- recreate the container on CA rotation via the CA id label
- mount the CA into the alertmanager suite's signoz (it was missing,
  so firing-path delivery failed x509 verification)
- move gchat stub/card builders into fixtures/notification_channel.py
2026-08-17 23:53:14 +05:30
Nikhil Mantri
d27f94abce Merge branch 'main' into feat/google_chat_alert_integration_tests 2026-08-17 22:00:15 +05:30
nikhilmantri0902
7774544809 test(alert-channel-integrations): recreate stale notification_channel cache lacking https:8443 2026-08-06 15:55:56 +05:30
nikhilmantri0902
2f48fc8ef8 test(alert-channel-integrations): drop unused 8443 host mapping for google chat wiremock 2026-08-06 15:37:36 +05:30
nikhilmantri0902
946058210b test(alert-channel-integrations): ruff format google chat test 2026-08-06 15:04:46 +05:30
Nikhil Mantri
113943771a Merge branch 'main' into feat/google_chat_alert_integration_tests 2026-08-06 15:02:23 +05:30
nikhilmantri0902
3c517e5bde chore: added alert integration tests for google chat 2026-08-06 14:46:09 +05:30
48 changed files with 1117 additions and 1679 deletions

View File

@@ -62,40 +62,6 @@ if (typeof window.ResizeObserver === 'undefined') {
(window as any).ResizeObserver = ResizeObserverMock;
}
if (typeof globalThis.DOMRect === 'undefined') {
(globalThis as any).DOMRect = class DOMRect {
x = 0;
y = 0;
width = 0;
height = 0;
top = 0;
right = 0;
bottom = 0;
left = 0;
constructor(x = 0, y = 0, width = 0, height = 0) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.top = y;
this.right = x + width;
this.bottom = y + height;
this.left = x;
}
toJSON(): any {
return { x: this.x, y: this.y, width: this.width, height: this.height };
}
static fromRect(rect?: {
x?: number;
y?: number;
width?: number;
height?: number;
}): DOMRect {
return new DOMRect(rect?.x, rect?.y, rect?.width, rect?.height);
}
};
}
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),

View File

@@ -48,9 +48,9 @@
"@monaco-editor/react": "^4.7.0",
"@sentry/react": "10.57.0",
"@sentry/vite-plugin": "5.3.0",
"@signozhq/design-tokens": "2.1.6",
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.4.0",
"@signozhq/ui": "0.1.0",
"@signozhq/ui": "0.0.23",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -238,4 +238,4 @@
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
}
}
}

823
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -28,9 +28,6 @@ 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 };
@@ -49,7 +46,6 @@ function FieldsSelectorContent({
signal,
maxFields,
requiredFields,
allowCustomFields,
width = DEFAULT_PANEL_WIDTH,
height,
defaultPosition,
@@ -71,7 +67,7 @@ function FieldsSelectorContent({
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim();
const value = e.target.value.trim().toLowerCase();
setInputValue(value);
debouncedUpdate(value);
},
@@ -157,7 +153,6 @@ function FieldsSelectorContent({
addedFields={draftFields}
onAdd={handleAdd}
isAtLimit={isAtLimit}
allowCustomFields={allowCustomFields}
/>
{hasUnsavedChanges && (
@@ -197,7 +192,7 @@ function FieldsSelector({
() =>
fields.map((f) => ({
...f,
key: buildCompositeKey(f.name, f.fieldContext),
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
})),
[fields],
);

View File

@@ -21,7 +21,6 @@ interface OtherFieldsProps {
addedFields: TelemetryFieldKey[];
onAdd: (field: TelemetryFieldKey) => void;
isAtLimit: boolean;
allowCustomFields?: boolean;
}
function OtherFields({
@@ -30,7 +29,6 @@ function OtherFields({
addedFields,
onAdd,
isAtLimit,
allowCustomFields,
}: OtherFieldsProps): JSX.Element {
const { data, isFetching } = useGetQueryKeySuggestions(
{
@@ -47,45 +45,25 @@ function OtherFields({
},
);
const otherFields = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data.data.keys || {}).flat();
const otherFields: TelemetryFieldKey[] = useMemo(() => {
const suggestions = Object.values(data?.data.data.keys || {}).flat();
// Normalize: synthesize `key` once so downstream reads can trust it.
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) => buildCompositeKey(f.name, f.fieldContext)),
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 available = suggestions.filter(
const addedIds = new Set(
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
);
return normalizedSuggestions.filter(
(attr) => !addedIds.has(attr.key as string),
);
// 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]);
}, [data, addedFields]);
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

@@ -1,188 +0,0 @@
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

@@ -1,125 +0,0 @@
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,13 +44,6 @@
--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: var(--tanstack-table-header-height, 36px);
height: 36px;
text-align: left;
font-size: 14px;
font-style: normal;

View File

@@ -664,7 +664,6 @@ function TanStackTableInner<TData, TItemKey = string>(
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => {
value ??= '10';
setLimit(+value);
pagination.onLimitChange?.(+value);
if (page !== 1) {

View File

@@ -11,7 +11,6 @@ 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

@@ -4,9 +4,9 @@
gap: var(--spacing-4);
padding: var(--spacing-2) var(--spacing-2);
--tabs-content-padding: 0;
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
--tab-content-padding: 0;
--tab-text-color: var(--l1-foreground);
--tab-active-text-color: var(--l1-foreground);
}
.pageError {

View File

@@ -4,8 +4,8 @@
height: 100%;
margin-top: var(--spacing-2);
margin-left: var(--spacing-2);
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
--tab-text-color: var(--l1-foreground);
--tab-active-text-color: var(--l1-foreground);
[role='tabpanel'] {
margin: 0;
padding: var(--spacing-0) var(--spacing-4);

View File

@@ -2,10 +2,10 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
--tabs-content-padding: 0;
--tab-content-padding: 0;
margin-top: var(--spacing-3);
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
--tab-text-color: var(--l1-foreground);
--tab-active-text-color: var(--l1-foreground);
}
.tabLabel {

View File

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

View File

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

View File

@@ -6,8 +6,8 @@
}
// Remove default tab content padding/margin — the card provides spacing.
--tabs-content-padding: 0;
--tabs-content-margin: var(--spacing-4) 0 0;
--tab-content-padding: 0;
--tab-content-margin: var(--spacing-4) 0 0;
}
.mcp-client-tabs {

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

@@ -35,7 +35,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
class="c0"
>
<p
class="_typography_j4pmm_1"
class="_typography_ulrzs_1"
data-slot="typography"
data-variant="text"
/>
@@ -50,7 +50,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
class="value-text-container"
>
<p
class="_typography_j4pmm_1 value-graph-text"
class="_typography_ulrzs_1 value-graph-text"
data-slot="typography"
data-testid="value-graph-text"
data-variant="text"
@@ -59,7 +59,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
295.43
</p>
<p
class="_typography_j4pmm_1 value-graph-unit"
class="_typography_ulrzs_1 value-graph-unit"
data-slot="typography"
data-testid="value-graph-suffix-unit"
data-variant="text"

View File

@@ -22,11 +22,11 @@ exports[`PipelinePage container test should render DragAction section 1`] = `
class="c0"
>
<div
class="_switch-wrapper_1a8sn_6"
class="_switch-wrapper_jbsv7_1"
>
<button
aria-checked="true"
class="_switch_1a8sn_6"
class="_switch_jbsv7_1"
data-color="robin"
data-state="checked"
id=":r0:"
@@ -35,7 +35,7 @@ exports[`PipelinePage container test should render DragAction section 1`] = `
value="on"
>
<span
class="_switch__thumb_1a8sn_71"
class="_switch__thumb_jbsv7_59"
data-state="checked"
/>
</button>

View File

@@ -74,7 +74,7 @@ exports[`PipelinePage container test should render PipelinePageLayout section 1`
/>
<div>
<p
class="_typography_j4pmm_1"
class="_typography_ulrzs_1"
data-slot="typography"
data-variant="text"
>

View File

@@ -105,7 +105,7 @@
flex-direction: column;
flex: 1;
min-height: 0;
--tabs-content-padding: 0px;
--tab-content-padding: 0px;
[role='tabpanel'] {
display: flex;

View File

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

View File

@@ -1,4 +1,3 @@
import type { TelemetryFieldKey } from 'api/v5/v5';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const defaultSelectedColumns: string[] = [
@@ -11,9 +10,3 @@ 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

@@ -1,133 +0,0 @@
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,18 +12,16 @@ 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 type { TableColumnDef } from 'components/TanStackTableView/types';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
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 { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
@@ -34,22 +32,20 @@ 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 {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
TIMESTAMP_FIELD,
} from './configs';
import { getTraceLink, transformSpanRows } from './utils';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from './configs';
import { Container, tableStyles } from './styles';
import { getListColumns, transformDataWithDate } from './utils';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
interface ListViewProps {
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
@@ -97,7 +93,7 @@ function ListView({
[stagedQuery, orderBy],
);
// Stable sorted-name signature for the queryKey.
// TEMP — remove after traces moves to TanStack table.
// - 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.
@@ -190,42 +186,60 @@ function ListView({
[queryTableDataResult],
);
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
const fields = [
TIMESTAMP_FIELD,
...(options?.selectColumns ?? []).filter(
(field) => field.name !== TIMESTAMP_FIELD.name,
),
];
return fields.map((field) => getFieldColumn(field));
}, [options?.selectColumns]);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const rows = useMemo(
() => transformSpanRows(queryTableData),
const columns = useMemo(
() =>
getListColumns(
options?.selectColumns || [],
formatTimezoneAdjustedTimestamp,
),
[options?.selectColumns, formatTimezoneAdjustedTimestamp],
);
const transformedQueryTableData = useMemo(
() => transformDataWithDate(queryTableData) || [],
[queryTableData],
);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(cols.map((c) => c.id));
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);
},
[config],
[columns, config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
const isDataAbsent =
!isLoading &&
!isFetching &&
!isError &&
transformedQueryTableData.length === 0;
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
if (
!isLoading &&
!isFetching &&
!isError &&
transformedQueryTableData.length !== 0
) {
logEvent('Traces Explorer: Data present', {
panelType,
});
}
}, [isLoading, isFetching, isError, rows, panelType]);
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
return (
<div className={styles.container}>
<Container>
<div className="trace-explorer-controls">
<div className="order-by-container">
<div className="order-by-label">
@@ -252,21 +266,33 @@ function ListView({
/>
</div>
<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>
{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>
);
}

View File

@@ -3,7 +3,6 @@ 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';
@@ -42,23 +41,12 @@ export const transformDataWithDate = (
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
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,
export const getTraceLink = (record: RowData): string =>
`${ROUTES.TRACE}/${record.traceID || record.trace_id}${formUrlParams({
spanId: record.spanID || record.span_id,
levelUp: 0,
levelDown: 0,
})}`;
};
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
@@ -148,21 +136,3 @@ 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

@@ -1,77 +0,0 @@
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

@@ -1,26 +0,0 @@
.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

@@ -1,116 +0,0 @@
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

@@ -1,18 +0,0 @@
// 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

@@ -1,26 +0,0 @@
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

@@ -1,12 +0,0 @@
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

@@ -1,15 +0,0 @@
.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,25 +1,50 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
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 { 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];
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,
}),
);
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>
),
},
];

View File

@@ -1,136 +0,0 @@
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,3 +1,4 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
Dispatch,
memo,
@@ -11,29 +12,30 @@ 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 styles from './TracesView.module.scss';
import { ActionsContainer, Container } from './styles';
interface TracesViewProps {
isFilterApplied: boolean;
@@ -117,13 +119,8 @@ function TracesView({
}, [data?.payload, data?.warning]);
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
const rows = useMemo<TracesTableRow[]>(
() =>
(responseData ?? []).map((item) => {
const row = item.data;
return { ...row, id: row.trace_id };
}) as TracesTableRow[],
const tableData = useMemo(
() => responseData?.map((listItem) => listItem.data),
[responseData],
);
@@ -136,52 +133,71 @@ function TracesView({
}, [isLoading, isFetching, setIsLoadingQueries]);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
if (!isLoading && !isFetching && !isError && (tableData || []).length !== 0) {
logEvent('Traces Explorer: Data present', {
panelType: 'TRACE',
});
}
}, [isLoading, isFetching, isError, rows.length]);
}, [isLoading, isFetching, isError, panelType, tableData]);
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>
<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="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={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
</div>
<TraceExplorerControls
isLoading={isLoading}
totalCount={responseData?.length || 0}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
</ActionsContainer>
)}
<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>
{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>
);
}

View File

@@ -0,0 +1,12 @@
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

@@ -35,7 +35,7 @@
}
.filterSelect {
min-width: 400px;
min-width: 300px;
flex: 1;
}
@@ -57,6 +57,8 @@
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 5px;
--tanstack-cell-padding-right-override: 5px;
--tanstack-cell-padding-left-override: 16px;
--tanstack-cell-padding-right-override: 16px;

View File

@@ -116,7 +116,7 @@
is hidden — the row stays a single crisp line and scrolls only when narrow. */
.typeTabsScroll {
justify-self: flex-end;
--tabs-list-wrapper-secondary-padding-left: 0;
--tab-list-wrapper-secondary-padding-left: 0;
}
/* Connected segmented control, mirroring Overview's SegmentedControl: no outer

View File

@@ -339,20 +339,37 @@ def verify_webhook_notification_expectation(
notification_channel: types.TestContainerDocker,
validation_data: dict,
) -> bool:
"""Check if wiremock received a request at the given path
whose JSON body is a superset of the expected json_body."""
"""Check that wiremock received the expected request(s) at the given path.
validation_data supports (all optional except path):
- path: request url path (matched as urlPath, so query strings are ignored)
- json_body: expected JSON subset of the request body
- count: exact number of requests required at the path
- min_count: minimum number of requests required (e.g. retries)
The body constraint must be satisfied by a single request; count constraints
apply to the total at the path."""
path = validation_data["path"]
json_body = validation_data["json_body"]
json_body = validation_data.get("json_body")
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
try:
res = requests.post(url, json={"method": "POST", "url": path}, timeout=10)
# urlPath ignores query strings; real webhook urls may carry their own (e.g. key/token).
res = requests.post(url, json={"method": "POST", "urlPath": path}, timeout=10)
except requests.exceptions.RequestException:
return False
if res.status_code != HTTPStatus.OK:
return False
for req in res.json()["requests"]:
reqs = res.json()["requests"]
if "count" in validation_data and len(reqs) != validation_data["count"]:
return False
if "min_count" in validation_data and len(reqs) < validation_data["min_count"]:
return False
if json_body is None:
return True
for req in reqs:
body = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
if _is_json_subset(json_body, body):
return True
@@ -416,7 +433,7 @@ def _received_notifications(
continue
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
try:
res = requests.post(url, json={"method": "POST", "url": validation.validation_data["path"]}, timeout=10)
res = requests.post(url, json={"method": "POST", "urlPath": validation.validation_data["path"]}, timeout=10)
webhook_bodies.extend(json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8")) for req in res.json()["requests"])
except requests.exceptions.RequestException as exc:
webhook_bodies.append(f"<failed to fetch wiremock journal: {exc}>")
@@ -455,4 +472,9 @@ def update_raw_channel_config(
path = urlparse(original_url).path
entry[url_field] = notification_channel.container_configs["8080"].get(path)
# Google Chat validates the webhook host
for entry in config.get("googlechat_configs", []):
https = notification_channel.container_configs["443"]
entry["webhook_url"] = f"{https.scheme}://{https.address}{urlparse(entry['webhook_url']).path}"
return config

View File

@@ -1,23 +1,33 @@
# pylint: disable=line-too-long
import json
import re
import time
import uuid
from collections.abc import Callable
from http import HTTPStatus
from pathlib import Path
import docker
import docker.errors
import pytest
import requests
from testcontainers.core.container import Network
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
from wiremock.testing.testcontainer import WireMockContainer
from fixtures import reuse, types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.maildev import MAILDEV_INCOMING_PASS, SMTP_TEST_FROM
from fixtures.tls import CA_ID_LABEL, KEYSTORE_PASSWORD, ca_id, issue_server_keystore
logger = setup_logger(__name__)
# Google Chat validates the webhook host, so the WireMock container joins the
# network under this alias and serves HTTPS on 443 with a certificate issued by
# the integration CA that signoz trusts; channels point at https://<host>/...
GOOGLE_CHAT_HOST = "chat.googleapis.com"
EMAIL_TRANSPORT_KEYS = [
"from",
@@ -124,9 +134,77 @@ email_default_config = {
}
def googlechat_config(space: str) -> dict:
"""Google Chat channel config for a per-test WireMock space path. Title/text are
omitted so the backend applies its default templates. The host is injected at
runtime by update_raw_channel_config."""
return {
"googlechat_configs": [
{
"webhook_url": f"/v1/spaces/{space}/messages", # host set on runtime
}
],
}
def googlechat_ok_mappings(path: str) -> list[Mapping]:
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=200, json_body={"name": "spaces/x/messages/x"}),
)
]
def googlechat_retry_mappings(path: str) -> list[Mapping]:
"""429 on the first call then 200, via a wiremock scenario transition."""
scenario = f"gc-retry-{path}"
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=429, json_body={"error": {"code": 429, "status": "RESOURCE_EXHAUSTED"}}),
scenario_name=scenario,
required_scenario_state="Started",
new_scenario_state="ok",
),
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=200, json_body={"name": "spaces/x/messages/x"}),
scenario_name=scenario,
required_scenario_state="ok",
),
]
def googlechat_card_subset(alertname: str, buttons: list[tuple[str, str]]) -> dict:
"""A cardsV2 subset asserting title, firing banner, rendered body, and each
button's text AND deep-link url (as a regex), so a broken link is caught too.
buttons: list of (text, url_regex)."""
return {
"text": f"[FIRING:1] {alertname}",
"cardsV2": [
{
"cardId": "signoz-alert",
"card": {
"header": {"title": f"[FIRING:1] {alertname}"},
"sections": [
# firing banner
{"widgets": [{"textParagraph": {"text": re.compile("FIRING")}}]},
# rendered alert body mentions the alertname
{"widgets": [{"textParagraph": {"text": re.compile(re.escape(alertname))}}]},
]
+ [{"widgets": [{"buttonList": {"buttons": [{"text": text, "onClick": {"openLink": {"url": re.compile(url)}}}]}}]} for text, url in buttons],
},
}
],
}
@pytest.fixture(name="notification_channel", scope="package")
def notification_channel(
def notification_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
tls: types.TLS,
tmpfs: Callable[[str], Path],
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.TestContainerDocker:
@@ -135,9 +213,25 @@ def notification_channel(
"""
def create() -> types.TestContainerDocker:
# http:8080 for admin API + plain webhook delivery; https:443 aliased as
# chat.googleapis.com with a CA-issued cert so Google Chat's validated
# webhook host routes here over real TLS (signoz trusts the integration CA).
keystore_path = issue_server_keystore(tls, tmpfs("notification-channel-certs"), GOOGLE_CHAT_HOST)
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
container.with_volume_mapping(str(keystore_path.parent), "/certs", "ro")
container.with_network(network)
container.start()
container.with_network_aliases(GOOGLE_CHAT_HOST)
container.with_kwargs(labels={CA_ID_LABEL: ca_id(tls)})
try:
container.start(f"--port 8080 --https-port 443 --https-keystore /certs/keystore.p12 --keystore-type PKCS12 --keystore-password {KEYSTORE_PASSWORD}")
except Exception:
# Ryuk is disabled: a started-but-unready container would survive and
# keep squatting on the chat.googleapis.com alias, poisoning DNS for
# any replacement on the shared network.
container.stop()
raise
return types.TestContainerDocker(
id=container.get_wrapped_container().id,
@@ -148,7 +242,11 @@ def notification_channel(
container.get_exposed_port(8080),
)
},
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
container_configs={
"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080),
# Google Chat delivery: https to the validated host via the network alias.
"443": types.TestContainerUrlConfig("https", GOOGLE_CHAT_HOST, 443),
},
)
def delete(container: types.TestContainerDocker):
@@ -165,6 +263,16 @@ def notification_channel(
def restore(cache: dict) -> types.TestContainerDocker:
return types.TestContainerDocker.from_cache(cache)
def stale(container: types.TestContainerDocker) -> bool:
# A container built against a rotated/absent CA can't serve a cert signoz
# trusts; recreate it instead of failing TLS opaquely.
client = docker.from_env()
try:
labels = client.containers.get(container_id=container.id).attrs["Config"]["Labels"]
except docker.errors.NotFound:
return True
return labels.get(CA_ID_LABEL) != ca_id(tls)
return reuse.wrap(
request,
pytestconfig,
@@ -173,6 +281,7 @@ def notification_channel(
create,
delete,
restore,
stale=stale,
)
@@ -248,6 +357,31 @@ def create_webhook_notification_channel(
return _create_webhook_notification_channel
def wait_for_org_registration(signoz: types.SigNoz, token: str, notification_channel: types.TestContainerDocker, wait_seconds: int = 60) -> None:
"""Polls until the org's alertmanager server is registered (one poll tick).
channels/test 404s until then, before reaching any notifier. The sentinel
receiver posts to its own unstubbed wiremock path, so request journals
asserted by tests stay clean."""
sentinel = {
"name": str(uuid.uuid4()),
"webhook_configs": [{"url": notification_channel.container_configs["8080"].get("/org-registration-sentinel")}],
}
deadline = time.time() + wait_seconds
last = None
while time.time() < deadline:
last = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=sentinel,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
if last.status_code != HTTPStatus.NOT_FOUND:
return
time.sleep(2)
raise AssertionError(f"org alertmanager did not register within {wait_seconds}s, last response: {last.status_code} {last.text}")
def send_test_notification(signoz: types.SigNoz, token: str, receiver: dict, wait_seconds: int = 90) -> None:
deadline = time.time() + wait_seconds
last = None

View File

@@ -0,0 +1,187 @@
import json
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import pytest
from wiremock.resources.mappings import Mapping
from fixtures import types
from fixtures.alerts import (
get_testdata_file_path,
update_raw_channel_config,
update_rule_channel_name,
verify_notification_expectation,
)
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.notification_channel import (
googlechat_card_subset,
googlechat_config,
googlechat_ok_mappings,
googlechat_retry_mappings,
wait_for_org_registration,
)
logger = setup_logger(__name__)
METRICS_DATA = "alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "alerts/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "alerts/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
LOGS_RULE = "alerts/test_scenarios/threshold_below_at_least_once/rule.json"
TRACES_DATA = "alerts/test_scenarios/threshold_above_average/alert_data.jsonl"
TRACES_RULE = "alerts/test_scenarios/threshold_above_average/rule.json"
GOOGLECHAT_CASES = [
types.AlertManagerNotificationTestCase(
name="googlechat_default_metrics_firing",
rule_path=METRICS_RULE,
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
channel_config=googlechat_config("gc-metrics"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": "/v1/spaces/gc-metrics/messages",
"count": 1,
"json_body": googlechat_card_subset("threshold_above_at_least_once", [("Open in SigNoz", r"/alerts/overview\?ruleId=")]),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="googlechat_rich_card_logs",
rule_path=LOGS_RULE,
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
channel_config=googlechat_config("gc-logs"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": "/v1/spaces/gc-logs/messages",
"count": 1,
"json_body": googlechat_card_subset(
"threshold_below_at_least_once",
[("View Related Logs", r"/logs/logs-explorer\?"), ("Open in SigNoz", r"/alerts/overview\?ruleId=")],
),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="googlechat_rich_card_traces",
rule_path=TRACES_RULE,
alert_data=[types.AlertData(type="traces", data_path=TRACES_DATA)],
channel_config=googlechat_config("gc-traces"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": "/v1/spaces/gc-traces/messages",
"count": 1,
"json_body": googlechat_card_subset(
"threshold_above_average",
[("View Related Traces", r"traces-explorer\?"), ("Open in SigNoz", r"/alerts/overview\?ruleId=")],
),
},
),
],
),
),
]
@pytest.mark.parametrize(
"gc_test_case",
GOOGLECHAT_CASES,
ids=lambda c: c.name,
)
def test_googlechat_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
gc_test_case: types.AlertManagerNotificationTestCase,
) -> None:
channel_name = str(uuid.uuid4())
path = gc_test_case.notification_expectation.notification_validations[0].validation_data["path"]
channel_config = update_raw_channel_config(gc_test_case.channel_config, channel_name, notification_channel)
make_http_mocks(notification_channel, googlechat_ok_mappings(path))
create_notification_channel(channel_config)
wait_for_org_registration(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
insert_alert_data(gc_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(gc_test_case.rule_path), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(notification_channel, maildev, gc_test_case.notification_expectation)
def test_googlechat_retry_429_then_200( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
) -> None:
channel_name = str(uuid.uuid4())
path = "/v1/spaces/gc-retry/messages"
channel_config = update_raw_channel_config(googlechat_config("gc-retry"), channel_name, notification_channel)
make_http_mocks(notification_channel, googlechat_retry_mappings(path))
create_notification_channel(channel_config)
wait_for_org_registration(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
insert_alert_data([types.AlertData(type="metrics", data_path=METRICS_DATA)], base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(METRICS_RULE), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(
notification_channel,
maildev,
types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
# a retryable 429 is followed by a successful re-POST => >=2 hits
"path": path,
"min_count": 2,
"json_body": {"cardsV2": [{"cardId": "signoz-alert"}]},
},
),
],
),
)

View File

@@ -13,6 +13,7 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
tls: types.TLS,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
maildev: types.TestContainerDocker,
@@ -24,6 +25,7 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
tls=tls,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz_alertmanager",

View File

@@ -0,0 +1,114 @@
import base64
import json
import re
import time
import uuid
from collections.abc import Callable
from http import HTTPStatus
from typing import NamedTuple
import pytest
import requests
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import update_raw_channel_config
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.notification_channel import googlechat_config
logger = setup_logger(__name__)
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
# with a hardcoded test alert and no retry — the deterministic place to assert
# permanent-failure behaviour. Rich cards + retry are covered in alertmanager/04_googlechat.py.
class TestChannelCase(NamedTuple):
__test__ = False
name: str
space: str
status: int # stub status
body: dict # stub body
expect_delivered: bool # expect channels/test 204
TEST_CHANNEL_CASES = [
TestChannelCase("success", "gc-tc-ok", 200, {"name": "spaces/x/messages/x"}, True),
TestChannelCase("permanent_400", "gc-tc-400", 400, {"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "Message cannot be empty."}}, False),
TestChannelCase("permission_403", "gc-tc-403", 403, {"error": {"code": 403, "status": "PERMISSION_DENIED", "message": "Method doesn't allow unregistered callers"}}, False),
]
@pytest.mark.parametrize(
"case",
TEST_CHANNEL_CASES,
ids=lambda c: c.name,
)
def test_googlechat_test_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
case: TestChannelCase,
) -> None:
path = f"/v1/spaces/{case.space}/messages"
make_http_mocks(
notification_channel,
[
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=case.status, json_body=case.body),
)
],
)
channel_name = str(uuid.uuid4())
receiver = update_raw_channel_config(googlechat_config(case.space), channel_name, notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# channels/test 404s until the org's alertmanager registers (one poll tick),
# without reaching the notifier — so the first non-404 response is the single
# authoritative delivery attempt and the count == 1 assertion below holds
deadline = time.time() + 60
while True:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
if response.status_code != HTTPStatus.NOT_FOUND or time.time() > deadline:
break
time.sleep(2)
if case.expect_delivered:
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
else:
# a downstream 400/403 surfaces as a 500 (untyped notify error) whose body
# carries the real downstream status code; pin it to distinguish 400 vs 403
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"expected 500, got {response.status_code}: {response.text}"
assert f"unexpected status code {case.status}" in response.text, f"expected downstream {case.status} in error body: {response.text}"
# exactly one delivery attempt either way (testChannel never retries)
count = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/count"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
assert count.json()["count"] == 1, f"expected exactly 1 request (no retry), got {count.text}"
if case.expect_delivered:
find = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/find"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
req = find.json()["requests"][0]
# the configured webhook url is posted verbatim, nothing appended
assert req["url"] == path, f"expected webhook url {path} posted verbatim, got {req['url']}"
# cardsV2 shape with the hardcoded test alert
card = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
assert card["cardsV2"][0]["cardId"] == "signoz-alert"
assert re.search(r"Test Alert \(", card["cardsV2"][0]["card"]["header"]["title"])