Compare commits

...

5 Commits

Author SHA1 Message Date
Gaurav Tewari
0cc223fb5a chore(deps): bump dompurify, sharp and source-map-js (#13416)
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
#### Description

- Fixes the Vanta findings for dompurify (GHSA-cg2g-xh64-v4q7), sharp
(GHSA-wq5f-xc86-pv6w) and source-map-js (CVE-2026-93749).
- dompurify → 3.4.17 and sharp → 0.35.5. The dompurify override floor is
raised so the copies pinned by `@grafana/data` and `monaco-editor` move
too.
- source-map-js → 1.2.2 by bumping `postcss` to 8.5.32 and
`typescript-plugin-css-modules` to 5.3.0, which now require it. `sass`
and `css-tree` accept any 1.x and share that copy.
- Those bumps also floor nanoid `^3.3.20` and less `^4.9.1`, so both
overrides are removed. Resolved versions are unchanged.

#### Issues closed by this PR


https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=293486763&issue=SigNoz%7Cnerve-pod%7C589

#### Screenshots / Screen Recordings

sbshot: https://claude.ai/artifact/JZ4gauNPS8CTnMu7aXGUeT
- 129 stories (first of every title plus log-body states), dark and
light: 237 of 258 pairs identical, 21 moved from clock text, focus
rings, scroll offset and animation.

#### Additional Information

- `typescript-plugin-css-modules` 5.3.0 brings its own `sass` 1.107.1.
The build still uses the direct `sass` 1.97.3.
- uuid (CVE-2026-41907, via `@storybook/test-runner`) and certifi
(`tests/uv.lock`) are left for a follow-up.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-04 14:44:14 +00:00
Gaurav Tewari
436a13430a feat: add nerve pod as codeowner for AI assistant (#13409)
#### Description
- Adds `@SigNoz/nerve` as code owner for the AI Assistant frontend
(`container/AIAssistant`, `hooks/aiAssistant`, `pages/AIAssistantPage`).

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information
- `@SigNoz/nerve` has no write access to this repo, so GitHub ignores it
as an owner until an org admin grants it. // @therealpandey

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-04 09:02:15 +00:00
Aditya Singh
825991864d feat(traces-explorer): open list rows in a span details drawer (#13361)
#### Description

- A row click in the traces explorer list opens the span in a side
drawer. The trace page no longer opens for every span. The drawer sits
behind the `TRACES_SPAN_DRAWER_ENABLED` localStorage flag.
- The drawer reuses the trace details span panel, so the overview,
events and logs tabs match the trace page. The up and down buttons and
arrow keys step through the rows. A click outside the table closes the
drawer.
- The list rows have no trace start and end time. The drawer fetches the
trace summary in the background for them. The % exec time shows when the
summary loads. The logs wait for it and query the trace window ±5 min.
- When the summary fails, the panel falls back to the span. The logs
query the span window ±5 min, and the % exec time is hidden.
- The trace ID now has a copy button instead of a link or a
click-to-copy toast.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/195

#### Screen recording



https://github.com/user-attachments/assets/6de21fc0-da64-4068-9f33-ee137b2de1d5

sbshot:
https://claude.ai/artifact/VBp6qqxdkTxR7ZUVXopY2X?sk=jsc4RO2UvZSzUXBJr1Yk6g



#### Additional Information

- The drawer adds one trace summary call per trace. React-query caches
it, so stepping between spans of one trace does not call it again.
- The drawer closes when the open span leaves the list, for example on a
refresh with a relative time or a page change. The infinite scroll PR
reworks the list query.
- Linked spans are matched by span ID.
- `useCloseOnOutsidePress` is a new shared hook. The logs drawer and
saved views will use it next.
- The explorer story moved to `Pages/Traces/Explorer/Overview`, so its
story IDs change.
2026-11-04 06:57:56 +00:00
Gaurav Tewari
d19b9afff4 feat(trace-details): show AI dot on thread tab and log empty thread with AI summary (#13380)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
- Thread tab shows a dot when the trace summary has an `ai` block, so AI
traces stand out before the tab is opened.
- New `Trace Detail: Thread empty with AI summary` event fires when a
trace has AI usage but the Thread tab shows its empty state, to measure
how often AI traces cannot be shown as a thread.
- The event carries the trace summary (root service, entry point, spans,
duration, errors, missing spans, AI tokens, cost) to find what those
traces have in common.

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

https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=292126938&issue=SigNoz%7Cnerve-pod%7C583

https://signoz-team.slack.com/archives/C0AMEPC0DFV/p1793636954294369 

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



https://github.com/user-attachments/assets/e675aefd-9581-43c5-b4fa-afe5ea5e35b8




 
#### Additional Information
- Stacked on #13358.
- Fires once per trace, like the thread `Data loaded` event; the summary
is reused, not fetched again.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-04 06:53:22 +00:00
Srikanth Chekuri
4cb022dd06 fix(telemetrystore): return invalid input with a fix for three ClickHouse SQL mistakes (#13328)
#### Description

- ClickHouse errors 170 (`BAD_GET`), 288
(`DISTRIBUTED_IN_JOIN_SUBQUERY_DENIED`) and 407 (`DECIMAL_OVERFLOW`)
from raw SQL came back as 500s, though each is a fixable query mistake.
They now return invalid input with the fix as a suggestion.

#### Additional Information

- 170 and 407 and their fixes were verified on ClickHouse 25.12. 288
needs a multi-shard cluster, so its suggestion follows the
`distributed_product_mode` docs.
- Like the existing entries, these also apply to SQL SigNoz generates.
- Part of SigNoz/nerve-pod#465.
2026-11-04 05:12:37 +00:00
58 changed files with 2741 additions and 708 deletions

5
.github/CODEOWNERS vendored
View File

@@ -302,3 +302,8 @@ go.mod @therealpandey
/frontend/.storybook/ @H4ad
/frontend/src/storybook/ @H4ad
/.claude/skills/signoz-page-story/ @H4ad
## AI Assistant
/frontend/src/container/AIAssistant/ @SigNoz/nerve @SigNoz/frontend-maintainers
/frontend/src/hooks/aiAssistant/ @SigNoz/nerve @SigNoz/frontend-maintainers
/frontend/src/pages/AIAssistantPage/ @SigNoz/nerve @SigNoz/frontend-maintainers

View File

@@ -143,7 +143,12 @@ const preview: Preview = {
'Logs',
['Explorer', 'Live Tail', 'Pipelines', 'Settings'],
'Traces',
['Explorer', 'Trace Details', 'Funnel Details'],
[
'Explorer',
['Overview', 'Span Drawer'],
'Trace Details',
'Funnel Details',
],
'Metrics',
['Explorer'],
'Infrastructure',

View File

@@ -80,7 +80,7 @@
"crypto-js": "4.2.0",
"d3-hierarchy": "3.1.2",
"dayjs": "^1.10.7",
"dompurify": "3.4.15",
"dompurify": "3.4.17",
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "4.10.1",
@@ -200,17 +200,17 @@
"oxfmt": "0.73.0",
"oxlint": "1.88.0",
"oxlint-tsgolint": "7.0.2004",
"postcss": "8.5.26",
"postcss": "8.5.32",
"postcss-scss": "4.0.9",
"react-resizable": "3.0.4",
"redux-mock-store": "1.5.4",
"sass": "1.97.3",
"sharp": "0.35.4",
"sharp": "0.35.5",
"storybook": "10.6.3",
"stylelint": "17.15.0",
"svgo": "4.1.0",
"ts-jest": "29.4.15",
"typescript-plugin-css-modules": "5.2.0",
"typescript-plugin-css-modules": "5.3.0",
"use-sync-external-store": "1.6.0",
"vite-plugin-checker": "0.12.0",
"vite-plugin-compression": "0.5.1",

465
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,9 +37,9 @@ overrides:
cookie@<0.7.0: '>=0.7.1 <1'
# via: @grafana/data@11.6.15 (3.4.0/3.2.4 exact);
# @monaco-editor/react > monaco-editor@0.55.1 (3.2.7 exact)
# remove: blocked. The direct dep is already on 3.4.15, but @grafana/data
# remove: blocked. The direct dep is already on 3.4.17, but @grafana/data
# (latest 13.1.0) and monaco-editor (latest 0.55.1) still pin vulnerable versions
dompurify@<=3.4.12: '>=3.4.13 <4'
dompurify@<=3.4.16: '>=3.4.17 <4'
# via: rolldown-vite@7.3.1 (esbuild ^0.27.0); ts-jest@29.4.9 (~0.27.4)
# remove: blocked on rolldown-vite (7.3.1 is latest, still ^0.27.0);
# orval >=8.20.0 and ts-jest >=29.4.11 already fixed on their side
@@ -60,17 +60,10 @@ overrides:
# @commitlint/cli > @commitlint/load > cosmiconfig@9 (^4.1.0)
# remove: blocked, both consumers cap js-yaml at ^4 and only 4.3.2 carries the fix
'js-yaml@>=4.0.0 <4.3.2': '>=4.3.2 <5'
# via: typescript-plugin-css-modules@5.2.0 (less ^4.2.0) > less@4.4.0 (image-size ~0.5.0)
# remove: bump typescript-plugin-css-modules once it floors less itself; image-size has
# no patched release at all, so dropping the dep is the only fix — less@4.5.0 did
less@<4.5.0: '>=4.5.0 <5'
# via: direct dep postcss (nanoid ^3.3.17)
# remove: blocked — postcss 8.5.26 (latest) caps nanoid at ^3, fix landed in 3.3.18
nanoid@<3.3.18: '>=3.3.18 <4'
# via: react-syntax-highlighter@15.5.0 (prismjs ^1.27.0 + refractor@3 ~1.27.0 tilde-pinned)
# remove: bump react-syntax-highlighter to >=16.1.1 (prismjs ^1.30.0, refractor@5)
prismjs@<1.30.0: '>=1.30.0 <2'
# via: typescript-plugin-css-modules@5.2.0 > postcss-modules-local-by-default@4.2.0
# via: typescript-plugin-css-modules@5.3.0 > postcss-modules-local-by-default@4.2.0
# (postcss-selector-parser ^7.0.0)
# remove: blocked, 4.2.0 is latest and the range is open, so the floor is what pulls the fix
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'

View File

@@ -0,0 +1,17 @@
.drawer {
overflow: hidden;
border-color: var(--l2-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px var(--l1-shadow);
&:focus,
&:focus-visible {
outline: none;
}
}
.body {
display: flex;
flex-direction: column;
height: 100%;
}

View File

@@ -1,7 +0,0 @@
.details-panel-drawer {
&__body {
display: flex;
flex-direction: column;
height: 100%;
}
}

View File

@@ -1,12 +1,15 @@
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Drawer, DrawerContent } from '@signozhq/ui/drawer';
import cx from 'classnames';
import { AnimatePresence } from 'motion/react';
import './DetailsPanelDrawer.styles.scss';
import styles from './DetailsPanelDrawer.module.scss';
interface DetailsPanelDrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
testId?: string;
}
function DetailsPanelDrawer({
@@ -14,22 +17,41 @@ function DetailsPanelDrawer({
onClose,
children,
className,
testId,
}: DetailsPanelDrawerProps): JSX.Element {
return (
<DrawerWrapper
<Drawer
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
showOverlay={false}
className={`details-panel-drawer ${className || ''}`}
modal={false}
>
<div className="details-panel-drawer__body">{children}</div>
</DrawerWrapper>
<AnimatePresence>
{isOpen && (
<DrawerContent
forceMount
direction="right"
width="extra-wide"
showOverlay={false}
// Outside presses are left to the page, which knows what counts as inside.
onInteractOutside={(e): void => e.preventDefault()}
className={cx(styles.drawer, className)}
testId={testId}
>
<div className={styles.body}>{children}</div>
</DrawerContent>
)}
</AnimatePresence>
</Drawer>
);
}
DetailsPanelDrawer.defaultProps = {
className: undefined,
testId: undefined,
};
export default DetailsPanelDrawer;

View File

@@ -0,0 +1,38 @@
import { useCallback } from 'react';
export type UseRowNavigationParams<TData> = {
rows: TData[];
activeIndex: number;
onNavigate: (row: TData, index: number) => void;
};
export type UseRowNavigationResult = {
goToPrev: () => void;
goToNext: () => void;
isPrevDisabled: boolean;
isNextDisabled: boolean;
};
/** Steps the active row through the loaded rows; stops at either end. */
export function useRowNavigation<TData>({
rows,
activeIndex,
onNavigate,
}: UseRowNavigationParams<TData>): UseRowNavigationResult {
const isPrevDisabled = activeIndex <= 0;
const isNextDisabled = activeIndex < 0 || activeIndex >= rows.length - 1;
const goToPrev = useCallback((): void => {
if (!isPrevDisabled) {
onNavigate(rows[activeIndex - 1], activeIndex - 1);
}
}, [isPrevDisabled, onNavigate, rows, activeIndex]);
const goToNext = useCallback((): void => {
if (!isNextDisabled) {
onNavigate(rows[activeIndex + 1], activeIndex + 1);
}
}, [isNextDisabled, onNavigate, rows, activeIndex]);
return { goToPrev, goToNext, isPrevDisabled, isNextDisabled };
}

View File

@@ -46,4 +46,5 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
TRACES_SPAN_DRAWER_ENABLED = 'TRACES_SPAN_DRAWER_ENABLED',
}

View File

@@ -6,3 +6,7 @@
height: calc(100vh - 240px);
min-height: 400px;
}
.drawerScope {
display: contents;
}

View File

@@ -0,0 +1,113 @@
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import { mockSuccess, mockTraceSummary, renderListView } from './testUtils';
// 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 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' },
{ name: 'http.route', fieldContext: 'attribute' },
{ name: 'cache.hit', fieldContext: 'attribute' },
],
},
config: { addColumn: { onRemove: jest.fn() } },
}),
}));
describe('Traces ListView - span drawer navigation', () => {
beforeEach(() => {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
mockSuccess();
mockTraceSummary({});
});
afterEach(() => {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
server.resetHandlers();
});
it('opens the clicked span and steps through rows with the arrow keys', async () => {
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
const drawer = await screen.findByTestId('span-details-drawer');
expect(within(drawer).getByText('HTTP GET')).toBeInTheDocument();
expect(within(drawer).getByTestId('span-details-trace-id')).toHaveTextContent(
'0000000000000000344ded1387b08a7e',
);
await user.keyboard('{ArrowDown}');
await expect(
within(drawer).findByText('authenticate_check_db'),
).resolves.toBeInTheDocument();
await user.keyboard('{ArrowUp}');
await expect(
within(drawer).findByText('HTTP GET'),
).resolves.toBeInTheDocument();
});
it('closes the drawer', async () => {
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
await screen.findByTestId('span-details-drawer');
await user.click(screen.getByTestId('span-drawer-close'));
await waitFor(() =>
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument(),
);
});
it('stays open on a press in the drawer or the list, and closes on one outside', async () => {
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
const drawer = await screen.findByTestId('span-details-drawer');
await user.click(drawer);
// Hovering re-renders the row, so the press needs a fresh query.
await user.hover(screen.getByText('authenticate_check_db'));
await user.click(screen.getByText('authenticate_check_db'));
await expect(
within(drawer).findByText('authenticate_check_db'),
).resolves.toBeInTheDocument();
await user.click(document.body);
await waitFor(() =>
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,192 @@
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import {
FIVE_MINUTES_IN_MS,
listResponse,
listRows,
mockSuccess,
mockTraceSummary,
QUERY_RANGE_URL,
renderListView,
TraceBounds,
} from './testUtils';
// 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 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' },
{ name: 'http.route', fieldContext: 'attribute' },
{ name: 'cache.hit', fieldContext: 'attribute' },
],
},
config: { addColumn: { onRemove: jest.fn() } },
}),
}));
describe('Traces ListView - span drawer trace bounds', () => {
beforeEach(() => {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
mockSuccess();
mockTraceSummary({});
});
afterEach(() => {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
server.resetHandlers();
});
it('requests span logs over whole milliseconds', async () => {
const logsRequests: QueryRangeRequestV5[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const body = await req.json<QueryRangeRequestV5>();
const spec = body.compositeQuery.queries[0]?.spec;
if (spec && 'signal' in spec && spec.signal === 'logs') {
logsRequests.push(body);
// No logs, so no context queries follow to outlive the test.
return res(ctx.status(200), ctx.json(listResponse([])));
}
return res(ctx.status(200), ctx.json(listResponse(listRows)));
}),
);
const user = userEvent.setup();
renderListView();
// Its 790.94939 ms duration makes the span window end mid-millisecond.
await user.click(await screen.findByText('authenticate_check_db'));
await waitFor(() => expect(logsRequests.length).toBeGreaterThan(0));
logsRequests.forEach(({ start, end }) => {
expect(Number.isInteger(start)).toBe(true);
expect(Number.isInteger(end)).toBe(true);
});
});
it('reads the trace bounds from the trace summary', async () => {
const bounds: TraceBounds = {
'0000000000000000344ded1387b08a7e': {
start: Date.parse('2024-07-19T08:39:58.700Z'),
end: Date.parse('2024-07-19T08:39:58.900Z'),
},
a364a8e15af3e9a8c866e0528db8b637: {
start: Date.parse('2024-07-19T08:39:59.900Z'),
end: Date.parse('2024-07-19T08:40:01.000Z'),
},
};
mockTraceSummary(bounds);
const logsRequests: {
traceId: string;
isTraceWide: boolean;
start: number;
end: number;
}[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const body = await req.json<QueryRangeRequestV5>();
const spec = body.compositeQuery.queries[0]?.spec;
if (spec && 'signal' in spec && spec.signal === 'logs') {
const traceId =
Object.keys(bounds).find((id) => spec.filter?.expression?.includes(id)) ??
'';
// The payload carries the window in µs.
logsRequests.push({
traceId,
isTraceWide: !spec.filter?.expression?.includes('span_id'),
start: body.start / 1e3,
end: body.end / 1e3,
});
return res(ctx.status(200), ctx.json(listResponse([])));
}
return res(ctx.status(200), ctx.json(listResponse(listRows)));
}),
);
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
const drawer = await screen.findByTestId('span-details-drawer');
await expect(
within(drawer).findByText(/of total exec time/),
).resolves.toBeInTheDocument();
// The next span is in another trace, so it needs that trace's bounds.
// Both its span logs and its trace-wide check must land before the test ends.
await user.keyboard('{ArrowDown}');
await waitFor(() =>
expect(
logsRequests
.filter(({ traceId }) => traceId === 'a364a8e15af3e9a8c866e0528db8b637')
.map(({ isTraceWide }) => isTraceWide),
).toStrictEqual(expect.arrayContaining([false, true])),
);
// Logs wait for the bounds: no request ever goes out with another window.
logsRequests.forEach(({ traceId, start, end }) => {
expect(start).toBe(bounds[traceId].start - FIVE_MINUTES_IN_MS);
expect(end).toBe(bounds[traceId].end + FIVE_MINUTES_IN_MS);
});
});
it('speaks only for the span when the trace has no summary', async () => {
const logsFilters: string[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const body = await req.json<QueryRangeRequestV5>();
const spec = body.compositeQuery.queries[0]?.spec;
if (spec && 'signal' in spec && spec.signal === 'logs') {
logsFilters.push(spec.filter?.expression ?? '');
return res(ctx.status(200), ctx.json(listResponse([])));
}
return res(ctx.status(200), ctx.json(listResponse(listRows)));
}),
);
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
const drawer = await screen.findByTestId('span-details-drawer');
await user.click(within(drawer).getByRole('tab', { name: /logs/i }));
await expect(
within(drawer).findByText('No logs found for this span.'),
).resolves.toBeInTheDocument();
// Checked once the 404 has settled; before it, exec time is absent anyway.
expect(
within(drawer).queryByText(/of total exec time/),
).not.toBeInTheDocument();
// No trace-wide check: every logs request is scoped to the span.
expect(logsFilters.length).toBeGreaterThan(0);
logsFilters.forEach((expression) => {
expect(expression).toContain('span_id');
});
});
});

View File

@@ -0,0 +1,110 @@
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 } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import ListView from '../index';
const BASE_URL = ENVIRONMENT.baseURL;
export const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
export const listRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
name: 'HTTP GET',
duration_nano: 55306000,
http_method: 'GET',
response_status_code: '200',
span_id: '772c4d29dd9076ac',
trace_id: '0000000000000000344ded1387b08a7e',
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
resource: { 'service.name': 'frontend' },
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
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',
attributes: {},
resource: { 'service.name': 'demo-app' },
},
},
];
export const listResponse = (rows: unknown[]): Record<string, unknown> => ({
data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } },
});
export const TRACE_SUMMARY_URL = `${BASE_URL}/api/v1/traces/:traceId/summary`;
export const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
export type TraceBounds = Record<string, { start: number; end: number }>;
// Traces missing from `bounds` get a 404, like a trace with no summary row.
export const mockTraceSummary = (bounds: TraceBounds): void => {
server.use(
rest.get(TRACE_SUMMARY_URL, (req, res, ctx) => {
const traceBounds = bounds[req.params.traceId as string];
if (!traceBounds) {
return res(ctx.status(404), ctx.json({ status: 'error' }));
}
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
startTimestampMillis: traceBounds.start,
endTimestampMillis: traceBounds.end,
hasMissingSpans: false,
rootServiceEntryPoint: '',
rootServiceName: '',
rootSpanStatusCode: '',
totalErrorSpansCount: 0,
totalSpansCount: 1,
},
}),
);
}),
);
};
export const mockSuccess = (rows: unknown[] = listRows): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listResponse(rows))),
),
);
};
export const renderListView = (
stagedQuery: Query = initialQueriesMap.traces,
): 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,
currentQuery: stagedQuery,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
);

View File

@@ -1,17 +1,20 @@
import { ENVIRONMENT } from 'constants/env';
import {
initialQueriesMap,
initialQueryBuilderFormTraceOperatorValues,
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 { screen } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import ListView from './index';
import {
listResponse,
listRows,
mockSuccess,
QUERY_RANGE_URL,
renderListView,
} from './__tests__/testUtils';
// 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.
@@ -49,74 +52,6 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
}),
}));
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: {
name: 'HTTP GET',
duration_nano: 55306000,
http_method: 'GET',
response_status_code: '200',
span_id: '772c4d29dd9076ac',
trace_id: '0000000000000000344ded1387b08a7e',
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
resource: { 'service.name': 'frontend' },
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
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',
attributes: {},
resource: { 'service.name': 'demo-app' },
},
},
];
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 = (
stagedQuery: Query = initialQueriesMap.traces,
): 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,
currentQuery: stagedQuery,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
);
describe('Traces ListView - Data Loaded', () => {
afterEach(() => {
server.resetHandlers();

View File

@@ -29,6 +29,8 @@ import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import SpanDrawer from 'container/TracesExplorer/SpanDrawer/SpanDrawer';
import { useSpanDrawer } from 'container/TracesExplorer/SpanDrawer/useSpanDrawer';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
@@ -202,6 +204,8 @@ function ListView({
[queryTableData],
);
const { tableProps, drawerProps, onPressInside } = useSpanDrawer(rows);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(cols.map((c) => c.id));
@@ -253,20 +257,26 @@ 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}
/>
{/* A press in the table or the drawer (a portal) keeps the drawer open. */}
<div className={styles.drawerScope} onPointerDownCapture={onPressInside}>
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
panelType="LIST"
getRowHref={getTraceLink}
{...tableProps}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
{drawerProps && <SpanDrawer {...drawerProps} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { ButtonGroup } from '@signozhq/ui/button-group';
import { ChevronDown, ChevronUp, CornerUpRight } from '@signozhq/icons';
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import { HeaderAction } from 'components/DetailsPanel';
import type { UseRowNavigationResult } from 'components/TanStackTableView/useRowNavigation';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { SpanDetailVariant } from 'pages/TraceDetailsV3/SpanDetailsPanel/constants';
import SpanDetailsPanel from 'pages/TraceDetailsV3/SpanDetailsPanel/SpanDetailsPanel';
import { openInNewTab } from 'utils/navigation';
import { useArrowKeyNavigation } from './useArrowKeyNavigation';
import { toSpanV3 } from './utils';
export type SpanDrawerProps = {
row: TracesTableRow | null;
onClose: () => void;
navigation: UseRowNavigationResult;
};
function SpanDrawer({
row,
onClose,
navigation,
}: SpanDrawerProps): JSX.Element {
const { goToPrev, goToNext, isPrevDisabled, isNextDisabled } = navigation;
const isOpen = row !== null;
useArrowKeyNavigation({ enabled: isOpen, onPrev: goToPrev, onNext: goToNext });
const panelState = useMemo(
() => ({ isOpen, open: (): void => {}, close: onClose }),
[isOpen, onClose],
);
const headerActions = useMemo((): HeaderAction[] => {
if (!row) {
return [];
}
return [
{
key: 'view-full-trace',
component: (
<Button
variant="outlined"
color="secondary"
size="md"
prefix={<CornerUpRight />}
onClick={(): void => openInNewTab(getTraceLink(row))}
testId="span-drawer-view-full-trace"
>
View full trace
</Button>
),
},
{
key: 'nav',
component: (
<ButtonGroup
variant="outlined"
color="secondary"
size="md"
testId="span-drawer-nav"
items={[
{
value: 'prev',
icon: <ChevronUp />,
ariaLabel: 'Previous span',
onClick: goToPrev,
disabled: isPrevDisabled,
disabledTooltip: 'No previous span on this page',
},
{
value: 'next',
icon: <ChevronDown />,
ariaLabel: 'Next span',
onClick: goToNext,
disabled: isNextDisabled,
disabledTooltip: 'No next span on this page',
},
]}
/>
),
},
];
}, [row, goToPrev, goToNext, isPrevDisabled, isNextDisabled]);
const span = useMemo(() => (row ? toSpanV3(row) : undefined), [row]);
const traceId = span?.trace_id ?? '';
const { data: traceSummary, isLoading: isTraceBoundsLoading } =
useGetTraceSummary(
{ traceID: traceId },
{ query: { enabled: !!traceId, retry: false } },
);
return (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={span}
variant={SpanDetailVariant.DRAWER}
headerActions={headerActions}
traceStartTime={traceSummary?.data?.startTimestampMillis}
traceEndTime={traceSummary?.data?.endTimestampMillis}
isTraceBoundsLoading={isTraceBoundsLoading}
/>
);
}
export default SpanDrawer;

View File

@@ -0,0 +1,39 @@
import { renderHook } from 'tests/test-utils';
import { useArrowKeyNavigation } from '../useArrowKeyNavigation';
const pressArrowDown = ({ handled }: { handled: boolean }): void => {
const event = new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
cancelable: true,
});
if (handled) {
event.preventDefault();
}
document.body.dispatchEvent(event);
};
describe('useArrowKeyNavigation', () => {
it('steps to the next span on ArrowDown', () => {
const onNext = jest.fn() as jest.MockedFunction<() => void>;
renderHook(() =>
useArrowKeyNavigation({ enabled: true, onPrev: jest.fn(), onNext }),
);
pressArrowDown({ handled: false });
expect(onNext).toHaveBeenCalledTimes(1);
});
it('ignores a key that a focused menu already handled', () => {
const onNext = jest.fn() as jest.MockedFunction<() => void>;
renderHook(() =>
useArrowKeyNavigation({ enabled: true, onPrev: jest.fn(), onNext }),
);
pressArrowDown({ handled: true });
expect(onNext).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,35 @@
import { act, renderHook } from 'tests/test-utils';
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { useSpanDrawer } from '../useSpanDrawer';
const rows: TracesTableRow[] = [{ id: 'span-a' }, { id: 'span-b' }];
describe('useSpanDrawer', () => {
beforeEach(() => {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
});
afterEach(() => {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
});
it('does not reopen a span that a refetch dropped and a later one brings back', () => {
const { result, rerender } = renderHook(
({ current }: { current: TracesTableRow[] }) => useSpanDrawer(current),
{ initialProps: { current: rows } },
);
act(() => result.current.tableProps.onRowClick?.(rows[1]));
expect(result.current.drawerProps?.row).toBe(rows[1]);
rerender({ current: [rows[0]] });
expect(result.current.drawerProps?.row).toBeNull();
rerender({ current: rows });
expect(result.current.drawerProps?.row).toBeNull();
});
});

View File

@@ -0,0 +1,74 @@
import { toSpanV3 } from '../utils';
describe('toSpanV3', () => {
const row = {
id: '772c4d29dd9076ac',
span_id: '772c4d29dd9076ac',
timestamp: '2024-07-19T08:39:58.735Z',
resource: { 'service.name': 'frontend' },
events: [
{
name: 'exception',
timeUnixNano: 1721378398735000000,
attributes: { 'exception.type': 'Error' },
},
],
links: [{ traceId: 'a364a8e15af3e9a8', spanId: '5704353737b6778e' }],
};
it('reshapes a list row into the span the details panel reads', () => {
const span = toSpanV3(row);
expect(span.timestamp).toBe(Date.UTC(2024, 6, 19, 8, 39, 58, 735));
expect(span['service.name']).toBe('frontend');
expect(span.events).toStrictEqual([
{
name: 'exception',
timeUnixNano: 1721378398735000000,
attributeMap: { 'exception.type': 'Error' },
isError: false,
},
]);
expect((span as unknown as { references: unknown }).references).toStrictEqual(
row.links,
);
});
it('drops the parent from the linked spans', () => {
const link = { traceId: 'a364a8e15af3e9a8', spanId: '5704353737b6778e' };
const parent = { traceId: '1f2e3d4c5b6a7980', spanId: '0aa1bb2cc3dd4ee5' };
const span = toSpanV3({
...row,
trace_id: parent.traceId,
parent_span_id: parent.spanId,
links: [parent, link, { ...parent, traceId: link.traceId }],
});
expect((span as unknown as { references: unknown }).references).toStrictEqual(
[link, { ...parent, traceId: link.traceId }],
);
});
it('drops the empty parent ref of a root span', () => {
const span = toSpanV3({
...row,
trace_id: 'a364a8e15af3e9a8',
parent_span_id: '',
links: [{ traceId: 'a364a8e15af3e9a8' }],
});
expect((span as unknown as { references: unknown }).references).toStrictEqual(
[],
);
});
it('defaults missing events, links and service name', () => {
const span = toSpanV3({ id: 'x', timestamp: row.timestamp });
expect(span.events).toStrictEqual([]);
expect(span['service.name']).toBe('');
expect((span as unknown as { references: unknown }).references).toStrictEqual(
[],
);
});
});

View File

@@ -0,0 +1,46 @@
import { useEffect } from 'react';
const EDITABLE_TARGET = 'input, textarea, [contenteditable="true"], .cm-editor';
type UseArrowKeyNavigationParams = {
enabled: boolean;
onPrev: () => void;
onNext: () => void;
};
export function useArrowKeyNavigation({
enabled,
onPrev,
onNext,
}: UseArrowKeyNavigationParams): void {
useEffect(() => {
if (!enabled) {
return undefined;
}
const handleKeyDown = (e: KeyboardEvent): void => {
// A focused menu or listbox already used the key.
if (e.defaultPrevented) {
return;
}
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) {
return;
}
if (e.target instanceof Element && e.target.closest(EDITABLE_TARGET)) {
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
onPrev();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onNext();
}
};
document.addEventListener('keydown', handleKeyDown);
return (): void => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [enabled, onPrev, onNext]);
}

View File

@@ -0,0 +1,87 @@
import { PointerEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { useRowNavigation } from 'components/TanStackTableView/useRowNavigation';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import type { TracesTableProps } from 'container/TracesExplorer/TracesTable/TracesTable';
import { useCloseOnOutsidePress } from 'hooks/useCloseOnOutsidePress';
import type { SpanDrawerProps } from './SpanDrawer';
import { isSpanDrawerEnabled } from './utils';
type ActiveSpan = { id: string; index: number };
type UseSpanDrawerResult = {
tableProps: Pick<
TracesTableProps,
'onRowClick' | 'isRowActive' | 'activeRowIndex'
>;
/** Null while the drawer flag is off. */
drawerProps: SpanDrawerProps | null;
/** `onPointerDownCapture` for the area a press keeps the drawer open in. */
onPressInside: (event: PointerEvent) => void;
};
export function useSpanDrawer(rows: TracesTableRow[]): UseSpanDrawerResult {
const [isEnabled] = useState(isSpanDrawerEnabled);
// The index lets navigation step past a duplicate span; the id survives a refetch.
const [activeSpan, setActiveSpan] = useState<ActiveSpan | null>(null);
const activeRowIndex = useMemo(() => {
if (!activeSpan) {
return -1;
}
return rows[activeSpan.index]?.id === activeSpan.id
? activeSpan.index
: rows.findIndex((row) => row.id === activeSpan.id);
}, [rows, activeSpan]);
const isRowActive = useCallback(
(row: TracesTableRow): boolean => row === rows[activeRowIndex],
[rows, activeRowIndex],
);
const handleNavigate = useCallback(
(row: TracesTableRow, index: number): void => {
setActiveSpan({ id: row.id, index });
},
[],
);
const selectRow = useCallback(
(row: TracesTableRow): void => handleNavigate(row, rows.indexOf(row)),
[handleNavigate, rows],
);
const clearActiveSpan = useCallback((): void => setActiveSpan(null), []);
// A refetch can drop the open span; forget it so a later page can't reopen it.
useEffect(() => {
if (activeSpan && activeRowIndex === -1) {
setActiveSpan(null);
}
}, [activeSpan, activeRowIndex]);
const onPressInside = useCloseOnOutsidePress(
isEnabled && activeRowIndex !== -1,
clearActiveSpan,
);
const navigation = useRowNavigation({
rows,
activeIndex: activeRowIndex,
onNavigate: handleNavigate,
});
if (!isEnabled) {
return { tableProps: {}, drawerProps: null, onPressInside };
}
return {
tableProps: { onRowClick: selectRow, isRowActive, activeRowIndex },
drawerProps: {
row: rows[activeRowIndex] ?? null,
onClose: clearActiveSpan,
navigation,
},
onPressInside,
};
}

View File

@@ -0,0 +1,46 @@
import get from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { SpanV3 } from 'types/api/trace/getTraceV3';
/** Off by default; set `TRACES_SPAN_DRAWER_ENABLED` to `true` in localStorage to try it. */
export const isSpanDrawerEnabled = (): boolean =>
get(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED) === 'true';
type ListSpanEvent = {
name: string;
timeUnixNano: number;
attributes?: Record<string, string>;
isError?: boolean;
};
type ListSpanLink = { traceId: string; spanId: string };
/**
* The list API returns a span in a different shape from the trace API the
* details panel was built on: an ISO timestamp, `attributes` on events, and
* `links` in place of `references`.
*/
export const toSpanV3 = (row: TracesTableRow): SpanV3 => {
const resource = (row.resource ?? {}) as Record<string, string>;
const events = (row.events ?? []) as ListSpanEvent[];
const links = (row.links ?? []) as ListSpanLink[];
return {
...row,
timestamp: new Date(row.timestamp as string).getTime(),
'service.name': resource['service.name'] ?? '',
events: events.map(({ attributes, isError, ...event }) => ({
...event,
attributeMap: attributes ?? {},
isError: isError ?? false,
})),
// The list API drops `refType`, so the parent ref is matched by id. A root
// span's parent ref has no span id.
references: links.filter(
(link) =>
!!link.spanId &&
(link.spanId !== row.parent_span_id || link.traceId !== row.trace_id),
),
} as unknown as SpanV3;
};

View File

@@ -24,6 +24,10 @@ export type TracesTableProps = {
panelType: PanelTypeKeys;
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
getRowHref: (row: TracesTableRow) => string;
/** Replaces the default row click, which opens the trace detail page. */
onRowClick?: (row: TracesTableRow) => void;
isRowActive?: (row: TracesTableRow) => boolean;
activeRowIndex?: number;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
@@ -41,6 +45,9 @@ function TracesTable({
respectColumnOrder = false,
panelType,
getRowHref,
onRowClick,
isRowActive,
activeRowIndex,
isLoading,
isFetching,
isError,
@@ -57,9 +64,13 @@ function TracesTable({
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
if (onRowClick) {
onRowClick(row);
return;
}
navigate(getRowHref(row));
},
[navigate, getRowHref],
[onRowClick, navigate, getRowHref],
);
const handleRowClickNewTab = useCallback(
@@ -97,6 +108,8 @@ function TracesTable({
onColumnRemove={onColumnRemove}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
isRowActive={isRowActive}
activeRowIndex={activeRowIndex}
getRowTestId={(row): string => `traces-table-row-${row.id}`}
testId={`traces-table-${panelType.toLowerCase()}`}
/>
@@ -109,6 +122,9 @@ function TracesTable({
TracesTable.defaultProps = {
columnStorageKey: undefined,
respectColumnOrder: false,
onRowClick: undefined,
isRowActive: undefined,
activeRowIndex: undefined,
onColumnOrderChange: undefined,
onColumnRemove: undefined,
cellTypographySize: 'medium',

View File

@@ -0,0 +1,107 @@
import { createPortal } from 'react-dom';
import { render, screen, userEvent } from 'tests/test-utils';
import { useCloseOnOutsidePress } from '../useCloseOnOutsidePress';
function Harness({
enabled = true,
onClose,
}: {
enabled?: boolean;
onClose: () => void;
}): JSX.Element {
const onPressInside = useCloseOnOutsidePress(enabled, onClose);
return (
<>
<div onPointerDownCapture={onPressInside}>
<button type="button" data-testid="inside">
inside
</button>
<button
type="button"
data-testid="inside-stops"
onPointerDown={(e): void => e.stopPropagation()}
>
inside, stops propagation
</button>
{createPortal(
<button type="button" data-testid="inside-portal">
portal
</button>,
document.body,
)}
</div>
<button type="button" data-testid="outside">
outside
</button>
<button
type="button"
data-testid="outside-stops"
onPointerDown={(e): void => e.stopPropagation()}
>
outside, stops propagation
</button>
</>
);
}
const flush = (): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve);
});
describe('useCloseOnOutsidePress', () => {
it('closes on a press outside', async () => {
const onClose = jest.fn() as jest.MockedFunction<() => void>;
render(<Harness onClose={onClose} />);
await userEvent.click(screen.getByTestId('outside'));
await flush();
expect(onClose).toHaveBeenCalledTimes(1);
});
it('stays open on a press inside, portals included', async () => {
const onClose = jest.fn() as jest.MockedFunction<() => void>;
render(<Harness onClose={onClose} />);
await userEvent.click(screen.getByTestId('inside'));
await userEvent.click(screen.getByTestId('inside-portal'));
await flush();
expect(onClose).not.toHaveBeenCalled();
});
it('closes when the outside element stops propagation', async () => {
const onClose = jest.fn() as jest.MockedFunction<() => void>;
render(<Harness onClose={onClose} />);
await userEvent.click(screen.getByTestId('outside-stops'));
await flush();
expect(onClose).toHaveBeenCalledTimes(1);
});
it('closes on the first outside press after an inside press stops propagation', async () => {
const onClose = jest.fn() as jest.MockedFunction<() => void>;
render(<Harness onClose={onClose} />);
await userEvent.click(screen.getByTestId('inside-stops'));
await flush();
expect(onClose).not.toHaveBeenCalled();
await userEvent.click(screen.getByTestId('outside'));
await flush();
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does nothing while disabled', async () => {
const onClose = jest.fn() as jest.MockedFunction<() => void>;
render(<Harness enabled={false} onClose={onClose} />);
await userEvent.click(screen.getByTestId('outside'));
await flush();
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,51 @@
import {
PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
} from 'react';
/**
* Calls `onClose` on any press outside the elements given the returned handler as
* `onPointerDownCapture`. Menus and dialogs rendered in portals still count as
* inside: React events follow the component tree.
*
* The document listener runs in the capture phase, so an outside element that
* stops propagation still closes. It runs before React marks the press, so the
* check waits for the dispatch to finish.
*/
export function useCloseOnOutsidePress(
enabled: boolean,
onClose: () => void,
): (event: ReactPointerEvent) => void {
const insidePress = useRef<Event | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!enabled) {
return undefined;
}
const timeouts = new Set<ReturnType<typeof setTimeout>>();
const handlePointerDown = (event: PointerEvent): void => {
const timeout = setTimeout(() => {
timeouts.delete(timeout);
if (insidePress.current !== event) {
onCloseRef.current();
}
});
timeouts.add(timeout);
};
document.addEventListener('pointerdown', handlePointerDown, true);
return (): void => {
document.removeEventListener('pointerdown', handlePointerDown, true);
timeouts.forEach(clearTimeout);
};
}, [enabled]);
return useCallback((event: ReactPointerEvent): void => {
insidePress.current = event.nativeEvent;
}, []);
}

View File

@@ -13,7 +13,8 @@ import styles from './Events.module.scss';
interface IEventsTableProps {
span: SpanV3;
startTime: number;
/** Trace start; the "since trace start" offset is hidden without it. */
startTime?: number;
isSearchVisible: boolean;
}
@@ -80,17 +81,19 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
<Typography.Text className={styles.attributeKey}>
Start Time
</Typography.Text>
<div className={styles.timestampContainer}>
<Typography.Text className={styles.attributeValue}>
{getYAxisFormattedValue(
`${(timeUnixNano || 0) / 1e6 - startTime}`,
'ms',
)}
</Typography.Text>
<Typography.Text className={styles.timestampText}>
since trace start
</Typography.Text>
</div>
{startTime !== undefined && (
<div className={styles.timestampContainer}>
<Typography.Text className={styles.attributeValue}>
{getYAxisFormattedValue(
`${(timeUnixNano || 0) / 1e6 - startTime}`,
'ms',
)}
</Typography.Text>
<Typography.Text className={styles.timestampText}>
since trace start
</Typography.Text>
</div>
)}
<div className={styles.timestampContainer}>
<Typography.Text className={styles.attributeValue}>
{getYAxisFormattedValue(

View File

@@ -0,0 +1,38 @@
import { render, screen } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import Events from '../Events';
const SPAN_START_MS = 1_700_000_000_000;
const span = {
span_id: 'span-1',
trace_id: 'trace-1',
name: 'checkout',
timestamp: SPAN_START_MS,
duration_nano: 50_000_000,
events: [
{
name: 'cache miss',
timeUnixNano: (SPAN_START_MS + 3) * 1e6,
attributeMap: {},
isError: false,
},
],
} as unknown as SpanV3;
describe('Events', () => {
it('shows the offset from the trace start when it is known', () => {
render(<Events span={span} startTime={SPAN_START_MS - 10} isSearchVisible />);
expect(screen.getByText('since trace start')).toBeInTheDocument();
expect(screen.getByText('since span start')).toBeInTheDocument();
});
it('shows only the offset from the span start without a trace start', () => {
render(<Events span={span} isSearchVisible />);
expect(screen.queryByText('since trace start')).not.toBeInTheDocument();
expect(screen.getByText('since span start')).toBeInTheDocument();
});
});

View File

@@ -110,3 +110,15 @@
scrollbar-width: none;
}
}
.drawerPanel {
.panelBody,
.logsFooter,
:global(.pretty-view__search-wrapper) {
background: var(--l2-background);
}
:global(.pretty-view__search-input) {
background: var(--l2-background) !important;
}
}

View File

@@ -3,6 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Tabs, type TabsItemProps } from '@signozhq/ui/tabs';
import { Bookmark, ChartColumnBig, List, ScrollText } from '@signozhq/icons';
import { Skeleton } from 'antd';
import cx from 'classnames';
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
import { DetailsPanelState } from 'components/DetailsPanel/types';
@@ -49,6 +50,7 @@ import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
import SpanLogs from './SpanLogs/SpanLogs';
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
import SpanDrawerHeader from './SpanDrawerHeader';
import SpanSummary from './SpanSummary';
import styles from './SpanDetailsPanel.module.scss';
@@ -58,8 +60,12 @@ interface SpanDetailsPanelProps {
selectedSpan: SpanV3 | undefined;
variant?: SpanDetailVariant;
onVariantChange?: (variant: SpanDetailVariant) => void;
/** Page-specific actions, shown before the dock mode switcher. */
headerActions?: HeaderAction[];
traceStartTime?: number;
traceEndTime?: number;
/** The trace bounds are still being fetched; logs wait for them. */
isTraceBoundsLoading?: boolean;
}
// At/above this panel width the summary moves inside the Overview tab (bottom
@@ -73,10 +79,12 @@ function SpanDetailsContent({
selectedSpan,
traceStartTime,
traceEndTime,
isTraceBoundsLoading = false,
}: {
selectedSpan: SpanV3;
traceStartTime?: number;
traceEndTime?: number;
isTraceBoundsLoading?: boolean;
}): JSX.Element {
const [bodyRef, { width: bodyWidth }] = useMeasure<HTMLDivElement>();
const spanAttributeActions = useSpanAttributeActions();
@@ -168,6 +176,31 @@ function SpanDetailsContent({
// [spanAttributeActions, setCopy],
// );
// Without trace bounds the panel speaks only for the span: its own logs
// window, and no trace-wide logs check.
const hasTraceBounds = !!traceStartTime && !!traceEndTime;
const logsTimeRange = useMemo(
() =>
traceStartTime && traceEndTime
? {
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
}
: {
startTime: selectedSpan.timestamp - FIVE_MINUTES_IN_MS,
// query_range rejects a fractional end.
endTime:
Math.ceil(selectedSpan.timestamp + selectedSpan.duration_nano / 1e6) +
FIVE_MINUTES_IN_MS,
},
[
traceStartTime,
traceEndTime,
selectedSpan.timestamp,
selectedSpan.duration_nano,
],
);
const {
logs,
isLoading: isLogsLoading,
@@ -178,11 +211,9 @@ function SpanDetailsContent({
} = useSpanContextLogs({
traceId: selectedSpan.trace_id,
spanId: selectedSpan.span_id,
timeRange: {
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
},
isDrawerOpen: true,
timeRange: logsTimeRange,
checkTraceLogs: hasTraceBounds,
enabled: !isTraceBoundsLoading,
});
const infraMetadata = useMemo(() => {
@@ -201,8 +232,7 @@ function SpanDetailsContent({
}, [selectedSpan]);
const handleExplorerPageRedirect = useCallback((): void => {
const startTimeMs = (traceStartTime || 0) - FIVE_MINUTES_IN_MS;
const endTimeMs = (traceEndTime || 0) + FIVE_MINUTES_IN_MS;
const { startTime: startTimeMs, endTime: endTimeMs } = logsTimeRange;
const traceIdFilter = {
op: 'AND',
@@ -244,14 +274,15 @@ function SpanDetailsContent({
searchParams.set(QueryParams.endTime, endTimeMs.toString());
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`);
}, [selectedSpan.trace_id, traceStartTime, traceEndTime]);
}, [selectedSpan.trace_id, logsTimeRange]);
const emptyLogsStateConfig = useMemo(
() => ({
...getEmptyLogsListConfig(() => {}),
...(!hasTraceBounds && { title: 'No logs found for this span.' }),
showClearFiltersButton: false,
}),
[],
[hasTraceBounds],
);
// const keyAttributes = useMemo(() => {
@@ -321,11 +352,7 @@ function SpanDetailsContent({
</Badge>
) : undefined,
children: (
<Events
span={selectedSpan}
startTime={traceStartTime || 0}
isSearchVisible
/>
<Events span={selectedSpan} startTime={traceStartTime} isSearchVisible />
),
},
{
@@ -336,10 +363,7 @@ function SpanDetailsContent({
<SpanLogs
traceId={selectedSpan.trace_id}
spanId={selectedSpan.span_id}
timeRange={{
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
}}
timeRange={logsTimeRange}
logs={logs}
isLoading={isLogsLoading}
isError={isLogsError}
@@ -394,7 +418,10 @@ function SpanDetailsContent({
with the panel's bottom edge — always visible, never scrolls. */}
{activeTab === 'logs' && (
<div className={styles.logsFooter}>
<OpenInLogsExplorer onClick={handleExplorerPageRedirect} />
<OpenInLogsExplorer
onClick={handleExplorerPageRedirect}
disabled={isTraceBoundsLoading}
/>
</div>
)}
</>
@@ -406,11 +433,14 @@ function SpanDetailsPanel({
selectedSpan,
variant = SpanDetailVariant.DIALOG,
onVariantChange,
headerActions: pageHeaderActions,
traceStartTime,
traceEndTime,
isTraceBoundsLoading,
}: SpanDetailsPanelProps): JSX.Element {
const headerActions = useMemo((): HeaderAction[] => {
const actions: HeaderAction[] = [
...(pageHeaderActions ?? []),
// TODO: Add back when driven through separate config for different pages
// {
// key: 'view-full-trace',
@@ -440,7 +470,7 @@ function SpanDetailsPanel({
}
return actions;
}, [variant, onVariantChange]);
}, [pageHeaderActions, variant, onVariantChange]);
const PANEL_WIDTH = SPAN_DETAILS_PANEL_MIN_WIDTH;
const PANEL_MARGIN_RIGHT = 20;
@@ -449,19 +479,28 @@ function SpanDetailsPanel({
const content = (
<>
<DetailsHeader
title="Span details"
onClose={panelState.close}
actions={headerActions}
className={
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
}
/>
{variant === SpanDetailVariant.DRAWER ? (
<SpanDrawerHeader
title="Span details"
onClose={panelState.close}
actions={headerActions}
/>
) : (
<DetailsHeader
title="Span details"
onClose={panelState.close}
actions={headerActions}
className={
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
}
/>
)}
{selectedSpan ? (
<SpanDetailsContent
selectedSpan={selectedSpan}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
isTraceBoundsLoading={isTraceBoundsLoading}
/>
) : (
<div className={styles.panelBody}>
@@ -483,7 +522,8 @@ function SpanDetailsPanel({
<DetailsPanelDrawer
isOpen={panelState.isOpen}
onClose={panelState.close}
className={styles.panel}
className={cx(styles.panel, styles.drawerPanel)}
testId="span-details-drawer"
>
{content}
</DetailsPanelDrawer>

View File

@@ -0,0 +1,33 @@
.header {
box-sizing: border-box;
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--spacing-4);
height: 48px;
padding: 0 var(--spacing-8) 0 var(--spacing-4);
border-bottom: 1px solid var(--l1-border);
background: var(--l2-background);
}
.divider {
height: 16px;
margin-inline: 0 var(--spacing-4);
}
.title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: var(--l1-foreground);
}
.actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,43 @@
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { X } from '@signozhq/icons';
import type { HeaderAction } from 'components/DetailsPanel';
import styles from './SpanDrawerHeader.module.scss';
interface SpanDrawerHeaderProps {
title: string;
onClose: () => void;
actions: HeaderAction[];
}
function SpanDrawerHeader({
title,
onClose,
actions,
}: SpanDrawerHeaderProps): JSX.Element {
return (
<div className={styles.header}>
<Button
variant="ghost"
color="secondary"
size="md"
icon
aria-label="Close"
onClick={onClose}
testId="span-drawer-close"
>
<X />
</Button>
<Divider type="vertical" className={styles.divider} />
<span className={styles.title}>{title}</span>
<div className={styles.actions}>
{actions.map((action) => (
<div key={action.key}>{action.component}</div>
))}
</div>
</div>
);
}
export default SpanDrawerHeader;

View File

@@ -3,17 +3,23 @@ import { Compass } from '@signozhq/icons';
interface OpenInLogsExplorerProps {
onClick: () => void;
disabled?: boolean;
}
// Opens the full Logs Explorer (new tab) filtered to this span's trace.
// Placement/alignment is the caller's responsibility.
function OpenInLogsExplorer({ onClick }: OpenInLogsExplorerProps): JSX.Element {
function OpenInLogsExplorer({
onClick,
disabled,
}: OpenInLogsExplorerProps): JSX.Element {
return (
<Button
variant="solid"
color="secondary"
size="md"
onClick={onClick}
disabled={disabled}
disabledTooltip="Loading the trace's time range"
prefix={<Compass size={16} />}
testId="open-in-explorer-button"
>

View File

@@ -20,7 +20,8 @@ interface UseSpanContextLogsProps {
startTime: number;
endTime: number;
};
isDrawerOpen?: boolean;
checkTraceLogs?: boolean;
enabled?: boolean;
}
interface UseSpanContextLogsReturn {
@@ -112,7 +113,8 @@ export const useSpanContextLogs = ({
traceId,
spanId,
timeRange,
isDrawerOpen = true,
checkTraceLogs = true,
enabled = true,
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
const [allLogs, setAllLogs] = useState<ILog[]>([]);
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
@@ -142,7 +144,7 @@ export const useSpanContextLogs = ({
timeRange.endTime,
],
queryFn: () => GetMetricQueryRange(spanQueryPayload, ENTITY_VERSION_V5),
enabled: !!traceId && !!spanId,
enabled: enabled && !!traceId && !!spanId,
staleTime: FIVE_MINUTES_IN_MS,
});
@@ -311,7 +313,11 @@ export const useSpanContextLogs = ({
],
queryFn: () =>
GetMetricQueryRange(traceOnlyQueryPayload as any, ENTITY_VERSION_V5),
enabled: isDrawerOpen && !!traceOnlyQueryPayload && spanLogs.length === 0,
enabled:
enabled &&
checkTraceLogs &&
!!traceOnlyQueryPayload &&
spanLogs.length === 0,
staleTime: FIVE_MINUTES_IN_MS,
});
@@ -332,7 +338,7 @@ export const useSpanContextLogs = ({
return {
logs: allLogs,
isLoading: isSpanLoading && spanLogs.length === 0,
isLoading: (!enabled || isSpanLoading) && spanLogs.length === 0,
isError: isSpanError,
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
spanLogIds,

View File

@@ -1,7 +1,21 @@
.traceId {
color: var(--accent-primary);
display: flex;
align-items: center;
gap: var(--spacing-1);
min-width: 0;
// Sized to the icon, so the copy button sits flush against the id.
--button-padding: 2px;
--button-height: auto;
--button-width: auto;
}
// A block, since overflow and text-overflow do not apply to the inline span
// Typography.Text renders.
.value {
display: block;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}

View File

@@ -1,11 +1,5 @@
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { toast } from '@signozhq/ui/sonner';
import { AppLink } from 'lib/router/AppLink';
import { matchRoute } from 'lib/router/matchRoute';
import { useAppLocation } from 'lib/router/useAppLocation';
import ROUTES from 'constants/routes';
import { Typography } from '@signozhq/ui/typography';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import styles from './TraceIdField.module.scss';
@@ -14,51 +8,20 @@ interface TraceIdFieldProps {
span: SpanV3;
}
/**
* Renders a span's trace id. When the user is already on the trace detail
* page for this trace, clicking the id copies it to the clipboard (the
* "navigate" affordance would be a no-op). Otherwise, falls back to the
* existing link to the trace detail page.
*/
export function TraceIdField({ span }: TraceIdFieldProps): JSX.Element {
const { pathname } = useAppLocation();
const match = matchRoute<'id'>(pathname, ROUTES.TRACE_DETAIL, { exact: true });
const [, setCopy] = useCopyToClipboard();
const isCurrentTrace = match?.params.id === span.trace_id;
if (isCurrentTrace) {
const handleCopy = (): void => {
setCopy(span.trace_id);
toast.success('Trace ID copied to clipboard', {
position: 'top-right',
});
};
return (
<Tooltip title="Click to copy trace ID">
<Button
size="md"
variant="link"
color="secondary"
maxWidth="100%"
onClick={handleCopy}
>
{span.trace_id}
</Button>
</Tooltip>
);
}
return (
<AppLink
to={{
pathname: `/trace/${span.trace_id}`,
search: window.location.search,
}}
className={styles.traceId}
>
{span.trace_id}
</AppLink>
<div className={styles.traceId}>
<span className={styles.value} title={span.trace_id}>
<Typography.Text testId="span-details-trace-id">
{span.trace_id}
</Typography.Text>
</span>
<CopyButton
value={span.trace_id}
size={12}
ariaLabel="Copy trace ID"
testId="span-details-copy-trace-id"
/>
</div>
);
}

View File

@@ -33,6 +33,13 @@
flex-shrink: 0;
}
.aiDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-primary);
}
.filterSection {
display: flex;
align-items: center;

View File

@@ -137,7 +137,7 @@ function TraceDetailsHeader({
badgeValue={traceID}
maxCharacters={100}
/>
<TraceDetailsTabs />
<TraceDetailsTabs hasAiSummary={!!traceSummary?.ai} />
</div>
)}
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (

View File

@@ -8,20 +8,13 @@ import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import styles from './TraceDetailsHeader.module.scss';
const TAB_ITEMS = [
{
value: TraceDetailsTab.Overview,
label: 'Overview',
testId: 'trace-details-tab-overview',
},
{
value: TraceDetailsTab.Thread,
label: 'Thread',
testId: 'trace-details-tab-thread',
},
];
interface TraceDetailsTabsProps {
hasAiSummary: boolean;
}
function TraceDetailsTabs(): JSX.Element {
function TraceDetailsTabs({
hasAiSummary,
}: TraceDetailsTabsProps): JSX.Element {
const { id: traceId = '' } = useAppParams<'id'>();
const [tab, setTab] = useTraceDetailsTab();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId);
@@ -35,6 +28,26 @@ function TraceDetailsTabs(): JSX.Element {
void setTab(value as TraceDetailsTab, { history: 'push' });
};
const items = [
{
value: TraceDetailsTab.Overview,
label: 'Overview',
testId: 'trace-details-tab-overview',
},
{
value: TraceDetailsTab.Thread,
label: 'Thread',
testId: 'trace-details-tab-thread',
suffix: hasAiSummary ? (
<span
className={styles.aiDot}
aria-hidden
data-testid="trace-details-tab-thread-ai-dot"
/>
) : undefined,
},
];
return (
<div className={styles.tabs}>
<ToggleGroup
@@ -44,7 +57,7 @@ function TraceDetailsTabs(): JSX.Element {
value={tab}
onChange={handleChange}
size="sm"
items={TAB_ITEMS}
items={items}
/>
</div>
);

View File

@@ -394,6 +394,27 @@ describe('TraceDetailsHeader – tabs', () => {
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('shows the AI dot on the Thread tab only when the summary has an AI block', () => {
renderHeader();
expect(
screen.queryByTestId('trace-details-tab-thread-ai-dot'),
).not.toBeInTheDocument();
jest.mocked(useTraceSummary).mockReturnValue({
data: {
...SUMMARY,
ai: {
tokens: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
},
},
isLoading: false,
});
renderHeader();
expect(
screen.getByTestId('trace-details-tab-thread-ai-dot'),
).toBeInTheDocument();
});
it('clears the filter result when switching to the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();

View File

@@ -34,7 +34,8 @@ function ThreadSpanDetails({
const [width, setWidth] = useState(RIGHT_DOCK_MIN);
const [height, setHeight] = useState(BOTTOM_DOCK_DEFAULT);
const { id: traceId = '' } = useAppParams<'id'>();
const { data: traceSummary } = useTraceSummary(traceId);
const { data: traceSummary, isLoading: isTraceSummaryLoading } =
useTraceSummary(traceId);
const panel = (
<SpanDetailsPanel
@@ -44,6 +45,7 @@ function ThreadSpanDetails({
onVariantChange={onVariantChange}
traceStartTime={traceSummary?.startTimestampMillis}
traceEndTime={traceSummary?.endTimestampMillis}
isTraceBoundsLoading={isTraceSummaryLoading}
/>
);

View File

@@ -9,7 +9,7 @@ import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import { useThreadDataLoadedEvent } from './hooks/useThreadDataLoadedEvent';
import { useThreadLoadedEvents } from './hooks/useThreadLoadedEvents';
import { useThreadSpanSelection } from './hooks/useThreadSpanSelection';
import { useThreadView } from './hooks/useThreadView';
import { useTraceThread } from './hooks/useTraceThread';
@@ -30,10 +30,12 @@ const INITIAL_LOADER_COUNT = 4;
interface TraceDetailsThreadProps {
// Owned by the page so the load event fires once per trace, not per tab visit.
dataLoadedFiredForRef: MutableRefObject<string>;
threadEmptyFiredForRef: MutableRefObject<string>;
}
function TraceDetailsThread({
dataLoadedFiredForRef,
threadEmptyFiredForRef,
}: TraceDetailsThreadProps): JSX.Element {
const { id: traceId } = useAppParams<'id'>();
const [, setTab] = useTraceDetailsTab();
@@ -68,17 +70,24 @@ function TraceDetailsThread({
const interestedSpanIndex = spans.findIndex(
(span) => span.span_id === interestedSpanId,
);
const hasNoThread =
!isLoading &&
interestedSpanStatus !== InterestedSpanStatus.NotFound &&
(isError || spans.length === 0);
const selectedSpan = spans.find((span) => span.span_id === selectedSpanId);
const [spanDetailVariant, setSpanDetailVariant] = useSpanDetailVariant();
const isBottomDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
useThreadDataLoadedEvent({
useThreadLoadedEvents({
traceId: traceId || '',
interestedSpanId,
hasSpans: spans.length > 0,
hasNoThread,
spanPanelVariant: spanDetailVariant,
firedForRef: dataLoadedFiredForRef,
dataLoadedFiredForRef,
threadEmptyFiredForRef,
logTraceEvent,
});
useSpanDetailsOpenedEvent({
@@ -96,10 +105,6 @@ function TraceDetailsThread({
onVariantChange={setSpanDetailVariant}
/>
);
const hasNoThread =
!isLoading &&
interestedSpanStatus !== InterestedSpanStatus.NotFound &&
(isError || spans.length === 0);
const renderBody = (): JSX.Element => {
if (isLoading) {

View File

@@ -0,0 +1,135 @@
import { MutableRefObject, ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import {
GetTraceSummary200,
SpantypesGettableTraceSummaryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getGetTraceSummaryQueryKey } from 'api/generated/services/tracedetail';
import { rest, server } from 'mocks-server/server';
import { renderHook, waitFor } from 'tests/test-utils';
import { TraceDetailLogEvent } from '../../../hooks/useTraceDetailLogEvent';
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
import { useThreadLoadedEvents } from '../useThreadLoadedEvents';
const BASE_SUMMARY: SpantypesGettableTraceSummaryDTO = {
startTimestampMillis: 1_000,
endTimestampMillis: 4_000,
totalSpansCount: 12,
totalErrorSpansCount: 2,
hasMissingSpans: true,
rootServiceEntryPoint: 'POST /chat',
rootServiceName: 'agent',
rootSpanStatusCode: 'STATUS_CODE_ERROR',
};
const SUMMARIES: Record<string, SpantypesGettableTraceSummaryDTO> = {
'trace-ai': {
...BASE_SUMMARY,
ai: {
tokens: {
input: 1_200,
output: 300,
cacheRead: 50,
cacheWrite: 10,
reasoning: 40,
},
totalCost: 0.0123,
},
},
'trace-ai-next': {
...BASE_SUMMARY,
endTimestampMillis: 1_500,
totalSpansCount: 4,
ai: {
tokens: { input: 80, output: 20, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
totalCost: 0.0004,
},
},
'trace-no-ai': BASE_SUMMARY,
};
export interface ThreadLoadedEventsProps {
traceId: string;
hasSpans?: boolean;
hasNoThread?: boolean;
interestedSpanId?: string;
spanPanelVariant?: SpanDetailVariant;
}
interface RenderOptions {
dataLoadedFiredForRef?: MutableRefObject<string>;
threadEmptyFiredForRef?: MutableRefObject<string>;
queryClient?: QueryClient;
}
interface RenderResult {
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>;
rerender: (props: ThreadLoadedEventsProps) => void;
unmount: () => void;
queryClient: QueryClient;
}
/** Serves the fixture summaries and returns the trace ids requested, in order. */
export function mockTraceSummaries(): string[] {
const requests: string[] = [];
server.use(
rest.get('*/api/v1/traces/:traceId/summary', (req, res, ctx) => {
requests.push(req.params.traceId as string);
const body: GetTraceSummary200 = {
status: 'success',
data: SUMMARIES[req.params.traceId as string],
};
return res(ctx.status(200), ctx.json(body));
}),
);
return requests;
}
export function renderThreadLoadedEvents(
initialProps: ThreadLoadedEventsProps,
{
dataLoadedFiredForRef = { current: '' },
threadEmptyFiredForRef = { current: '' },
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
}),
}: RenderOptions = {},
): RenderResult {
const logTraceEvent = jest.fn() as jest.MockedFunction<TraceDetailLogEvent>;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { rerender, unmount } = renderHook(
({
traceId,
hasSpans = false,
hasNoThread = false,
interestedSpanId,
spanPanelVariant = SpanDetailVariant.DOCKED_RIGHT,
}: ThreadLoadedEventsProps) =>
useThreadLoadedEvents({
traceId,
interestedSpanId,
hasSpans,
hasNoThread,
spanPanelVariant,
dataLoadedFiredForRef,
threadEmptyFiredForRef,
logTraceEvent,
}),
{ initialProps, wrapper },
);
return { logTraceEvent, rerender, unmount, queryClient };
}
export async function waitForSummary(
queryClient: QueryClient,
traceId: string,
): Promise<void> {
await waitFor(() =>
expect(
queryClient.getQueryData(getGetTraceSummaryQueryKey({ traceID: traceId })),
).toBeDefined(),
);
}

View File

@@ -1,174 +0,0 @@
import { MutableRefObject, ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import {
GetTraceSummary200,
SpantypesGettableTraceSummaryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getGetTraceSummaryQueryKey } from 'api/generated/services/tracedetail';
import { rest, server } from 'mocks-server/server';
import { renderHook, waitFor } from 'tests/test-utils';
import { TraceDetailsTab } from '../../../constants';
import { TraceDetailEvents } from '../../../events';
import { TraceDetailLogEvent } from '../../../hooks/useTraceDetailLogEvent';
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
import { useThreadDataLoadedEvent } from '../useThreadDataLoadedEvent';
const SUMMARIES: Record<string, SpantypesGettableTraceSummaryDTO> = {
'trace-a': {
startTimestampMillis: 1_000,
endTimestampMillis: 4_000,
totalSpansCount: 12,
totalErrorSpansCount: 2,
hasMissingSpans: false,
rootServiceEntryPoint: 'POST /chat',
rootServiceName: 'agent',
rootSpanStatusCode: 'STATUS_CODE_ERROR',
},
'trace-b': {
startTimestampMillis: 5_000,
endTimestampMillis: 5_500,
totalSpansCount: 3,
totalErrorSpansCount: 0,
hasMissingSpans: false,
rootServiceEntryPoint: 'POST /chat',
rootServiceName: 'agent',
rootSpanStatusCode: 'STATUS_CODE_OK',
},
};
interface HookProps {
traceId: string;
hasSpans: boolean;
spanPanelVariant?: SpanDetailVariant;
}
function renderDataLoadedEvent(
initialProps: HookProps,
firedForRef: MutableRefObject<string> = { current: '' },
): {
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>;
rerender: (props: HookProps) => void;
queryClient: QueryClient;
} {
const logTraceEvent = jest.fn() as jest.MockedFunction<TraceDetailLogEvent>;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { rerender } = renderHook(
({ traceId, hasSpans, spanPanelVariant }: HookProps) =>
useThreadDataLoadedEvent({
traceId,
hasSpans,
spanPanelVariant: spanPanelVariant ?? SpanDetailVariant.DOCKED_RIGHT,
firedForRef,
logTraceEvent,
}),
{ initialProps, wrapper },
);
return { logTraceEvent, rerender, queryClient };
}
describe('useThreadDataLoadedEvent', () => {
beforeEach(() => {
server.use(
rest.get('*/api/v1/traces/:traceId/summary', (req, res, ctx) => {
const body: GetTraceSummary200 = {
status: 'success',
data: SUMMARIES[req.params.traceId as string],
};
return res(ctx.status(200), ctx.json(body));
}),
);
});
it('waits for the trace summary, then reports it as a thread load', async () => {
const { logTraceEvent } = renderDataLoadedEvent({
traceId: 'trace-a',
hasSpans: true,
});
expect(logTraceEvent).not.toHaveBeenCalled();
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
expect(logTraceEvent).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
detailsTab: TraceDetailsTab.Thread,
totalSpansCount: 12,
traceDurationMs: 3_000,
hadErrors: true,
spanPanelVariant: SpanDetailVariant.DOCKED_RIGHT,
});
});
it('waits for the thread spans even when the summary is already loaded', async () => {
const { logTraceEvent, rerender, queryClient } = renderDataLoadedEvent({
traceId: 'trace-a',
hasSpans: false,
});
await waitFor(() =>
expect(
queryClient.getQueryData(
getGetTraceSummaryQueryKey({ traceID: 'trace-a' }),
),
).toBeDefined(),
);
rerender({ traceId: 'trace-a', hasSpans: false });
expect(logTraceEvent).not.toHaveBeenCalled();
rerender({ traceId: 'trace-a', hasSpans: true });
expect(logTraceEvent).toHaveBeenCalledTimes(1);
});
it('reports a trace once, including when the tab is visited again', async () => {
const firedForRef = { current: '' };
const firstVisit = renderDataLoadedEvent(
{ traceId: 'trace-a', hasSpans: true },
firedForRef,
);
await waitFor(() =>
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1),
);
firstVisit.rerender({
traceId: 'trace-a',
hasSpans: true,
spanPanelVariant: SpanDetailVariant.DOCKED,
});
const secondVisit = renderDataLoadedEvent(
{ traceId: 'trace-a', hasSpans: true },
firedForRef,
);
await waitFor(() =>
expect(
secondVisit.queryClient.getQueryData(
getGetTraceSummaryQueryKey({ traceID: 'trace-a' }),
),
).toBeDefined(),
);
secondVisit.rerender({ traceId: 'trace-a', hasSpans: true });
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1);
expect(secondVisit.logTraceEvent).not.toHaveBeenCalled();
});
it('reports the next trace with its own summary', async () => {
const { logTraceEvent, rerender } = renderDataLoadedEvent({
traceId: 'trace-a',
hasSpans: true,
});
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
rerender({ traceId: 'trace-b', hasSpans: true });
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
expect(logTraceEvent).toHaveBeenLastCalledWith(
TraceDetailEvents.DataLoaded,
expect.objectContaining({ totalSpansCount: 3, traceDurationMs: 500 }),
);
});
});

View File

@@ -0,0 +1,111 @@
import { waitFor } from 'tests/test-utils';
import { TraceDetailsTab } from '../../../constants';
import { TraceDetailEvents } from '../../../events';
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
import {
mockTraceSummaries,
renderThreadLoadedEvents,
ThreadLoadedEventsProps,
waitForSummary,
} from './threadLoadedEventsTestUtils';
describe('useThreadLoadedEvents: data loaded', () => {
beforeEach(() => {
mockTraceSummaries();
});
it('waits for the trace summary, then reports it as a thread load', async () => {
const { logTraceEvent } = renderThreadLoadedEvents({
traceId: 'trace-ai',
hasSpans: true,
});
expect(logTraceEvent).not.toHaveBeenCalled();
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
expect(logTraceEvent).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
detailsTab: TraceDetailsTab.Thread,
totalSpansCount: 12,
traceDurationMs: 3_000,
hadErrors: true,
spanPanelVariant: SpanDetailVariant.DOCKED_RIGHT,
});
});
it('waits for the thread spans even when the summary is already loaded', async () => {
const { logTraceEvent, rerender, queryClient } = renderThreadLoadedEvents({
traceId: 'trace-ai',
});
await waitForSummary(queryClient, 'trace-ai');
rerender({ traceId: 'trace-ai' });
expect(logTraceEvent).not.toHaveBeenCalled();
rerender({ traceId: 'trace-ai', hasSpans: true });
expect(logTraceEvent).toHaveBeenCalledTimes(1);
});
it('reports a trace once, including when the tab is visited again', async () => {
const dataLoadedFiredForRef = { current: '' };
const props: ThreadLoadedEventsProps = {
traceId: 'trace-ai',
hasSpans: true,
};
const firstVisit = renderThreadLoadedEvents(props, { dataLoadedFiredForRef });
await waitFor(() =>
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1),
);
firstVisit.rerender({
...props,
spanPanelVariant: SpanDetailVariant.DOCKED,
});
const secondVisit = renderThreadLoadedEvents(props, {
dataLoadedFiredForRef,
});
await waitForSummary(secondVisit.queryClient, 'trace-ai');
secondVisit.rerender(props);
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1);
expect(secondVisit.logTraceEvent).not.toHaveBeenCalled();
});
it('reports the next trace with its own summary', async () => {
const { logTraceEvent, rerender } = renderThreadLoadedEvents({
traceId: 'trace-ai',
hasSpans: true,
});
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
rerender({ traceId: 'trace-ai-next', hasSpans: true });
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
expect(logTraceEvent).toHaveBeenLastCalledWith(
TraceDetailEvents.DataLoaded,
expect.objectContaining({ totalSpansCount: 4, traceDurationMs: 500 }),
);
});
it('also reports an empty thread, once each, when an errored thread still holds spans', async () => {
const props: ThreadLoadedEventsProps = {
traceId: 'trace-ai',
hasSpans: true,
hasNoThread: true,
};
const { logTraceEvent, rerender } = renderThreadLoadedEvents(props);
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
rerender(props);
expect(logTraceEvent).toHaveBeenCalledTimes(2);
expect(logTraceEvent).toHaveBeenCalledWith(
TraceDetailEvents.DataLoaded,
expect.any(Object),
);
expect(logTraceEvent).toHaveBeenCalledWith(
TraceDetailEvents.ThreadEmptyWithAiSummary,
expect.any(Object),
);
});
});

View File

@@ -0,0 +1,114 @@
import { waitFor } from 'tests/test-utils';
import { TraceDetailsTab } from '../../../constants';
import { TraceDetailEvents } from '../../../events';
import {
mockTraceSummaries,
renderThreadLoadedEvents,
ThreadLoadedEventsProps,
waitForSummary,
} from './threadLoadedEventsTestUtils';
describe('useThreadLoadedEvents: thread empty with AI summary', () => {
let summaryRequests: string[];
beforeEach(() => {
summaryRequests = mockTraceSummaries();
});
it('reports the trace summary when the thread is empty but the summary has AI usage', async () => {
const { logTraceEvent } = renderThreadLoadedEvents({
traceId: 'trace-ai',
hasNoThread: true,
interestedSpanId: 'span-1',
});
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
expect(logTraceEvent).toHaveBeenCalledWith(
TraceDetailEvents.ThreadEmptyWithAiSummary,
{
detailsTab: TraceDetailsTab.Thread,
spanId: 'span-1',
rootServiceName: 'agent',
rootServiceEntryPoint: 'POST /chat',
totalSpansCount: 12,
traceDurationMs: 3_000,
hadErrors: true,
hasMissingSpans: true,
inputTokens: 1_200,
outputTokens: 300,
cacheReadTokens: 50,
cacheWriteTokens: 10,
reasoningTokens: 40,
totalCost: 0.0123,
},
);
});
it('does not report when the summary has no AI usage', async () => {
const props: ThreadLoadedEventsProps = {
traceId: 'trace-no-ai',
hasNoThread: true,
};
const { logTraceEvent, rerender, queryClient } =
renderThreadLoadedEvents(props);
await waitForSummary(queryClient, 'trace-no-ai');
rerender(props);
expect(logTraceEvent).not.toHaveBeenCalled();
});
it('waits until the thread is known to be empty', async () => {
const { logTraceEvent, rerender, queryClient } = renderThreadLoadedEvents({
traceId: 'trace-ai',
});
await waitForSummary(queryClient, 'trace-ai');
rerender({ traceId: 'trace-ai' });
expect(logTraceEvent).not.toHaveBeenCalled();
rerender({ traceId: 'trace-ai', hasNoThread: true });
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
});
it('reports once per trace and reuses the loaded summary when the tab is visited again', async () => {
const threadEmptyFiredForRef = { current: '' };
const props: ThreadLoadedEventsProps = {
traceId: 'trace-ai',
hasNoThread: true,
};
const first = renderThreadLoadedEvents(props, { threadEmptyFiredForRef });
await waitFor(() => expect(first.logTraceEvent).toHaveBeenCalledTimes(1));
first.unmount();
const second = renderThreadLoadedEvents(props, {
threadEmptyFiredForRef,
queryClient: first.queryClient,
});
expect(second.queryClient.isFetching()).toBe(0);
expect(summaryRequests).toStrictEqual(['trace-ai']);
expect(second.logTraceEvent).not.toHaveBeenCalled();
});
it('reports the next trace with its own summary', async () => {
const { logTraceEvent, rerender } = renderThreadLoadedEvents({
traceId: 'trace-ai',
hasNoThread: true,
});
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
rerender({ traceId: 'trace-ai-next', hasNoThread: true });
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
expect(logTraceEvent).toHaveBeenLastCalledWith(
TraceDetailEvents.ThreadEmptyWithAiSummary,
expect.objectContaining({
totalSpansCount: 4,
inputTokens: 80,
totalCost: 0.0004,
}),
);
});
});

View File

@@ -1,45 +0,0 @@
import { MutableRefObject, useEffect } from 'react';
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import { TraceDetailsTab } from '../../constants';
import { TraceDetailEventKeys, TraceDetailEvents } from '../../events';
import { TraceDetailLogEvent } from '../../hooks/useTraceDetailLogEvent';
import { SpanDetailVariant } from '../../SpanDetailsPanel/constants';
interface ThreadDataLoadedEventArgs {
traceId: string;
hasSpans: boolean;
spanPanelVariant: SpanDetailVariant;
firedForRef: MutableRefObject<string>;
logTraceEvent: TraceDetailLogEvent;
}
export function useThreadDataLoadedEvent({
traceId,
hasSpans,
spanPanelVariant,
firedForRef,
logTraceEvent,
}: ThreadDataLoadedEventArgs): void {
// No keepPreviousData: a previous trace's summary must not be reported for this one.
const { data } = useGetTraceSummary(
{ traceID: traceId },
{ query: { enabled: !!traceId, refetchOnMount: false } },
);
const summary = data?.data;
useEffect(() => {
if (!traceId || firedForRef.current === traceId || !hasSpans || !summary) {
return;
}
firedForRef.current = traceId;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.DetailsTab]: TraceDetailsTab.Thread,
[TraceDetailEventKeys.TotalSpansCount]: summary.totalSpansCount,
[TraceDetailEventKeys.TraceDurationMs]:
summary.endTimestampMillis - summary.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: summary.totalErrorSpansCount > 0,
[TraceDetailEventKeys.SpanPanelVariant]: spanPanelVariant,
});
}, [traceId, hasSpans, summary, spanPanelVariant, firedForRef, logTraceEvent]);
}

View File

@@ -0,0 +1,70 @@
import { MutableRefObject, useEffect } from 'react';
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import { TraceDetailEvents } from '../../events';
import { TraceDetailLogEvent } from '../../hooks/useTraceDetailLogEvent';
import { SpanDetailVariant } from '../../SpanDetailsPanel/constants';
import {
getThreadDataLoadedEventAttributes,
getThreadEmptyWithAiEventAttributes,
} from '../utils';
interface ThreadLoadedEventsArgs {
traceId: string;
interestedSpanId?: string;
hasSpans: boolean;
hasNoThread: boolean;
spanPanelVariant: SpanDetailVariant;
dataLoadedFiredForRef: MutableRefObject<string>;
threadEmptyFiredForRef: MutableRefObject<string>;
logTraceEvent: TraceDetailLogEvent;
}
export function useThreadLoadedEvents({
traceId,
interestedSpanId,
hasSpans,
hasNoThread,
spanPanelVariant,
dataLoadedFiredForRef,
threadEmptyFiredForRef,
logTraceEvent,
}: ThreadLoadedEventsArgs): void {
// No keepPreviousData: a previous trace's summary must not be reported for this one.
const { data } = useGetTraceSummary(
{ traceID: traceId },
{ query: { enabled: !!traceId, refetchOnMount: false } },
);
const summary = data?.data;
useEffect(() => {
if (!traceId || !summary) {
return;
}
// Not exclusive: an errored thread can still hold spans, so each event keeps its own ref.
if (hasSpans && dataLoadedFiredForRef.current !== traceId) {
dataLoadedFiredForRef.current = traceId;
logTraceEvent(
TraceDetailEvents.DataLoaded,
getThreadDataLoadedEventAttributes(summary, spanPanelVariant),
);
}
if (hasNoThread && summary.ai && threadEmptyFiredForRef.current !== traceId) {
threadEmptyFiredForRef.current = traceId;
logTraceEvent(
TraceDetailEvents.ThreadEmptyWithAiSummary,
getThreadEmptyWithAiEventAttributes(summary, interestedSpanId),
);
}
}, [
traceId,
interestedSpanId,
hasSpans,
hasNoThread,
summary,
spanPanelVariant,
dataLoadedFiredForRef,
threadEmptyFiredForRef,
logTraceEvent,
]);
}

View File

@@ -1,7 +1,13 @@
import type { SpantypesThreadSpanDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SpantypesGettableTraceSummaryDTO,
SpantypesThreadSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { isAxiosError } from 'axios';
import { collectToolCallKeys } from '../AIThreadMessage/utils';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys } from '../events';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import { InterestedSpanStatus, ThreadSpan } from './types';
/** Derives SpanV3's `timestamp` and `service.name`, as the waterfall does. */
@@ -47,3 +53,44 @@ export function getInterestedSpanStatus(
? InterestedSpanStatus.Found
: InterestedSpanStatus.NoIOMessages;
}
function getThreadSummaryAttributes(
summary: SpantypesGettableTraceSummaryDTO,
): Record<string, unknown> {
return {
[TraceDetailEventKeys.DetailsTab]: TraceDetailsTab.Thread,
[TraceDetailEventKeys.TotalSpansCount]: summary.totalSpansCount,
[TraceDetailEventKeys.TraceDurationMs]:
summary.endTimestampMillis - summary.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: summary.totalErrorSpansCount > 0,
};
}
export function getThreadDataLoadedEventAttributes(
summary: SpantypesGettableTraceSummaryDTO,
spanPanelVariant: SpanDetailVariant,
): Record<string, unknown> {
return {
...getThreadSummaryAttributes(summary),
[TraceDetailEventKeys.SpanPanelVariant]: spanPanelVariant,
};
}
export function getThreadEmptyWithAiEventAttributes(
summary: SpantypesGettableTraceSummaryDTO,
interestedSpanId: string | undefined,
): Record<string, unknown> {
return {
...getThreadSummaryAttributes(summary),
[TraceDetailEventKeys.SpanId]: interestedSpanId,
[TraceDetailEventKeys.RootServiceName]: summary.rootServiceName,
[TraceDetailEventKeys.RootServiceEntryPoint]: summary.rootServiceEntryPoint,
[TraceDetailEventKeys.HasMissingSpans]: summary.hasMissingSpans,
[TraceDetailEventKeys.InputTokens]: summary.ai?.tokens.input,
[TraceDetailEventKeys.OutputTokens]: summary.ai?.tokens.output,
[TraceDetailEventKeys.CacheReadTokens]: summary.ai?.tokens.cacheRead,
[TraceDetailEventKeys.CacheWriteTokens]: summary.ai?.tokens.cacheWrite,
[TraceDetailEventKeys.ReasoningTokens]: summary.ai?.tokens.reasoning,
[TraceDetailEventKeys.TotalCost]: summary.ai?.totalCost,
};
}

View File

@@ -14,6 +14,7 @@ export enum TraceDetailEvents {
SpanPercentileAttributeChanged = 'Trace Detail: Span percentile attribute changed',
SpanDetailsOpened = 'Trace Detail: Span details opened',
DetailsTabChanged = 'Trace Detail: Details tab changed',
ThreadEmptyWithAiSummary = 'Trace Detail: Thread empty with AI summary',
}
export enum TraceDetailEventKeys {
@@ -48,6 +49,16 @@ export enum TraceDetailEventKeys {
Selected = 'selected',
// Data loaded / span details opened: which tab the user is on
DetailsTab = 'detailsTab',
// Thread empty with AI summary (reuses SpanId, DetailsTab, TotalSpansCount, TraceDurationMs, HadErrors)
RootServiceName = 'rootServiceName',
RootServiceEntryPoint = 'rootServiceEntryPoint',
HasMissingSpans = 'hasMissingSpans',
InputTokens = 'inputTokens',
OutputTokens = 'outputTokens',
CacheReadTokens = 'cacheReadTokens',
CacheWriteTokens = 'cacheWriteTokens',
ReasoningTokens = 'reasoningTokens',
TotalCost = 'totalCost',
}
export type TraceDetailView = 'v2' | 'v3';

View File

@@ -17,6 +17,7 @@ function TraceDetailsV3(): JSX.Element {
// between traces (the route component stays mounted) re-fires it once each.
const dataLoadedFiredForRef = useRef<string>('');
const threadDataLoadedFiredForRef = useRef<string>('');
const threadEmptyFiredForRef = useRef<string>('');
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean): void => {
@@ -40,7 +41,10 @@ function TraceDetailsV3(): JSX.Element {
dataLoadedFiredForRef={dataLoadedFiredForRef}
/>
) : (
<TraceDetailsThread dataLoadedFiredForRef={threadDataLoadedFiredForRef} />
<TraceDetailsThread
dataLoadedFiredForRef={threadDataLoadedFiredForRef}
threadEmptyFiredForRef={threadEmptyFiredForRef}
/>
)}
</TraceDetailsWrapper>
);

View File

@@ -13,7 +13,7 @@ import type { SpantypesPostableTraceAggregationsDTO } from 'api/generated/servic
import { LOCALSTORAGE } from 'constants/localStorage';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { screen, userEvent, within } from 'storybook/test';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import type { QueryRangeRequestV5, RawRow } from 'types/api/v5/queryRange';
import {
choiceControl,
@@ -150,6 +150,12 @@ export const traceDetailsMocks = defineStoryMocks({
value: 8,
max: 20,
}),
traceOnlyLogs: toggleControl('Logs only on the trace', {
group: PANEL,
description:
'The span has no logs of its own but the trace does, which is the Logs tab\'s "view logs for the current trace" state.',
value: false,
}),
exportState: choiceControl<ExportState>('Trace export', {
group: EXPORT,
description:
@@ -165,6 +171,12 @@ export const traceDetailsMocks = defineStoryMocks({
errors: values.errors,
traceStart: traceStartOf(),
};
const spanLogRowsFor = (body: QueryRangeRequestV5): RawRow[] => {
const scope = spanLogScopeOf(body);
const count =
values.traceOnlyLogs && scope !== 'trace' ? 0 : values.spanLogs;
return spanLogRows(count, trace, scope);
};
return [
rest.post('http://localhost/api/v1/export_raw_data', (_req, res, ctx) => {
@@ -266,9 +278,7 @@ export const traceDetailsMocks = defineStoryMocks({
if (requestType === 'raw') {
return queryRangeV5RawResponse(
signalOf(body) === 'logs'
? spanLogRows(values.spanLogs, trace, spanLogScopeOf(body))
: spanMatchRows(trace),
signalOf(body) === 'logs' ? spanLogRowsFor(body) : spanMatchRows(trace),
);
}

View File

@@ -17,7 +17,6 @@ import {
focusSpanIndex,
scenarioSpanCount,
spanIdAt,
STORY_TRACE_ID,
} from './__story_mockdata__/traceDetails';
import {
openRowMenu,
@@ -249,18 +248,35 @@ export const SpanPercentileOpen: Story = {
},
};
/** The span's trace id clicked in the panel, which copies it. */
export const TraceIdCopiedToast: Story = {
/** The span has no logs of its own but its trace does, so the Logs tab points to the trace's. */
export const SpanPanelTraceOnlyLogs: Story = {
args: { traceOnlyLogs: true },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole('tab', { name: /Logs/ }, untilLoaded),
);
await expect(
await within(canvasElement).findByText(
'No logs found for selected span. View logs for the current trace.',
undefined,
untilLoaded,
),
).toBeVisible();
},
};
/** The span panel's trace id copied with its copy button, which turns into a check. */
export const TraceIdCopied: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: STORY_TRACE_ID },
{ name: 'Copy trace ID' },
untilLoaded,
),
);
await expect(
await screen.findByText('Trace ID copied to clipboard'),
await within(canvasElement).findByRole('button', { name: 'Copied' }),
).toBeVisible();
},
};

View File

@@ -4,9 +4,14 @@
*/
import { rest } from 'msw';
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { spanPercentilesResponse } from 'pages/TraceDetailsV3/stories/__story_mockdata__/traceDetails';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { explorerViewToPanelType } from 'utils/explorerUtils';
@@ -27,6 +32,10 @@ import {
import {
exportDashboardsResponse,
savedTraceViewsResponse,
SPAN_LOGS_STATES,
spanLogRows,
spanLogScopeOf,
type SpanLogsState,
traceAttributeValuesResponse,
traceFieldKeysResponse,
traceFieldValues,
@@ -34,8 +43,11 @@ import {
traceQuickFiltersResponse,
traceRootSpanRows,
traceSpanRows,
TRACE_SUMMARY_STATES,
traceSummaryResponse,
TRACES_EXPLORER_VIEWS,
TRACES_TABS,
type TraceSummaryState,
type TracesTab,
} from './__story_mockdata__/traces';
@@ -65,6 +77,8 @@ const tracesRoute = ({ tab, view }: RouteValues): string => {
interface RawQuerySpec {
limit?: number;
offset?: number;
signal?: string;
filter?: { expression?: string };
}
const rawSpecOf = (body: QueryRangeRequestV5): RawQuerySpec => {
@@ -73,6 +87,9 @@ const rawSpecOf = (body: QueryRangeRequestV5): RawQuerySpec => {
return spec as RawQuerySpec;
};
// The summary has no window of its own; its bounds wrap the span the list showed.
const listWindow = { start: 0, end: 0 };
export const tracesMocks = defineStoryMocks({
controls: {
tab: choiceControl<TracesTab>('Tab', {
@@ -102,6 +119,26 @@ export const tracesMocks = defineStoryMocks({
'Answers 503 on the spans of the services that fail, which is what the status column and the error colouring read.',
value: true,
}),
spanDrawer: toggleControl('Span drawer', {
group: LIST,
description:
'The `TRACES_SPAN_DRAWER_ENABLED` localStorage flag the span drawer ships behind. Off, a row click opens the trace page.',
value: false,
}),
traceSummary: choiceControl<TraceSummaryState>('Trace summary', {
group: LIST,
description:
'What the span drawer gets for the trace start and end. Not found falls back to span-only logs; loading holds the logs tab.',
options: TRACE_SUMMARY_STATES,
value: 'found',
}),
spanLogs: choiceControl<SpanLogsState>('Span logs', {
group: LIST,
description:
"What the span drawer's Logs tab gets. `span`: the span's logs with context either side; `trace only`: none for the span but some for the trace; `none`; `loading` and `error` hold or fail only the log queries.",
options: SPAN_LOGS_STATES,
value: 'span',
}),
warning: toggleControl('Query warning', {
group: LIST,
description: 'Attaches a warning to the response, next to the toolbar.',
@@ -169,6 +206,96 @@ export const tracesMocks = defineStoryMocks({
response.json(exportDashboardsResponse),
),
rest.post(
'http://localhost/api/v1/span_percentile',
response.json(spanPercentilesResponse),
),
// Answers on its own control rather than the Data one, so the drawer's
// fallback and waiting states are reachable with the list loaded.
rest.get(
'http://localhost/api/v1/traces/:traceId/summary',
(req, res, ctx) => {
if (values.traceSummary === 'loading') {
return res(ctx.delay('infinite'));
}
const summary =
values.traceSummary === 'found'
? traceSummaryResponse({
traceId: String(req.params.traceId),
count: values.spans,
...listWindow,
})
: null;
return summary
? res(ctx.status(200), ctx.json(summary))
: res(ctx.status(404), ctx.json({ status: 'error' }));
},
),
// The drawer's percentile panel dereferences this preference without a guard.
rest.get(
'http://localhost/api/v1/user/preferences/:name',
response.json((req) => ({
status: 'success',
data: {
name: String(req.params.name),
description: 'storybook',
valueType: 'array',
defaultValue: [],
allowedValues: [],
allowedScopes: ['user'],
value:
String(req.params.name) ===
USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES
? ['service.name']
: [],
},
})),
),
// The drawer's log queries answer on their own control, so they can hang or
// fail with the list loaded. Anything else falls through to the next handler.
rest.post('http://localhost/api/v5/query_range', async (req, res, ctx) => {
const body = (await req.json()) as QueryRangeRequestV5;
const { signal, filter } = rawSpecOf(body);
if (signal !== 'logs') {
return undefined;
}
if (values.spanLogs === 'loading') {
return res(ctx.delay('infinite'));
}
if (values.spanLogs === 'error') {
return res(
ctx.status(500),
ctx.json({ status: 'error', error: 'storybook logs failure' }),
);
}
const expression = filter?.expression ?? '';
const scope = spanLogScopeOf(expression);
const answers =
values.spanLogs === 'span' ||
(values.spanLogs === 'trace only' && scope === 'trace');
return res(
ctx.status(200),
ctx.json(
queryRangeV5RawResponse(
answers
? spanLogRows({
scope,
expression,
startMicros: body.start,
endMicros: body.end,
})
: [],
),
),
);
}),
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => {
@@ -195,6 +322,9 @@ export const tracesMocks = defineStoryMocks({
: undefined;
if (requestType === 'raw' || requestType === 'trace') {
listWindow.start = start;
listWindow.end = end;
return queryRangeV5RawResponse(
requestType === 'trace' ? traceRootSpanRows(rows) : traceSpanRows(rows),
{
@@ -232,4 +362,11 @@ export const tracesMocks = defineStoryMocks({
),
],
config: (values) => ({ route: tracesRoute(values) }),
effect: (values) => {
if (values.spanDrawer) {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
} else {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
}
},
});

View File

@@ -25,7 +25,7 @@ const pageStory = storyMocks(tracesMocks, { layout: 'app' });
* Route: `/traces-explorer`.
*/
const meta = {
title: 'Pages/Traces/Explorer',
title: 'Pages/Traces/Explorer/Overview',
tags: ['play'],
component: TracesModulePage,
...pageStory,

View File

@@ -17,6 +17,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import type {
GetQuickFilters200,
GetTraceSummary200,
ListSavedViews200,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -173,6 +174,22 @@ const flatSpanRows = ({
};
});
/** The list API's event shape: `attributes`, where the trace API says `attributeMap`. */
const exceptionEvent = (
timestamp: string,
): {
name: string;
timeUnixNano: number;
attributes: Record<string, string>;
} => ({
name: 'exception',
timeUnixNano: new Date(timestamp).getTime() * 1_000_000,
attributes: {
'exception.type': 'UpstreamTimeoutError',
'exception.message': 'upstream did not answer within 1s',
},
});
/** The List view returns every span field, with resource and attribute fields nested in maps. */
export const traceSpanRows = (options: SpanRowsOptions): RawRow[] =>
flatSpanRows(options).map(
@@ -182,6 +199,10 @@ export const traceSpanRows = (options: SpanRowsOptions): RawRow[] =>
...data,
attributes: {},
resource: { 'service.name': serviceName },
events: data.has_error ? [exceptionEvent(timestamp)] : [],
links: data.has_error
? [{ traceId: traceIdAt(0), spanId: spanIdAt(0) }]
: [],
},
}),
);
@@ -196,6 +217,122 @@ export const traceRootSpanRows = (options: SpanRowsOptions): RawRow[] =>
},
}));
export const TRACE_SUMMARY_STATES = ['found', 'not found', 'loading'] as const;
export type TraceSummaryState = (typeof TRACE_SUMMARY_STATES)[number];
const TRACE_PADDING_MS = 250;
/** Null for a trace the list never showed. */
export const traceSummaryResponse = ({
traceId,
count,
start,
end,
}: Pick<SpanRowsOptions, 'count' | 'start' | 'end'> & {
traceId: string;
}): GetTraceSummary200 | null => {
const index = rowWindow({ count, offset: 0, limit: count }).find(
(i) => traceIdAt(i) === traceId,
);
if (index === undefined) {
return null;
}
const spanStart = new Date(timestampAt(index, start, end)).getTime();
const span = SPANS[index % SPANS.length];
return {
status: 'success',
data: {
startTimestampMillis: spanStart - TRACE_PADDING_MS,
endTimestampMillis: spanStart + span.durationMs + TRACE_PADDING_MS,
rootServiceName: SPANS[0].service,
rootServiceEntryPoint: SPANS[0].operation,
rootSpanStatusCode: '200',
totalSpansCount: SPANS.length,
totalErrorSpansCount: SPANS.filter(({ failing }) => failing).length,
hasMissingSpans: false,
},
};
};
export const SPAN_LOGS_STATES = [
'span',
'trace only',
'none',
'loading',
'error',
] as const;
export type SpanLogsState = (typeof SPAN_LOGS_STATES)[number];
/**
* Which of the drawer's log queries is asking: the span's own logs, the context
* either side of them, or the whole trace when the span has none.
*/
export type SpanLogScope = 'span' | 'before' | 'after' | 'trace';
export const spanLogScopeOf = (expression: string): SpanLogScope => {
if (expression.includes('span_id')) {
return 'span';
}
if (expression.includes('id <')) {
return 'before';
}
return expression.includes('id >') ? 'after' : 'trace';
};
const SPAN_LOG_BODIES = [
'request received',
'cache miss, querying orders',
'payment provider timed out',
'response sent',
];
const CONTEXT_OFFSET_MS: Record<SpanLogScope, number> = {
before: -60_000,
span: 0,
after: 60_000,
trace: 0,
};
/**
* Log rows for one of the drawer's queries, placed in the middle of the window
* it asked for. The span-log queries send that window in microseconds.
*/
export const spanLogRows = ({
scope,
expression,
startMicros,
endMicros,
}: {
scope: SpanLogScope;
expression: string;
startMicros: number;
endMicros: number;
}): RawRow[] => {
const traceId = expression.match(/trace_id = '([^']+)'/)?.[1] ?? '';
const spanId = expression.match(/span_id = '([^']+)'/)?.[1] ?? '';
const middle = (startMicros + endMicros) / 2 / 1e3;
return SPAN_LOG_BODIES.map((body, index) => ({
timestamp: new Date(
middle + CONTEXT_OFFSET_MS[scope] + index * 100,
).toISOString(),
data: {
body,
id: `storybook-${scope}-log-${index + 1}`,
trace_id: traceId,
span_id: scope === 'span' ? spanId : '',
severity_text: index === 2 ? 'ERROR' : 'INFO',
severity_number: index === 2 ? 17 : 9,
attributes_string: {},
resources_string: { 'service.name': SPANS[0].service },
scope_name: '',
},
}));
};
const { resource, attribute } = TelemetrytypesFieldContextDTO;
const { string: stringType, bool } = TelemetrytypesFieldDataTypeDTO;

View File

@@ -0,0 +1,191 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { tracesMocks } from '../TracesModulePage.stories.mocks';
import TracesModulePage from '../../TracesModulePage';
type TracesArgs = PageStoryArgs<typeof tracesMocks>;
const pageStory = storyMocks(tracesMocks, { layout: 'app' });
/**
* The span details drawer the explorer list opens on a row, one story per state
* of its trace summary and its Logs tab. Every story opens the same failing
* span; the Trace summary and Span logs controls pick what the drawer gets.
*
* Route: `/traces-explorer`.
*/
const meta = {
title: 'Pages/Traces/Explorer/Span Drawer',
tags: ['play'],
component: TracesModulePage,
...pageStory,
parameters: { ...pageStory.parameters },
args: { spanDrawer: true },
} satisfies Meta<TracesArgs>;
export default meta;
type Story = StoryObj<TracesArgs>;
/** The page fetches before it renders its rows, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
// The quick filters list the same span name, so the row is found by its test
// id; the table renders its rows a few at a time, so it waits for this one.
const openDrawer = async (): Promise<HTMLElement> => {
const row = await waitFor((): HTMLElement => {
const match = screen
.getAllByTestId(/^traces-table-row-/)
.find((candidate) => candidate.textContent?.includes('POST /api/orders'));
expect(match).toBeDefined();
return match as HTMLElement;
}, untilLoaded);
await userEvent.click(within(row).getByText('POST /api/orders'));
return screen.findByTestId('span-details-drawer');
};
const openLogsTab = async (): Promise<HTMLElement> => {
const drawer = await openDrawer();
await userEvent.click(within(drawer).getByRole('tab', { name: /Logs/ }));
return drawer;
};
/**
* The drawer on a failing span: its summary with % exec time, attributes, the
* exception event and a linked span, with up and down stepping through the page.
*/
export const Default: Story = {
play: async ({ mount }): Promise<void> => {
await mount();
const drawer = await openDrawer();
await expect(
await within(drawer).findByText(
/of total exec time/,
undefined,
untilLoaded,
),
).toBeVisible();
},
};
/** The Logs tab with the span's logs, between the logs just before and after them. */
export const Logs: Story = {
args: { spanLogs: 'span' },
play: async ({ mount }): Promise<void> => {
await mount();
const drawer = await openLogsTab();
await expect(
(
await within(drawer).findAllByText(
/payment provider timed out/,
undefined,
untilLoaded,
)
)[0],
).toBeVisible();
},
};
/** The span has no logs of its own but its trace does, so the tab points to the trace's. */
export const TraceOnlyLogs: Story = {
args: { spanLogs: 'trace only' },
play: async ({ mount }): Promise<void> => {
await mount();
const drawer = await openLogsTab();
await expect(
await within(drawer).findByText(
'No logs found for selected span. View logs for the current trace.',
undefined,
untilLoaded,
),
).toBeVisible();
},
};
/** Neither the span nor its trace has logs. */
export const NoLogs: Story = {
args: { spanLogs: 'none' },
play: async ({ mount }): Promise<void> => {
await mount();
const drawer = await openLogsTab();
await expect(
await within(drawer).findByText(
'No logs found for this trace.',
undefined,
untilLoaded,
),
).toBeVisible();
},
};
/**
* The trace summary 404s, so the drawer speaks only for the span: no % exec
* time, logs over the span's own window and no trace-wide logs check.
*/
export const WithoutTraceSummary: Story = {
args: { traceSummary: 'not found', spanLogs: 'none' },
// The deliberate 404 is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ mount }): Promise<void> => {
await mount();
const drawer = await openLogsTab();
await expect(
await within(drawer).findByText(
'No logs found for this span.',
undefined,
untilLoaded,
),
).toBeVisible();
await expect(
within(drawer).queryByText(/of total exec time/),
).not.toBeInTheDocument();
},
};
/** The trace summary never answers: the Logs tab waits and its explorer link is off. */
export const TraceSummaryLoading: Story = {
args: { traceSummary: 'loading' },
play: async ({ mount }): Promise<void> => {
await mount();
await openLogsTab();
await expect(
await screen.findByTestId('open-in-explorer-button'),
).toHaveAttribute('aria-disabled', 'true');
},
};
/** The log queries fail while the rest of the drawer renders. */
export const LogsError: Story = {
args: { spanLogs: 'error' },
// The deliberate 500 is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ mount }): Promise<void> => {
await mount();
const drawer = await openLogsTab();
await expect(
await within(drawer).findByText(
/Something went wrong/,
undefined,
untilLoaded,
),
).toBeVisible();
},
};

View File

@@ -20,6 +20,9 @@ var (
ErrCodeUnknownIdentifier = errors.MustNewCode("unknown_identifier")
ErrCodeIllegalArgument = errors.MustNewCode("illegal_argument")
ErrCodeDistributedSubqueryDenied = errors.MustNewCode("distributed_subquery_denied")
ErrCodeDecimalOverflow = errors.MustNewCode("decimal_overflow")
ErrCodeQueryCanceled = errors.MustNewCode("query_canceled")
ErrCodeQueryTimeout = errors.MustNewCode("query_timeout")
ErrCodeExecutionFailed = errors.MustNewCode("execution_failed")
@@ -72,6 +75,18 @@ var clickHouseExceptionWrappers = map[chproto.Error]func(cause error, ex *clickh
chproto.ErrTooLessArgumentsForFunction: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "too few arguments to function: %s", ex.Message)
},
chproto.ErrBadGet: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "parameter of the wrong type: %s", ex.Message).
WithSuggestions("write numeric function parameters in the type the function expects, for example 1.0 rather than 1 where it takes a Float64")
},
chproto.ErrDistributedInJoinSubqueryDenied: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeDistributedSubqueryDenied, "IN or JOIN subquery over a distributed table is not allowed: %s", ex.Message).
WithSuggestions("use GLOBAL IN or GLOBAL JOIN for the subquery")
},
chproto.ErrDecimalOverflow: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeDecimalOverflow, "decimal overflow: %s", ex.Message).
WithSuggestions("cast the Decimal values to Float64, or to a wider Decimal such as Decimal128, before the arithmetic")
},
}
type provider struct {