Compare commits

..

10 Commits

Author SHA1 Message Date
Gaurav Tewari
56cef582e4 refactor: trace details and and header changes 2026-10-07 16:47:35 +05:30
Gaurav Tewari
67b9f42e7a feat: trace detail header & tooltip changes 2026-10-07 16:47:35 +05:30
Gaurav Tewari
8537a7688c feat: update badge color 2026-10-07 16:47:34 +05:30
Gaurav Tewari
6e8e6f3659 feat: add support for badge and update ai icons 2026-10-07 16:47:34 +05:30
Gaurav Tewari
b317048220 chore: update tooltip design and support ai spans 2026-10-07 16:47:34 +05:30
Gaurav Tewari
5c9a8eb19f chore: another refactor approch 2026-10-07 11:17:33 +00:00
Gaurav Tewari
a54206ed5e feat: add trace tab and initial refactor 2026-10-07 11:17:33 +00:00
Nityananda Gohain
636c023483 feat: add trace summary endpoint (#12911)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Adds `GET /api/v1/traces/{traceID}/summary`. It returns the trace
header fields shown with the waterfall, plus AI token and cost totals,
using one query.
- The waterfall loads every span to get these fields. It will stop
returning them once the frontend uses this endpoint.
- Fixes these waterfall bugs:
  - On traces over 10k spans, the entry point is empty.
- If some spans point to a parent that was never received, the root can
change on every reload.



| Trace size | Endpoint | Latency | Server memory | Data sent to server
|
|---|---|---|---|---|
| 2M spans | summary | 0.65 s | 84 MiB | 3 KB |
| 2M spans | waterfall | 12.5 s | 2.6 GiB | 128 MiB |

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/nerve-pod/issues/265

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- AI token and cost fields are now typed `number`, the same as in
metadata.

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-10-07 11:05:13 +00:00
Aditya Singh
1d54a93e02 feat(quick-filters): saved views slots in the sidebar layout (#12994)
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
- first slice of saved views in the quick filters sidebar..
`QuickFiltersLayout` gets a `savedViewProps` next to `quickFilterProps`,
both optional. views header sits above the filters, `≡` opens an All
views panel in the sidebar's place and the filters slide right over the
content as one drawer. nothing remounts, so expanded sections and an
open settings drawer survive the toggle.
- header and panel are placeholders for now (dummy name, empty list)..
this PR is the layout, the real header / list / actions come on top of
it. behind `SAVED_VIEW_ENABLED`, flag off is today's ui.
- metrics has no quick filters, so it gets the layout with only the
views side.. header on top, list always visible, nothing slides.
- logs explorer moved onto `QuickFiltersLayout`.. it was the last one
mounting quick filters on its own. content wrapper now hands a definite
height down so logs can keep sizing its list from the pane instead of
`calc(100vh - ..)`.
- quick filters settings drawer animates with transform instead of
width.. width relaid out the drawer every frame, that was the jitter.
clipped so it comes out from behind the filters.

#### Issues closed by this PR

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


#### Screenshots/ Screen recording 

<img width="1728" height="1001" alt="12994 - metrics dummy section"
src="https://github.com/user-attachments/assets/7a57fd43-a11b-44e7-a8ca-4b197826bd36"
/>


https://github.com/user-attachments/assets/fd6a4ac4-6464-4059-9986-99d42e159a3d




#### Additional Information
- flag off checked on logs / traces / metrics / exceptions.. sidebar,
list scroll and pane scroll same as before.
- borders and l1 background per figma, quick filters width still 280px..
will take that up with design.
- cc. @H4ad since `QuickFiltersLayout` is from the scroll fix PR.
2026-10-07 07:08:31 +00:00
Abhi kumar
ea127308b6 chore(storybook): add new-panel picker and config pane stories (#13058)
#### Description

- Stories for the current new-panel picker and panel editor config pane,
as the visual baseline for #12992 and #13010.
- Dashboard detail: picker opened from the toolbar, with a kind
selected, without sections, and from a section's menu.
- Panel editor: one story per panel kind with every config section
expanded; Area and Text added to the editor's kind list.
- `story-shots.mjs`: round the grown viewport height up, since
Playwright rejects fractional sizes.
2026-10-07 06:22:19 +00:00
114 changed files with 3315 additions and 2271 deletions

1
.gitattributes vendored
View File

@@ -1,4 +1,3 @@
*.css linguist-detectable=false
*.stories.mocks.tsx linguist-generated=true
**/__story_mockdata__/** linguist-generated=true
tests/e2e/drivers/** linguist-generated=true

View File

@@ -68,6 +68,7 @@ jobs:
- semconvfamilies
- serviceaccount
- spanmapper
- tracedetail
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

View File

@@ -9844,6 +9844,40 @@ components:
required:
- aggregations
type: object
SpantypesGettableTraceSummary:
properties:
ai:
$ref: '#/components/schemas/SpantypesTraceAISummary'
endTimestampMillis:
minimum: 0
type: integer
hasMissingSpans:
type: boolean
rootServiceEntryPoint:
type: string
rootServiceName:
type: string
rootSpanStatusCode:
type: string
startTimestampMillis:
minimum: 0
type: integer
totalErrorSpansCount:
minimum: 0
type: integer
totalSpansCount:
minimum: 0
type: integer
required:
- startTimestampMillis
- endTimestampMillis
- rootServiceName
- rootServiceEntryPoint
- rootSpanStatusCode
- totalSpansCount
- totalErrorSpansCount
- hasMissingSpans
type: object
SpantypesGettableWaterfallTrace:
properties:
endTimestampMillis:
@@ -10158,6 +10192,39 @@ components:
nullable: true
type: object
type: object
SpantypesTraceAISummary:
properties:
tokens:
$ref: '#/components/schemas/SpantypesTraceAITokens'
totalCost:
type: number
required:
- tokens
type: object
SpantypesTraceAITokens:
properties:
cacheRead:
minimum: 0
type: integer
cacheWrite:
minimum: 0
type: integer
input:
minimum: 0
type: integer
output:
minimum: 0
type: integer
reasoning:
minimum: 0
type: integer
required:
- input
- output
- cacheRead
- cacheWrite
- reasoning
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -15882,6 +15949,66 @@ paths:
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/traces/{traceID}/summary:
get:
deprecated: false
description: Returns the trace's time range, root span, span and error counts,
and whether any spans are missing. AI traces also include token and cost totals.
operationId: GetTraceSummary
parameters:
- in: path
name: traceID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceSummary'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get summary for a trace
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/user/me:
get:
deprecated: true

View File

@@ -86,9 +86,6 @@ tests/
├── .env.local # generated by bootstrap/setup.py (gitignored)
├── bootstrap/
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
├── drivers/ # the driver kit and one driver per feature
│ ├── kit.ts
│ └── routing.ts
├── fixtures/ # Playwright test fixtures (test.extend) only
│ └── auth.ts
├── helpers/ # function helpers + the constants they share with tests
@@ -115,24 +112,6 @@ These two folders look similar but mean different things:
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
### Drivers
New suites use a driver rather than loose helpers. A driver is a function that closes over the page and returns the feature's actions and locators. `drivers/kit.ts` turns it into a fixture, so a spec imports one module and never passes a `page` around:
```ts
import { expect, PATH, test } from '../../drivers/routing';
test('TC-09 RouteTab switches the url', async ({ routing }) => {
await routing.goto(PATH.metricsSummary);
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
});
```
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
- A driver reuses helpers by calling them, never by reaching for another fixture.
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
### Extended fixtures
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.

View File

@@ -417,7 +417,7 @@ for (const theme of themes.length ? themes : [null]) {
break;
}
height = needed;
height = Math.ceil(needed);
await page.setViewportSize({ width: Number(opts.width), height });
await page.waitForTimeout(Number(opts.settle));
}

View File

@@ -11335,6 +11335,82 @@ export interface SpantypesGettableTraceAggregationsDTO {
aggregations: SpantypesSpanAggregationResultDTO[];
}
export interface SpantypesTraceAITokensDTO {
/**
* @type integer
* @minimum 0
*/
cacheRead: number;
/**
* @type integer
* @minimum 0
*/
cacheWrite: number;
/**
* @type integer
* @minimum 0
*/
input: number;
/**
* @type integer
* @minimum 0
*/
output: number;
/**
* @type integer
* @minimum 0
*/
reasoning: number;
}
export interface SpantypesTraceAISummaryDTO {
tokens: SpantypesTraceAITokensDTO;
/**
* @type number
*/
totalCost?: number;
}
export interface SpantypesGettableTraceSummaryDTO {
ai?: SpantypesTraceAISummaryDTO;
/**
* @type integer
* @minimum 0
*/
endTimestampMillis: number;
/**
* @type boolean
*/
hasMissingSpans: boolean;
/**
* @type string
*/
rootServiceEntryPoint: string;
/**
* @type string
*/
rootServiceName: string;
/**
* @type string
*/
rootSpanStatusCode: string;
/**
* @type integer
* @minimum 0
*/
startTimestampMillis: number;
/**
* @type integer
* @minimum 0
*/
totalErrorSpansCount: number;
/**
* @type integer
* @minimum 0
*/
totalSpansCount: number;
}
export interface SpantypesOtelSpanRefDTO {
/**
* @type string
@@ -13023,6 +13099,17 @@ export type GetTraceAggregations200 = {
status: string;
};
export type GetTraceSummaryPathParameters = {
traceID: string;
};
export type GetTraceSummary200 = {
data: SpantypesGettableTraceSummaryDTO;
/**
* @type string
*/
status: string;
};
export type ListUserPreferences200 = {
/**
* @type array

View File

@@ -4,11 +4,17 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
@@ -16,6 +22,8 @@ import type {
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetTraceSummary200,
GetTraceSummaryPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
@@ -27,6 +35,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Computes span aggregations grouped by requested field.
* @summary Get aggregations for a trace
@@ -127,6 +155,108 @@ export const useGetTraceAggregations = <
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.
* @summary Get summary for a trace
*/
export const getTraceSummary = (
{ traceID }: GetTraceSummaryPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceSummary200>({
url: `/api/v1/traces/${traceID}/summary`,
method: 'GET',
signal,
});
};
export const getGetTraceSummaryQueryKey = ({
traceID,
}: GetTraceSummaryPathParameters) => {
return [`/api/v1/traces/${traceID}/summary`] as const;
};
export const getGetTraceSummaryQueryOptions = <
TData = Awaited<ReturnType<typeof getTraceSummary>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceSummaryPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
signal,
}) => getTraceSummary({ traceID }, signal);
return {
queryKey,
queryFn,
enabled: traceID !== null && traceID !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetTraceSummaryQueryResult = NonNullable<
Awaited<ReturnType<typeof getTraceSummary>>
>;
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get summary for a trace
*/
export function useGetTraceSummary<
TData = Awaited<ReturnType<typeof getTraceSummary>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceSummaryPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get summary for a trace
*/
export const invalidateGetTraceSummary = async (
queryClient: QueryClient,
{ traceID }: GetTraceSummaryPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
options,
);
return queryClient;
};
/**
* Returns the flamegraph view of spans for a given trace ID.
* @summary Get flamegraph view for a trace

View File

@@ -72,8 +72,6 @@ interface CustomTimePickerProps {
maxTime: number;
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
isModalTimeSelection?: boolean;
/** Lands on the trigger input. Not spread — the component takes no rest props. */
'data-testid'?: string;
}
function CustomTimePicker({
@@ -97,7 +95,6 @@ function CustomTimePicker({
minTime,
maxTime,
isModalTimeSelection = false,
'data-testid': dataTestId,
}: CustomTimePickerProps): JSX.Element {
const [selectedTimePlaceholderValue, setSelectedTimePlaceholderValue] =
useState('Select / Enter Time Range');
@@ -599,7 +596,6 @@ function CustomTimePicker({
>
<Input
ref={inputRef}
data-testid={dataTestId}
autoComplete="off"
className={cx(
'timeSelection-input',
@@ -686,5 +682,4 @@ CustomTimePicker.defaultProps = {
onExitLiveLogs: noop,
showLiveLogs: false,
showRecentlyUsed: true,
'data-testid': undefined,
};

View File

@@ -180,7 +180,6 @@ function CustomTimePickerPopoverContent({
type="text"
className="time-btns"
key={option.label + option.value}
data-testid={`time-chip-${option.value}`}
onClick={(): void => {
handleExitLiveLogs();
onSelectHandler(option.label, option.value);
@@ -260,7 +259,6 @@ function CustomTimePickerPopoverContent({
<Button
type="text"
key={option.label + option.value}
data-testid={`time-option-${option.value}`}
onClick={(e: React.MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
e.preventDefault();

View File

@@ -98,7 +98,6 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
<div
class="c0"
data-testid="not-found"
>
<img
alt="not-found"

View File

@@ -6,7 +6,7 @@ import { Button, Container, Text, TextContainer } from './styles';
function NotFound({ text = defaultText }: Props): JSX.Element {
return (
<Container data-testid="not-found">
<Container>
<NotFoundImage />
<TextContainer>

View File

@@ -12,6 +12,7 @@
overflow: visible;
position: relative;
align-self: stretch;
clip-path: inset(0 -100vw 0 0);
}
}

View File

@@ -57,6 +57,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
showFilterCollapse = true,
showQueryName = true,
useFieldApis,
savedViewsHeader,
} = props;
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [params, setParams] = useApiMonitoringParams();
@@ -379,6 +380,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<div className="quick-filters-container">
<div className="quick-filters">
{savedViewsHeader}
{source !== QuickFiltersSource.INFRA_MONITORING && (
<section className="header">
{renderLeftActions()}

View File

@@ -8,9 +8,7 @@
overflow: hidden;
}
// Positioned so overlays (settings drawer) paint above the content pane
// without changing this pane's layout width.
.filters {
.sidebar {
width: 280px;
flex-shrink: 0;
display: flex;
@@ -21,6 +19,44 @@
z-index: 2;
}
.savedViews {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--l1-background);
border-right: 1px solid var(--l1-border);
}
.quickFilters {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--l1-background);
transition: transform 0.2s ease-in-out;
z-index: 1;
&.isOpen {
transform: translateX(100%);
}
}
// No quick filters: the header sits in flow and the list stays under it
.isStatic {
border-right: 1px solid var(--l1-border);
.quickFilters {
position: static;
flex-shrink: 0;
}
.savedViews {
border-right: 0;
}
}
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
// `height: 100%`), which owns the scrolling.
.content {
@@ -31,3 +67,7 @@
flex-direction: column;
overflow: hidden;
}
.contentInner {
height: 100%;
}

View File

@@ -1,6 +1,9 @@
import { ComponentProps, ReactNode } from 'react';
import { ComponentProps, ReactNode, useState } from 'react';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
@@ -12,8 +15,15 @@ type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
ComponentProps<typeof QuickFilters>
>;
// What the page configures; the layout adds the open / close wiring.
type SavedViewsElementProps = Omit<
ComponentProps<typeof SavedViewsPanel>,
'onClose'
>;
export interface QuickFiltersLayoutProps {
quickFilterProps: QuickFiltersElementProps;
quickFilterProps?: QuickFiltersElementProps;
savedViewProps?: SavedViewsElementProps;
showFilters: boolean;
className?: string;
contentClassName?: string;
@@ -23,20 +33,60 @@ export interface QuickFiltersLayoutProps {
function QuickFiltersLayout({
quickFilterProps,
savedViewProps,
showFilters,
className,
contentClassName,
testId,
children,
}: QuickFiltersLayoutProps): JSX.Element {
const [isViewsOpen, setIsViewsOpen] = useState(false);
const isSavedViewEnabled = useSavedViewEnabled();
const hasQuickFilters = !!quickFilterProps;
const hasSavedViews = !!savedViewProps && isSavedViewEnabled;
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
const isPanelPinned = hasSavedViews && !hasQuickFilters;
const isSliding = hasQuickFilters && isViewsOpen;
const savedViewsHeader = hasSavedViews ? (
<SavedViewsHeader
{...savedViewProps}
onOpenViews={isPanelPinned ? undefined : (): void => setIsViewsOpen(true)}
/>
) : undefined;
return (
<div className={cx(styles.layout, className)} data-testid={testId}>
{showFilters && (
{showSidebar && (
<aside
className={styles.filters}
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
data-testid="quick-filters-layout-filters"
>
<QuickFilters {...quickFilterProps} />
<div
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
data-testid="quick-filters-layout-drawer"
>
{hasQuickFilters ? (
<QuickFilters
{...quickFilterProps}
savedViewsHeader={savedViewsHeader}
/>
) : (
savedViewsHeader
)}
</div>
<div className={styles.savedViews}>
{hasSavedViews && (isPanelPinned || isViewsOpen) && (
<SavedViewsPanel
{...savedViewProps}
onClose={isPanelPinned ? undefined : (): void => setIsViewsOpen(false)}
/>
)}
</div>
</aside>
)}
<section
@@ -44,7 +94,7 @@ function QuickFiltersLayout({
data-testid="quick-filters-layout-content"
>
<OverlayScrollbar>
<div>{children}</div>
<div className={styles.contentInner}>{children}</div>
</OverlayScrollbar>
</section>
</div>

View File

@@ -1,4 +1,6 @@
import { render, screen } from 'tests/test-utils';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { render, screen, userEvent } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
import QuickFiltersLayout from '../QuickFiltersLayout';
@@ -7,15 +9,33 @@ jest.mock('../QuickFiltersLayout.module.scss', () => ({
__esModule: true,
default: {
layout: 'layout',
filters: 'filters',
sidebar: 'sidebar',
isStatic: 'isStatic',
savedViews: 'savedViews',
quickFilters: 'quickFilters',
isOpen: 'isOpen',
content: 'content',
contentInner: 'contentInner',
},
}));
jest.mock('hooks/useSavedViewEnabled', () => ({
useSavedViewEnabled: jest.fn(() => true),
}));
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({ source }: { source: string }): JSX.Element => (
<div data-testid="quick-filters">{source}</div>
default: ({
source,
savedViewsHeader,
}: {
source: string;
savedViewsHeader?: React.ReactNode;
}): JSX.Element => (
<div data-testid="quick-filters">
{savedViewsHeader}
{source}
</div>
),
}));
@@ -23,8 +43,16 @@ const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
handleFilterVisibilityChange: jest.fn(),
};
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
const drawer = (): HTMLElement =>
screen.getByTestId('quick-filters-layout-drawer');
describe('QuickFiltersLayout', () => {
beforeEach(() => {
jest.mocked(useSavedViewEnabled).mockReturnValue(true);
});
it('renders QuickFilters with the given props inside the filters pane', () => {
render(
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
@@ -40,11 +68,16 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
'content',
);
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
});
it('does not render the filters pane when showFilters is false', () => {
render(
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
<QuickFiltersLayout
showFilters={false}
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
@@ -56,24 +89,138 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByText('content')).toBeInTheDocument();
});
it('renders no sidebar at all without quick filters or saved views', () => {
render(
<QuickFiltersLayout showFilters>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.getByText('content')).toBeInTheDocument();
});
it('merges classNames onto the root and content panes', () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
className="page-root"
contentClassName="page-content"
testId="page"
className="root-extra"
contentClassName="content-extra"
testId="layout"
>
<div>content</div>
</QuickFiltersLayout>,
);
const root = screen.getByTestId('page');
expect(root).toHaveClass('layout', 'page-root');
expect(screen.getByTestId('layout')).toHaveClass('layout', 'root-extra');
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
'content',
'page-content',
'content-extra',
);
});
describe('saved views', () => {
it('ignores savedViewProps while the flag is off', () => {
jest.mocked(useSavedViewEnabled).mockReturnValue(false);
const { unmount } = render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
// Saved views only, so nothing is left to show.
unmount();
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
});
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
const user = userEvent.setup();
const quickFilters = screen.getByTestId('quick-filters');
expect(quickFilters).toContainElement(
screen.getByTestId('saved-views-header'),
);
expect(drawer()).toContainElement(quickFilters);
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
await user.click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(drawer()).toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
await user.click(screen.getByTestId('saved-views-close'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
});
it('hands the source to the header and the panel', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('saved-views-header')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
});
it('without quick filters: static sidebar, header on top, list always on screen with no open or close controls', () => {
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
const sidebar = screen.getByTestId('quick-filters-layout-filters');
expect(sidebar).toHaveClass('isStatic');
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-close')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
});
});
});

View File

@@ -8,12 +8,20 @@
width: 342px;
height: 100%;
background: var(--l1-background);
transition: width 0.05s ease-in-out;
border-right: 1px solid var(--l1-border);
overflow: hidden;
color: var(--l1-foreground);
transition:
transform 0.2s ease-in-out,
visibility 0s;
&.hidden {
width: 0;
transform: translateX(-100%);
visibility: hidden;
pointer-events: none;
transition:
transform 0.2s ease-in-out,
visibility 0s 0.2s;
}
.qf-header {
display: flex;

View File

@@ -1,3 +1,4 @@
import { ReactNode } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -61,6 +62,7 @@ export interface IQuickFiltersProps {
showFilterCollapse?: boolean;
showQueryName?: boolean;
useFieldApis?: QuickFilterCheckboxUseFieldApis;
savedViewsHeader?: ReactNode;
}
export enum QuickFiltersSource {

View File

@@ -35,8 +35,7 @@ function TanStackRowCellInner<TData>({
<td
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
data-single-column={hasSingleColumn || undefined}
// While loading, rowData may be a placeholder ({ id: 'skeleton-N' }).
onClick={showSkeleton ? undefined : onClick}
onClick={onClick}
>
{showSkeleton ? (
<Skeleton.Input

View File

@@ -83,18 +83,6 @@ describe('TanStackTableView Integration', () => {
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('does not call onRowClick for skeleton rows', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
renderTanStackTable({
props: { data: [], isLoading: true, onRowClick },
});
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).not.toHaveBeenCalled();
});
it('shows loading spinner for infinite scroll when loading', () => {
renderTanStackTable({
props: { isLoading: true, onEndReached: jest.fn() },

View File

@@ -10,6 +10,8 @@ import WarningPopover from 'components/WarningPopover/WarningPopover';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
@@ -347,57 +349,62 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
value={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
<QuickFiltersLayout
showFilters
savedViewProps={{ source: SavedviewtypesSourceDTO.metrics }}
>
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
value={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
/>
</Tooltip>
</div>
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
</Tooltip>
</div>
</div>
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
showFunctions={false}
version="v3"
/>
<div className="explore-content">
<TimeSeries
onFetchingStateChange={setIsLoadingQueries}
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
/>
</div>
</div>
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
showFunctions={false}
version="v3"
/>
<div className="explore-content">
<TimeSeries
onFetchingStateChange={setIsLoadingQueries}
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
/>
</div>
</div>
</QuickFiltersLayout>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}

View File

@@ -308,7 +308,7 @@ function CreateEditRolePageContent(): JSX.Element {
cancelText="Keep editing"
onConfirm={confirmNavigation}
onCancel={cancelNavigation}
testId="discard-changes-dialog"
data-testid="discard-changes-dialog"
>
<Typography>
{isCreateMode

View File

@@ -0,0 +1,19 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--l1-border);
color: var(--l1-foreground);
flex-shrink: 0;
}
.name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}

View File

@@ -0,0 +1,43 @@
import { Menu } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './SavedViewsHeader.module.scss';
// Temp Placeholder data till main functionality is plugged in
const PLACEHOLDER_VIEW_NAME = 'My view';
function SavedViewsHeader({
source,
onOpenViews,
}: {
source: SavedviewtypesSourceDTO;
// Absent when the list is always on screen.
onOpenViews?: () => void;
}): JSX.Element {
return (
<div
className={styles.header}
data-testid="saved-views-header"
data-source={source}
>
<Typography.Text className={styles.name}>
{PLACEHOLDER_VIEW_NAME}
</Typography.Text>
{onOpenViews && (
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="All views"
prefix={<Menu size={14} />}
onClick={onOpenViews}
data-testid="saved-views-open"
/>
)}
</div>
);
}
export default SavedViewsHeader;

View File

@@ -0,0 +1,21 @@
.panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
color: var(--l1-foreground);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
}
.title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}

View File

@@ -0,0 +1,40 @@
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './SavedViewsPanel.module.scss';
function SavedViewsPanel({
source,
onClose,
}: {
source: SavedviewtypesSourceDTO;
// Absent when the list is always on screen.
onClose?: () => void;
}): JSX.Element {
return (
<div
className={styles.panel}
data-testid="saved-views-panel"
data-source={source}
>
<div className={styles.header}>
<Typography.Text className={styles.title}>All views</Typography.Text>
{onClose && (
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Close"
prefix={<X size={14} />}
onClick={onClose}
data-testid="saved-views-close"
/>
)}
</div>
</div>
);
}
export default SavedViewsPanel;

View File

@@ -2,6 +2,7 @@ import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -22,6 +23,7 @@ const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
*/
const meta = {
title: 'Pages/Dashboards/Panel Editor',
tags: ['play'],
// The page is wrapped in `withAuthZPage`, which types its props as an index
// signature; the story's args are what the controls resolve to.
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
@@ -38,6 +40,25 @@ export default meta;
type Story = StoryObj<PanelEditorArgs>;
const expandConfigSections: Story['play'] = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const headers = await canvas.findAllByTestId(
/^config-section-/,
{},
{ timeout: 10000 },
);
for (const header of headers) {
if (header.getAttribute('aria-expanded') === 'false') {
await userEvent.click(header);
}
}
(document.activeElement as HTMLElement | null)?.blur();
for (let el = headers[0].parentElement; el; el = el.parentElement) {
el.scrollTop = 0;
}
};
/**
* Editing a saved time series panel: the live preview over the query builder on
* the left, the panel's formatting, legend, axes and thresholds on the right.
@@ -86,3 +107,57 @@ export const ReadOnly: Story = {
export const Tooltips: Story = {
args: { tooltipsOpen: true },
};
/** A new time series panel's config pane, every section expanded. */
export const ConfigTimeSeries: Story = {
args: { panel: 'new', newPanelKind: 'time-series' },
play: expandConfigSections,
};
/** A new number panel's config pane, every section expanded. */
export const ConfigNumber: Story = {
args: { panel: 'new', newPanelKind: 'number' },
play: expandConfigSections,
};
/** A new table panel's config pane, every section expanded. */
export const ConfigTable: Story = {
args: { panel: 'new', newPanelKind: 'table' },
play: expandConfigSections,
};
/** A new bar chart panel's config pane, every section expanded. */
export const ConfigBarChart: Story = {
args: { panel: 'new', newPanelKind: 'bar-chart' },
play: expandConfigSections,
};
/** A new area chart panel's config pane, every section expanded. */
export const ConfigAreaChart: Story = {
args: { panel: 'new', newPanelKind: 'area-chart' },
play: expandConfigSections,
};
/** A new pie chart panel's config pane, every section expanded. */
export const ConfigPieChart: Story = {
args: { panel: 'new', newPanelKind: 'pie-chart' },
play: expandConfigSections,
};
/** A new histogram panel's config pane, every section expanded. */
export const ConfigHistogram: Story = {
args: { panel: 'new', newPanelKind: 'histogram' },
play: expandConfigSections,
};
/** A new list panel's config pane, every section expanded. */
export const ConfigList: Story = {
args: { panel: 'new', newPanelKind: 'list' },
play: expandConfigSections,
};
/** A new text panel's config pane, every section expanded. */
export const ConfigText: Story = {
args: { panel: 'new', newPanelKind: 'text' },
play: expandConfigSections,
};

View File

@@ -21,12 +21,14 @@ import type { PanelKind } from '../../../DashboardContainer/Panels/types/panelKi
*/
export const NEW_PANEL_KINDS = [
'time-series',
'bar-chart',
'number',
'pie-chart',
'table',
'bar-chart',
'area-chart',
'pie-chart',
'histogram',
'list',
'text',
] as const;
export type NewPanelKind = (typeof NEW_PANEL_KINDS)[number];
@@ -34,11 +36,13 @@ export type NewPanelKind = (typeof NEW_PANEL_KINDS)[number];
const KIND_BY_OPTION: Record<NewPanelKind, PanelKind> = {
'time-series': 'signoz/TimeSeriesPanel',
'bar-chart': 'signoz/BarChartPanel',
'area-chart': 'signoz/AreaChartPanel',
number: 'signoz/NumberPanel',
'pie-chart': 'signoz/PieChartPanel',
table: 'signoz/TablePanel',
histogram: 'signoz/HistogramPanel',
list: 'signoz/ListPanel',
text: 'signoz/TextPanel',
};
export const newPanelKindOf = (option: NewPanelKind): PanelKind =>

View File

@@ -285,6 +285,70 @@ export const SectionActionsMenu: Story = {
},
};
/**
* The new-panel picker opened from the toolbar: the panel kinds as tiles, with
* the target section to place the panel in.
*/
export const NewPanelPicker: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('add-panel-header', {}, { timeout: 10000 }),
);
await screen.findByTestId('panel-type-signoz/TimeSeriesPanel');
},
};
/** The new-panel picker with a kind picked, waiting on the section to confirm. */
export const NewPanelPickerTypeSelected: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('add-panel-header', {}, { timeout: 10000 }),
);
await userEvent.click(
await screen.findByTestId('panel-type-signoz/TimeSeriesPanel'),
);
},
};
/**
* The new-panel picker on a dashboard without sections, where there is no
* section to choose.
*/
export const NewPanelPickerNoSections: Story = {
args: { sectioned: false },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('add-panel-header', {}, { timeout: 10000 }),
);
await screen.findByTestId('panel-type-signoz/TimeSeriesPanel');
},
};
/**
* The new-panel picker opened from the second section's menu, targeting that
* section.
*/
export const NewPanelPickerFromSection: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const [, secondSection] = await canvas.findAllByRole(
'button',
{ name: 'Section actions' },
{ timeout: 10000 },
);
await userEvent.click(secondSection);
await userEvent.click(await screen.findByText('Add panel'));
await screen.findByTestId('panel-type-signoz/TimeSeriesPanel');
},
};
/**
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
*

View File

@@ -4,15 +4,10 @@
min-height: 0;
overflow: hidden;
.log-quick-filter-left-section {
width: 0%;
flex-shrink: 0;
}
.log-module-right-section {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
.log-explorer-query-container {
@@ -39,26 +34,4 @@
}
}
}
&.filter-visible {
.log-quick-filter-left-section {
width: 260px;
height: 100%;
overflow: visible;
min-height: 0;
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.log-module-right-section {
width: calc(100% - 260px);
}
}
}

View File

@@ -3,10 +3,10 @@ import { useQueryClient } from 'react-query';
import * as Sentry from '@sentry/react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
@@ -225,21 +225,19 @@ function LogsExplorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<EventSourceProvider>
<div
className={cx('logs-module-page', showFilters ? 'filter-visible' : '')}
<QuickFiltersLayout
className="logs-module-page"
showFilters={showFilters}
savedViewProps={{ source: SavedviewtypesSourceDTO.logs }}
quickFilterProps={{
className: 'qf-logs-explorer',
signal: SignalType.LOGS,
source: QuickFiltersSource.LOGS_EXPLORER,
handleFilterVisibilityChange,
useFieldApis: quickFilterFieldApis,
}}
>
{showFilters && (
<section className={cx('log-quick-filter-left-section')}>
<QuickFilters
className="qf-logs-explorer"
signal={SignalType.LOGS}
source={QuickFiltersSource.LOGS_EXPLORER}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={quickFilterFieldApis}
/>
</section>
)}
<section className={cx('log-module-right-section')}>
<section className="log-module-right-section">
<Toolbar
showAutoRefresh={false}
leftActions={
@@ -292,7 +290,7 @@ function LogsExplorer(): JSX.Element {
</div>
</div>
</section>
</div>
</QuickFiltersLayout>
</EventSourceProvider>
</Sentry.ErrorBoundary>
);

View File

@@ -5,18 +5,23 @@ import { Typography } from '@signozhq/ui/typography';
import styles from './EntityMetadataItem.module.scss';
interface EntityMetadataItemProps {
tooltip: string;
tooltip: ReactNode;
tooltipClassName?: string;
icon?: ReactNode;
children: ReactNode;
}
function EntityMetadataItem({
tooltip,
tooltipClassName,
icon,
children,
}: EntityMetadataItemProps): JSX.Element {
return (
<TooltipSimple title={tooltip}>
<TooltipSimple
title={tooltip}
tooltipContentProps={{ className: tooltipClassName }}
>
<span className={styles.item}>
{icon}
<Typography.Text as="span">{children}</Typography.Text>
@@ -27,6 +32,7 @@ function EntityMetadataItem({
EntityMetadataItem.defaultProps = {
icon: null,
tooltipClassName: undefined,
};
export default EntityMetadataItem;

View File

@@ -8,3 +8,7 @@
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
--typography-font-size: var(--periscope-font-size-base);
}
.tokenTooltip {
min-width: 160px;
}

View File

@@ -1,11 +1,21 @@
import { CalendarClock, Server, Timer } from '@signozhq/icons';
import {
CalendarClock,
CircleDollarSign,
Coins,
Server,
Timer,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { formatCost, formatTokens } from '../SpanHoverCard/aiUsage';
import { TraceTokenUsage } from '../TraceDetailsHeader/types';
import EntityMetadataItem from './EntityMetadataItem';
import TokenUsageTooltip from './TokenUsageTooltip';
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
import styles from './EntityMetadataRow.module.scss';
interface EntityMetadataRowProps {
@@ -16,6 +26,8 @@ interface EntityMetadataRowProps {
execTimePercent?: number;
timestamp?: string;
statusCode?: string | number;
tokens?: TraceTokenUsage;
cost?: number | null;
}
const ICON_SIZE = 14;
@@ -32,6 +44,8 @@ function EntityMetadataRow({
execTimePercent,
timestamp,
statusCode,
tokens,
cost,
}: EntityMetadataRowProps): JSX.Element {
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
const durationTooltip =
@@ -91,6 +105,25 @@ function EntityMetadataRow({
<HttpStatusBadge statusCode={statusCode} />
</EntityMetadataItem>
)}
{tokens && (
<EntityMetadataItem
tooltip={<TokenUsageTooltip tokens={tokens} />}
tooltipClassName={tooltipStyles.tooltipContent}
icon={<Coins size={ICON_SIZE} />}
>
Tokens: {formatTokens(tokens.input)} → {formatTokens(tokens.output)}
</EntityMetadataItem>
)}
{cost != null && (
<EntityMetadataItem
tooltip="Total cost"
icon={<CircleDollarSign size={ICON_SIZE} />}
>
{formatCost(cost)}
</EntityMetadataItem>
)}
</div>
);
}
@@ -102,6 +135,8 @@ EntityMetadataRow.defaultProps = {
execTimePercent: undefined,
timestamp: undefined,
statusCode: undefined,
tokens: undefined,
cost: undefined,
};
export default EntityMetadataRow;

View File

@@ -0,0 +1,38 @@
import { formatTokens } from '../SpanHoverCard/aiUsage';
import TooltipRow from '../TraceTooltip/TooltipRow';
import TooltipSection from '../TraceTooltip/TooltipSection';
import { TraceTokenUsage } from '../TraceDetailsHeader/types';
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
import styles from './EntityMetadataRow.module.scss';
interface TokenUsageTooltipProps {
tokens: TraceTokenUsage;
}
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
return (
<div className={`${tooltipStyles.body} ${styles.tokenTooltip}`}>
<TooltipSection>
<TooltipRow label="Tokens" value={formatTokens(input + output)} isTotal />
</TooltipSection>
<TooltipSection>
<TooltipRow label="Input" value={formatTokens(input)} />
<TooltipRow label="Output" value={formatTokens(output)} />
{cacheRead !== undefined && (
<TooltipRow label="Cache read" value={formatTokens(cacheRead)} />
)}
{cacheWrite !== undefined && (
<TooltipRow label="Cache write" value={formatTokens(cacheWrite)} />
)}
{reasoning !== undefined && (
<TooltipRow label="Reasoning" value={formatTokens(reasoning)} />
)}
</TooltipSection>
</div>
);
}
export default TokenUsageTooltip;

View File

@@ -0,0 +1,37 @@
import { CSSProperties } from 'react';
import { Badge } from '@signozhq/ui/badge';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getAiSpanKind } from '../utils/genAi';
import { AI_SPAN_BADGES } from './constants';
interface SpanHintBadgeProps {
span: SpanV3;
}
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
const aiSpanKind = getAiSpanKind(span);
if (aiSpanKind) {
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
return (
<Badge
variant="outline"
// The outline variant derives text, fill and border from this color.
style={{ '--badge-background': color } as CSSProperties}
data-testid={`span-hint-badge-${aiSpanKind}`}
>
{label}
</Badge>
);
}
if (!span.response_status_code) {
return null;
}
return <HttpStatusBadge statusCode={span.response_status_code} />;
}
export default SpanHintBadge;

View File

@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import SpanHintBadge from '../SpanHintBadge';
const spanWith = (
attributes: Record<string, string | number>,
responseStatusCode = '',
): SpanV3 =>
({
attributes,
resource: {},
response_status_code: responseStatusCode,
}) as unknown as SpanV3;
describe('SpanHintBadge', () => {
it.each([
[{ 'gen_ai.request.model': 'gpt-4o' }, 'LLM'],
[{ 'gen_ai.tool.name': 'search' }, 'TOOL'],
[{ 'gen_ai.agent.name': 'planner' }, 'AGENT'],
])('shows only the AI badge for %p', (attributes, label) => {
render(<SpanHintBadge span={spanWith(attributes, '200')} />);
expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.queryByText('200')).not.toBeInTheDocument();
});
it('falls back to the HTTP status code for non-AI spans', () => {
render(<SpanHintBadge span={spanWith({}, '503')} />);
expect(screen.getByText('503')).toBeInTheDocument();
});
it('renders nothing for a non-AI span without a status code', () => {
const { container } = render(<SpanHintBadge span={spanWith({})} />);
expect(container).toBeEmptyDOMElement();
});
});

View File

@@ -0,0 +1,10 @@
import { AiSpanKind } from '../utils/genAi';
export const AI_SPAN_BADGES: Record<
AiSpanKind,
{ label: string; color: string }
> = {
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
};

View File

@@ -12,37 +12,16 @@
// Hover card may be rendered while the SpanDetailsPanel is docked as
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
--tooltip-z-index: 1000;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
}
.content {
font-size: 12px;
color: var(--l1-foreground);
min-width: 220px;
max-width: 320px;
}
.name {
font-weight: 600;
margin-bottom: 4px;
}
.row {
line-height: 1.5;
color: var(--l2-foreground);
}
.preview {
// container for additional preview rows
}
.previewKey {
color: var(--l2-foreground);
}
.previewValue {
color: var(--l1-foreground);
.header {
display: flex;
flex-direction: column;
gap: 4px;
padding-bottom: 8px;
}

View File

@@ -4,6 +4,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
@@ -12,6 +14,12 @@ import { useMemo } from 'react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import { getSpanAiDetails, SpanAiDetails } from './aiUsage';
import SpanUsageBreakdown from './SpanUsageBreakdown';
import TooltipRow from '../TraceTooltip/TooltipRow';
import TooltipSection from '../TraceTooltip/TooltipSection';
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
import styles from './SpanHoverCard.module.scss';
/**
@@ -38,6 +46,7 @@ export interface SpanTooltipContentProps {
relativeStartMs: number;
durationMs: number;
previewRows?: SpanPreviewRow[];
ai?: SpanAiDetails;
}
export function SpanTooltipContent({
@@ -47,33 +56,46 @@ export function SpanTooltipContent({
relativeStartMs,
durationMs,
previewRows,
ai,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
return (
<div className={styles.content}>
<div className={styles.name} style={{ color }}>
{spanName}
</div>
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
<div className={styles.row}>
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
<div className={cx(tooltipStyles.body, styles.content)}>
<div className={styles.header}>
<Typography.Text className={tooltipStyles.title} style={{ color }}>
{spanName}
</Typography.Text>
{ai?.usage && (
<Typography.Text size="small" weight="medium">
Usage Breakdown
</Typography.Text>
)}
</div>
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
<TooltipSection>
{ai?.model && <TooltipRow label="model" value={ai.model} />}
{ai?.toolName && <TooltipRow label="tool" value={ai.toolName} />}
{ai?.agentName && <TooltipRow label="agent" value={ai.agentName} />}
<TooltipRow label="status" value={hasError ? 'error' : 'ok'} />
<TooltipRow label="start" value={`${toFixed(relativeStartMs, 2)} ms`} />
<TooltipRow
label="duration"
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
/>
</TooltipSection>
{previewRows && previewRows.length > 0 && (
<div className={styles.preview}>
<TooltipSection>
{previewRows.map((row) => (
<div
<TooltipRow
key={row.key}
className={styles.row}
data-testid={`span-hover-card-preview-${row.key}`}
>
<span className={styles.previewKey}>{row.key}:</span>{' '}
<span className={styles.previewValue}>{row.value}</span>
</div>
label={row.key}
value={row.value}
testId={`span-hover-card-preview-${row.key}`}
/>
))}
</div>
</TooltipSection>
)}
</div>
);
@@ -137,6 +159,7 @@ export function SpanHoverCard({
relativeStartMs: span.timestamp - traceStartTime,
durationMs: span.duration_nano / 1e6,
previewRows,
ai: getSpanAiDetails(span),
},
};
}, [
@@ -166,7 +189,7 @@ export function SpanHoverCard({
side="right"
align="start"
sideOffset={8}
className={styles.popover}
className={cx(tooltipStyles.tooltipContent, styles.popover)}
>
{hoverCardData && <SpanTooltipContent {...hoverCardData.tooltip} />}
</TooltipContent>

View File

@@ -0,0 +1,47 @@
import {
formatCost,
formatTokens,
getUsageTotals,
SpanAiUsage,
} from './aiUsage';
import TooltipRow from '../TraceTooltip/TooltipRow';
import TooltipSection from '../TraceTooltip/TooltipSection';
interface SpanUsageBreakdownProps {
usage: SpanAiUsage;
}
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
const { outputTokens = 0, cacheReadTokens, cacheCreationTokens, cost } = usage;
const { inputUsage, totalUsage } = getUsageTotals(usage);
return (
<>
<TooltipSection>
<TooltipRow label="Input usage" value={formatTokens(inputUsage)} isTotal />
{cacheReadTokens !== undefined && (
<TooltipRow label="cache read" value={formatTokens(cacheReadTokens)} />
)}
{cacheCreationTokens !== undefined && (
<TooltipRow
label="cache creation"
value={formatTokens(cacheCreationTokens)}
/>
)}
</TooltipSection>
<TooltipSection>
<TooltipRow
label="Output usage"
value={formatTokens(outputTokens)}
isTotal
/>
</TooltipSection>
<TooltipSection>
<TooltipRow label="Total usage" value={formatTokens(totalUsage)} isTotal />
{cost !== undefined && <TooltipRow label="cost" value={formatCost(cost)} />}
</TooltipSection>
</>
);
}
export default SpanUsageBreakdown;

View File

@@ -0,0 +1,144 @@
import { render, screen } from '@testing-library/react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getSpanAiDetails, getUsageTotals } from '../aiUsage';
import { SpanTooltipContent } from '../SpanHoverCard';
const baseProps = {
spanName: 'chat gpt-4o',
color: '#fff',
hasError: false,
relativeStartMs: 268,
durationMs: 1110,
};
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
({ attributes, resource: {} }) as unknown as SpanV3;
const usageAttributes = {
'gen_ai.usage.input_tokens': 1000,
'gen_ai.usage.output_tokens': 200,
'gen_ai.usage.cache_read.input_tokens': 600,
'gen_ai.usage.cache_creation.input_tokens': 100,
};
describe('getSpanAiDetails', () => {
it('returns undefined for a span without gen_ai attributes', () => {
expect(getSpanAiDetails(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
});
it('reads model, tool, agent, token usage and cost', () => {
expect(
getSpanAiDetails(
spanWith({
'gen_ai.request.model': 'gpt-4o',
'gen_ai.tool.name': 'search',
'gen_ai.agent.name': 'planner',
'gen_ai.usage.input_tokens': '120',
'gen_ai.usage.output_tokens': 30,
'signoz.gen_ai.usage.tokens.cost': '0.00000056',
}),
),
).toStrictEqual({
model: 'gpt-4o',
toolName: 'search',
agentName: 'planner',
usage: {
inputTokens: 120,
outputTokens: 30,
cacheReadTokens: undefined,
cacheCreationTokens: undefined,
cost: 0.00000056,
isAnthropic: false,
},
});
});
it.each<Record<string, string>>([
{ 'gen_ai.provider.name': 'Anthropic' },
{ 'gen_ai.system': 'anthropic' },
{ 'gen_ai.response.model': 'claude-sonnet-4' },
{ 'gen_ai.request.model': 'anthropic.claude-v2' },
])('detects Anthropic from %p', (attributes) => {
expect(
getSpanAiDetails(spanWith({ ...usageAttributes, ...attributes }))?.usage
?.isAnthropic,
).toBe(true);
});
});
describe('getUsageTotals', () => {
it('adds cache tokens to input for Anthropic', () => {
const usage = getSpanAiDetails(
spanWith({ ...usageAttributes, 'gen_ai.provider.name': 'anthropic' }),
)?.usage;
expect(usage && getUsageTotals(usage)).toStrictEqual({
inputUsage: 1700,
totalUsage: 1900,
});
});
it('treats input as already including cache tokens for other providers', () => {
const usage = getSpanAiDetails(
spanWith({ ...usageAttributes, 'gen_ai.provider.name': 'openai' }),
)?.usage;
expect(usage && getUsageTotals(usage)).toStrictEqual({
inputUsage: 1000,
totalUsage: 1200,
});
});
});
describe('SpanTooltipContent', () => {
it('renders status, start and duration for a non-AI span', () => {
render(<SpanTooltipContent {...baseProps} />);
expect(screen.getByText('chat gpt-4o')).toBeInTheDocument();
expect(screen.getByText('ok')).toBeInTheDocument();
expect(screen.getByText('268 ms')).toBeInTheDocument();
expect(screen.getByText('1.11 s')).toBeInTheDocument();
expect(screen.queryByText('Usage Breakdown')).not.toBeInTheDocument();
});
it('renders the usage breakdown with cache rows and cost', () => {
render(
<SpanTooltipContent
{...baseProps}
ai={{
model: 'gpt-4o',
usage: {
inputTokens: 1000,
outputTokens: 200,
cacheReadTokens: 600,
cacheCreationTokens: 100,
cost: 0.00000056,
isAnthropic: false,
},
}}
/>,
);
expect(screen.getByText('Usage Breakdown')).toBeInTheDocument();
expect(screen.getByText('1,000')).toBeInTheDocument();
expect(screen.getByText('cache read')).toBeInTheDocument();
expect(screen.getByText('600')).toBeInTheDocument();
expect(screen.getByText('cache creation')).toBeInTheDocument();
expect(screen.getByText('1,200')).toBeInTheDocument();
expect(screen.getByText('$ 0.00000056')).toBeInTheDocument();
});
it('hides cache rows and cost when the attributes are absent', () => {
render(
<SpanTooltipContent
{...baseProps}
ai={{ usage: { inputTokens: 10, outputTokens: 5, isAnthropic: false } }}
/>,
);
expect(screen.queryByText('cache read')).not.toBeInTheDocument();
expect(screen.queryByText('cache creation')).not.toBeInTheDocument();
expect(screen.queryByText('cost')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,97 @@
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { GEN_AI_KEYS } from '../utils/genAi';
export interface SpanAiUsage {
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
cost?: number;
isAnthropic: boolean;
}
export interface SpanAiDetails {
model?: string;
toolName?: string;
agentName?: string;
usage?: SpanAiUsage;
}
function getNumber(span: SpanV3, key: string): number | undefined {
const value = getSpanAttribute(span, key);
if (value === undefined || value === '') {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function isAnthropicSpan(span: SpanV3): boolean {
const provider = (
getSpanAttribute(span, GEN_AI_KEYS.providerName) ??
getSpanAttribute(span, GEN_AI_KEYS.system) ??
''
).toLowerCase();
const model = (
getSpanAttribute(span, GEN_AI_KEYS.responseModel) ??
getSpanAttribute(span, GEN_AI_KEYS.requestModel) ??
''
).toLowerCase();
return (
provider === 'anthropic' ||
model.includes('claude') ||
model.includes('anthropic')
);
}
export function getSpanAiDetails(span: SpanV3): SpanAiDetails | undefined {
const usage: SpanAiUsage = {
inputTokens: getNumber(span, GEN_AI_KEYS.inputTokens),
outputTokens: getNumber(span, GEN_AI_KEYS.outputTokens),
cacheReadTokens: getNumber(span, GEN_AI_KEYS.cacheReadTokens),
cacheCreationTokens: getNumber(span, GEN_AI_KEYS.cacheCreationTokens),
cost: getNumber(span, GEN_AI_KEYS.cost),
isAnthropic: isAnthropicSpan(span),
};
const hasUsage =
usage.inputTokens !== undefined || usage.outputTokens !== undefined;
const details: SpanAiDetails = {
model: getSpanAttribute(span, GEN_AI_KEYS.requestModel) || undefined,
toolName: getSpanAttribute(span, GEN_AI_KEYS.toolName) || undefined,
agentName: getSpanAttribute(span, GEN_AI_KEYS.agentName) || undefined,
usage: hasUsage ? usage : undefined,
};
return details.model || details.toolName || details.agentName || hasUsage
? details
: undefined;
}
/**
* Anthropic reports input_tokens without cached tokens; every other provider
* (OpenAI, Azure OpenAI, Gemini, Mistral, DeepSeek, Groq, xAI) counts cached
* tokens inside input_tokens.
*/
export function getUsageTotals(usage: SpanAiUsage): {
inputUsage: number;
totalUsage: number;
} {
const input = usage.inputTokens ?? 0;
const inputUsage = usage.isAnthropic
? input + (usage.cacheReadTokens ?? 0) + (usage.cacheCreationTokens ?? 0)
: input;
return { inputUsage, totalUsage: inputUsage + (usage.outputTokens ?? 0) };
}
export function formatTokens(value: number): string {
return value.toLocaleString('en-US');
}
export function formatCost(value: number): string {
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
}

View File

@@ -34,6 +34,15 @@
}
}
.tabs {
flex-shrink: 0;
}
.tab {
flex: unset;
padding: 0 24px;
}
.filterSection {
display: flex;
align-items: center;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import {
@@ -18,40 +18,26 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { DataSource } from 'types/common/queryBuilder';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { useTraceStore } from '../stores/traceStore';
import TraceDownloadPanel from './TraceDownloadPanel';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
import MissingSpansBanner from './MissingSpansBanner';
import { useTraceSummary } from './useTraceSummary';
import TraceDetailsTabs from './TraceDetailsTabs';
import TraceOptionsMenu from './TraceOptionsMenu';
import styles from './TraceDetailsHeader.module.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
interface FilterMetadata {
startTime: number;
endTime: number;
traceId: string;
}
export interface TraceMetadataForHeader {
startTimestampMillis: number;
endTimestampMillis: number;
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
hasMissingSpans: boolean;
totalSpansCount: number;
}
interface TraceDetailsHeaderProps {
filterMetadata: FilterMetadata;
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
isDataLoaded?: boolean;
traceMetadata?: TraceMetadataForHeader;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
}
const SKELETON_COUNT = 3;
@@ -73,16 +59,22 @@ function DetailsLoader(): JSX.Element {
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
isDataLoaded,
traceMetadata,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV3URLProps>();
const { data: summary, isLoading } = useTraceSummary();
const [showTraceDetails, setShowTraceDetails] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const [tab] = useTraceDetailsTab();
const isOverview = tab === TraceDetailsTab.Overview;
// Overview-only panels must not linger over another tab.
useEffect(() => {
setIsPreviewFieldsOpen(false);
setIsAnalyticsOpen(false);
}, [tab]);
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
@@ -116,9 +108,13 @@ function TraceDetailsHeader({
setShowTraceDetails((prev) => !prev);
}, []);
const durationMs = traceMetadata
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
: 0;
useTraceDetailsStripInfo({
totalSpansCount: summary?.totalSpansCount ?? 0,
totalErrorSpansCount: summary?.totalErrorSpansCount ?? 0,
});
const startTime = (summary?.startTimestampMillis ?? 0) / 1e3;
const endTime = (summary?.endTimestampMillis ?? 0) / 1e3;
return (
<div className={styles.wrapper}>
@@ -140,9 +136,10 @@ function TraceDetailsHeader({
badgeValue={traceID || ''}
maxCharacters={100}
/>
<TraceDetailsTabs />
</div>
)}
{isDataLoaded && (
{isOverview && onFilteredSpansChange && summary && (
<div
className={cx(
styles.filterSection,
@@ -171,9 +168,9 @@ function TraceDetailsHeader({
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
traceId={traceID || ''}
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
totalSpansCount={traceMetadata?.totalSpansCount || 0}
startTime={startTime}
endTime={endTime}
totalSpansCount={summary.totalSpansCount}
/>
</div>
</TooltipProvider>
@@ -183,9 +180,9 @@ function TraceDetailsHeader({
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
>
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
startTime={startTime}
endTime={endTime}
traceID={traceID || ''}
onFilteredSpansChange={onFilteredSpansChange}
isExpanded={isFilterExpanded}
onExpand={(): void => setIsFilterExpanded(true)}
@@ -196,28 +193,29 @@ function TraceDetailsHeader({
)}
</div>
{showTraceDetails && (
{(showTraceDetails || !isOverview) && (
<div className={styles.subHeader}>
{traceMetadata ? (
{isLoading || !summary ? (
<DetailsLoader />
) : (
<EntityMetadataRow
entity="trace"
service={{
name: traceMetadata.rootServiceName,
entryPoint: traceMetadata.rootServiceEntryPoint,
name: summary.rootServiceName,
entryPoint: summary.rootServiceEntryPoint,
}}
durationMs={durationMs}
timestamp={dayjs(traceMetadata.startTimestampMillis).format(
durationMs={summary.endTimestampMillis - summary.startTimestampMillis}
timestamp={dayjs(summary.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceMetadata.rootSpanStatusCode}
tokens={summary.ai?.tokens}
cost={summary.ai?.totalCost}
/>
) : (
<DetailsLoader />
)}
</div>
)}
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
{isOverview && summary?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}

View File

@@ -0,0 +1,44 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { TraceDetailsTab } from '../constants';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import styles from './TraceDetailsHeader.module.scss';
function TraceDetailsTabs(): JSX.Element {
const [tab, setTab] = useTraceDetailsTab();
const handleChange = (value: string): void => {
// Clicking the active item deselects it and reports ''.
if (value) {
void setTab(value as TraceDetailsTab);
}
};
return (
<ToggleGroup
type="single"
value={tab}
onChange={handleChange}
className={styles.tabs}
size="sm"
>
<ToggleGroupItem
value={TraceDetailsTab.Overview}
className={styles.tab}
data-testid="trace-details-tab-overview"
>
Overview
</ToggleGroupItem>
<ToggleGroupItem
value={TraceDetailsTab.Thread}
className={styles.tab}
data-testid="trace-details-tab-thread"
>
Thread
</ToggleGroupItem>
</ToggleGroup>
);
}
export default TraceDetailsTabs;

View File

@@ -1,9 +1,12 @@
import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
import { MOCK_TRACE_SUMMARY } from '../mockTraceSummary';
import * as traceSummaryModule from '../useTraceSummary';
import TraceDetailsHeader from '../TraceDetailsHeader';
const mockGoBack = jest.fn();
@@ -50,15 +53,9 @@ jest.mock('components/FieldsSelector', () => ({
),
}));
const baseProps = {
filterMetadata: {
startTime: 0,
endTime: 1,
traceId: 'trace-123',
},
onFilteredSpansChange: jest.fn(),
isDataLoaded: false,
};
const baseProps = { onFilteredSpansChange: jest.fn() };
const metadataText = /Missing Span/;
describe('TraceDetailsHeader – back button', () => {
beforeEach(() => {
@@ -94,8 +91,8 @@ describe('TraceDetailsHeader – action cluster', () => {
mockReplace.mockClear();
});
it('does not render the action buttons while data is still loading', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
it('does not render the action buttons without a filter handler', () => {
render(<TraceDetailsHeader />);
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
@@ -105,8 +102,8 @@ describe('TraceDetailsHeader – action cluster', () => {
).not.toBeInTheDocument();
});
it('renders Analytics and Settings action buttons once data is loaded', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
it('renders Analytics and Settings action buttons', () => {
render(<TraceDetailsHeader {...baseProps} />);
expect(
screen.getByRole('button', { name: /^analytics$/i }),
@@ -117,7 +114,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
render(<TraceDetailsHeader {...baseProps} />);
const panel = screen.getByTestId('analytics-panel');
expect(panel).toHaveAttribute('data-open', 'false');
@@ -133,68 +130,125 @@ describe('TraceDetailsHeader – action cluster', () => {
});
describe('TraceDetailsHeader – trace metadata row', () => {
// Plain prop, no API mock needed: traceMetadata is passed straight in.
const traceMetadata = {
startTimestampMillis: 1_700_000_000_000,
endTimestampMillis: 1_700_000_120_000, // +120000ms = 2 min
rootServiceName: 'inventory-frontend',
rootServiceEntryPoint: 'large-trace-root',
rootSpanStatusCode: '404',
hasMissingSpans: false,
totalSpansCount: 42,
};
afterEach(() => {
jest.restoreAllMocks();
});
it('renders the metadata (service, entry point, duration, status) when provided', () => {
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
it('shows skeletons instead of the metadata while the summary loads', () => {
jest
.spyOn(traceSummaryModule, 'useTraceSummary')
.mockReturnValue({ data: undefined, isLoading: true });
const { container } = render(<TraceDetailsHeader {...baseProps} />);
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
expect(screen.getByText('large-trace-root')).toBeInTheDocument();
expect(screen.getByText('404')).toBeInTheDocument();
// Duration goes through the shared formatter (e.g. "2 min").
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
});
it('renders the summary metadata with tokens and cost', () => {
render(<TraceDetailsHeader {...baseProps} />);
const { startTimestampMillis, endTimestampMillis } = MOCK_TRACE_SUMMARY;
const duration = getYAxisFormattedValue(
`${traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis}`,
`${endTimestampMillis - startTimestampMillis}`,
'ms',
);
expect(screen.getByText(metadataText)).toBeInTheDocument();
expect(screen.getByText(duration)).toBeInTheDocument();
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.getByText('$ 0.0421')).toBeInTheDocument();
});
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
const user = userEvent.setup({ delay: null });
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
render(<TraceDetailsHeader {...baseProps} />);
// Visible by default (showTraceDetails defaults to true).
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
expect(screen.getByText(metadataText)).toBeInTheDocument();
// Hide it.
await user.click(screen.getByRole('button', { name: /trace options/i }));
await user.click(
await screen.findByRole('menuitem', { name: /hide trace details/i }),
);
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
// Show it again.
await user.click(screen.getByRole('button', { name: /trace options/i }));
await user.click(
await screen.findByRole('menuitem', { name: /show trace details/i }),
);
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
});
it('does not render the metadata row when traceMetadata is absent', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
});
describe('TraceDetailsHeader – tabs', () => {
// In-memory URL state, so a tab switch can't leak into the next test.
const renderHeader = (): void => {
render(
<NuqsTestingAdapter hasMemory>
<TraceDetailsHeader {...baseProps} />
</NuqsTestingAdapter>,
);
};
it('shows the Overview-only sections on the Overview tab', () => {
renderHeader();
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /^analytics$/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /trace options/i }),
).toBeInTheDocument();
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
});
it('keeps only the metadata row on the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /trace options/i }),
).not.toBeInTheDocument();
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByRole('button', { name: /trace options/i }));
await user.click(
await screen.findByRole('menuitem', { name: /hide trace details/i }),
);
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('closes the Analytics panel when switching to the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByRole('button', { name: /^analytics$/i }));
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
'data-open',
'true',
);
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
'data-open',
'false',
);
});
});

View File

@@ -0,0 +1,22 @@
import { TraceSummary } from './types';
// Placeholder until GET /api/v1/traces/{id}/summary feeds the header.
export const MOCK_TRACE_SUMMARY: TraceSummary = {
startTimestampMillis: 1789627365929,
endTimestampMillis: 1789627367867,
rootServiceName: '',
rootServiceEntryPoint: 'Missing Span',
totalSpansCount: 3,
totalErrorSpansCount: 0,
hasMissingSpans: true,
ai: {
tokens: {
input: 12040,
output: 3110,
cacheRead: 8000,
cacheWrite: 1200,
reasoning: 900,
},
totalCost: 0.0421,
},
};

View File

@@ -0,0 +1,23 @@
export interface TraceTokenUsage {
input: number;
output: number;
cacheRead?: number;
cacheWrite?: number;
reasoning?: number;
}
/** Response of GET /api/v1/traces/{id}/summary. */
export interface TraceSummary {
startTimestampMillis: number;
endTimestampMillis: number;
rootServiceName: string;
rootServiceEntryPoint: string;
totalSpansCount: number;
totalErrorSpansCount: number;
hasMissingSpans: boolean;
ai?: {
tokens?: TraceTokenUsage;
// null when no span has a cost attribute
totalCost: number | null;
};
}

View File

@@ -0,0 +1,12 @@
import { MOCK_TRACE_SUMMARY } from './mockTraceSummary';
import { TraceSummary } from './types';
interface UseTraceSummaryResult {
data: TraceSummary | undefined;
isLoading: boolean;
}
// Placeholder until GET /api/v1/traces/{id}/summary is available.
export function useTraceSummary(): UseTraceSummaryResult {
return { data: MOCK_TRACE_SUMMARY, isLoading: false };
}

View File

@@ -1,10 +1,3 @@
.root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.layoutRow {
display: flex;
flex: 1;

View File

@@ -0,0 +1,500 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
Info,
TriangleAlert,
} from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import NoData from '../NoData/NoData';
import {
setTraceStoreAvailableColorByFields,
useTraceStore,
} from '../stores/traceStore';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from '../TraceWaterfall/types';
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
import { getAvailableColorByFieldNames } from '../utils';
import cx from 'classnames';
import styles from './TraceDetailsOverview.module.scss';
// Lucide chevrons for the flame/waterfall accordion headers, matching the
// span-tree chevrons in the waterfall.
function renderPanelExpandIcon({
isActive,
}: {
isActive?: boolean;
}): JSX.Element {
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
}
interface TraceDetailsOverviewProps {
filteredSpanIds: string[];
isFilterActive: boolean;
}
function TraceDetailsOverview({
filteredSpanIds,
isFilterActive,
}: TraceDetailsOverviewProps): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
}),
);
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
Set<string>
>(new Set());
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
const selectedSpanId = urlQuery.get('spanId') || undefined;
const { safeNavigate } = useSafeNavigate();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
// Tracks which traceId the load event already fired for, so navigating
// between traces (the route component stays mounted) re-fires it once each.
const dataLoadedFiredForRef = useRef('');
const colorByField = useTraceStore((s) => s.colorByField);
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
const handleSpanDetailsClose = useCallback((): void => {
urlQuery.delete('spanId');
safeNavigate({ search: urlQuery.toString() }, { replace: true });
}, [urlQuery, safeNavigate]);
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleSpanDetailsClose,
});
const allSpansRef = useRef<SpanV3[]>([]);
const deepLinkResolvedRef = useRef(false);
// Refetch only when the URL target isn't already loaded. Keeps row clicks
// and other in-window URL navigation from triggering a backend window slide.
useEffect(() => {
const spanId = urlQuery.get('spanId') || '';
if (!spanId) {
return;
}
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
if (idx !== -1) {
setSelectedSpan(allSpansRef.current[idx]);
return;
}
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
const frozenParamsRef = useRef({
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
});
const queryParams = fullDataLoadedRef.current
? frozenParamsRef.current
: {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV4({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
});
const allSpans = traceData?.payload?.spans || [];
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
useEffect(() => {
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
frozenParamsRef.current = {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
}
useEffect(() => {
allSpansRef.current = allSpans;
}, [allSpans]);
useEffect(() => {
if (
!traceId ||
dataLoadedFiredForRef.current === traceId ||
!userPrefsReady
) {
return;
}
const payload = traceData?.payload;
if (!payload?.spans?.length) {
return;
}
dataLoadedFiredForRef.current = traceId;
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
[TraceDetailEventKeys.NumServices]: numServices,
[TraceDetailEventKeys.TraceDurationMs]:
payload.endTimestampMillis - payload.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
[TraceDetailEventKeys.FlamegraphSampled]:
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
[TraceDetailEventKeys.SpanPanelVariant]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
});
}, [
traceId,
userPrefsReady,
traceData,
totalSpansCount,
colorByField,
previewFieldsCount,
logTraceEvent,
]);
// Frontend mode: expand all parents by default when full data arrives
useEffect(() => {
if (isFullDataLoaded && allSpans.length > 0) {
const parentIds = new Set(
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
);
setLocalUncollapsedNodes(parentIds);
}
}, [isFullDataLoaded, allSpans]);
// Backend mode: sync uncollapsed state from API response
useEffect(() => {
if (
!isFullDataLoaded &&
traceData &&
traceData.payload &&
traceData.payload.uncollapsedSpans
) {
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
}
}, [traceData, isFullDataLoaded]);
// Tracks whether we've already done the initial URL→selectedSpan handoff
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
useEffect(() => {
if (deepLinkResolvedRef.current) {
return;
}
if (allSpans.length === 0) {
return;
}
if (selectedSpanId) {
const span = allSpans.find((s) => s.span_id === selectedSpanId);
if (!span) {
// Span not in the current window — wait for more data (backend
// pagination) before marking resolved.
return;
}
setSelectedSpan(span);
} else {
setSelectedSpan((prev) => prev ?? allSpans[0]);
}
deepLinkResolvedRef.current = true;
}, [selectedSpanId, allSpans]);
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
// visible. Keyed on URL `spanId`(selectedSpanId).
useEffect(() => {
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
return;
}
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
if (ancestors.size === 0) {
return;
}
setLocalUncollapsedNodes((prev) => {
// Check if all ancestors are already expanded — avoid unnecessary state update
let allPresent = true;
for (const id of ancestors) {
if (!prev.has(id)) {
allPresent = false;
break;
}
}
if (allPresent) {
return prev;
}
const next = new Set(prev);
for (const id of ancestors) {
next.add(id);
}
return next;
});
}, [isFullDataLoaded, selectedSpanId, allSpans]);
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
const handleCollapseChange = (key: string): void => {
logTraceEvent(
key === 'flame'
? TraceDetailEvents.FlameGraphToggled
: TraceDetailEvents.WaterfallToggled,
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
);
setActiveKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
};
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
() =>
(getLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
);
const RIGHT_DOCK_MIN = 480;
const RIGHT_DOCK_MAX = 720;
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
const handleVariantChange = useCallback(
(newVariant: SpanDetailVariant): void => {
setLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
newVariant,
);
setSpanDetailVariant(newVariant);
},
[],
);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
defaultHeight={300}
minHeight={150}
disabled={!isWaterfallDocked}
>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId || ''}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
isFullDataLoaded={isFullDataLoaded}
localUncollapsedNodes={localUncollapsedNodes}
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
);
return (
<>
{showNoData ? (
<NoData />
) : (
<>
<div className={styles.layoutRow}>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
>
<Info
size={16}
color="var(--l2-foreground)"
style={{ cursor: 'pointer' }}
/>
</WarningPopover>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors:{' '}
{traceData.payload.totalErrorSpansCount ?? (
<span className="translate-safe">{0}</span>
)}
</span>
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{showRightDock && (
<ResizableBox
handle="left"
defaultWidth={rightDockWidth}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setRightDockWidth}
className={styles.rightDock}
>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED_RIGHT}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</ResizableBox>
)}
</div>
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
)}
</>
)}
</>
);
}
export default TraceDetailsOverview;

View File

@@ -0,0 +1,5 @@
function TraceDetailsThread(): JSX.Element {
return <div data-testid="trace-details-thread" />;
}
export default TraceDetailsThread;

View File

@@ -0,0 +1,6 @@
.root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,21 @@
import { ReactNode } from 'react';
import TraceStoreSync from '../stores/TraceStoreSync';
import styles from './TraceDetailsWrapper.module.scss';
interface TraceDetailsWrapperProps {
children: ReactNode;
}
function TraceDetailsWrapper({
children,
}: TraceDetailsWrapperProps): JSX.Element {
return (
<TraceStoreSync>
<div className={styles.root}>{children}</div>
</TraceStoreSync>
);
}
export default TraceDetailsWrapper;

View File

@@ -33,10 +33,5 @@
position: fixed;
z-index: 1000;
pointer-events: none;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
}

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import cx from 'classnames';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -17,6 +18,7 @@ import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
import { useScrollToSpan } from './hooks/useScrollToSpan';
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
import styles from './FlamegraphCanvas.module.scss';
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
@@ -203,7 +205,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
const tooltipElement = tooltipContent
? createPortal(
<div
className={styles.tooltip}
className={cx(tooltipStyles.surface, styles.tooltip)}
style={{
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),

View File

@@ -0,0 +1,34 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './TraceTooltip.module.scss';
interface TooltipRowProps {
label: string;
value: string;
isTotal?: boolean;
testId?: string;
}
function TooltipRow({
label,
value,
isTotal,
testId,
}: TooltipRowProps): JSX.Element {
return (
<div className={styles.row} data-testid={testId}>
<Typography.Text
size="small"
color={isTotal ? undefined : 'muted'}
truncate={1}
>
{label}
</Typography.Text>
<Typography.Text size="small" truncate={1} className={styles.rowValue}>
{value}
</Typography.Text>
</div>
);
}
export default TooltipRow;

View File

@@ -0,0 +1,13 @@
import { ReactNode } from 'react';
import styles from './TraceTooltip.module.scss';
interface TooltipSectionProps {
children: ReactNode;
}
function TooltipSection({ children }: TooltipSectionProps): JSX.Element {
return <div className={styles.section}>{children}</div>;
}
export default TooltipSection;

View File

@@ -0,0 +1,82 @@
// Figma `dark/shadow-dropdown` popover surface, shared by every trace-details
// tooltip so they look the same.
$surface-background: color-mix(in srgb, var(--l3-background) 60%, transparent);
$surface-border-color: var(--l2-border);
$surface-radius: var(--radius-2);
$surface-padding: var(--spacing-4);
$surface-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
// For plain elements, e.g. the flamegraph's cursor-following portal.
.surface {
background-color: $surface-background;
border: 1px solid $surface-border-color;
border-radius: $surface-radius;
padding: $surface-padding;
box-shadow: $surface-shadow;
backdrop-filter: blur(20px);
}
// For @signozhq/ui Tooltip content: it paints both the bubble and the arrow
// from these variables.
.tooltipContent {
--tooltip-background: #{$surface-background};
--tooltip-border-color: #{$surface-border-color};
--tooltip-border-radius: #{$surface-radius};
--tooltip-padding: #{$surface-padding};
--tooltip-box-shadow: #{$surface-shadow};
backdrop-filter: blur(20px);
}
.body {
display: flex;
flex-direction: column;
letter-spacing: var(--paragraph-small-400-letter-spacing);
color: var(--l1-foreground-hover);
}
.title {
--typography-font-family: var(--code-small-500-font-family);
--typography-font-size: var(--code-small-500-font-size);
--typography-font-weight: var(--code-small-500-font-weight);
--typography-line-height: var(--code-small-500-line-height);
letter-spacing: var(--code-small-500-letter-spacing);
word-break: break-all;
}
.section {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 0;
border-top: 1px solid;
border-image: linear-gradient(
90deg,
var(--l3-background-transparent),
var(--l3-border),
var(--l3-background-transparent)
)
1;
&:first-child {
padding-top: 0;
border-top: none;
}
&:last-child {
padding-bottom: 0;
}
}
.row {
display: flex;
justify-content: space-between;
gap: 16px;
min-width: 0;
}
.rowValue {
flex-shrink: 0;
max-width: 60%;
text-align: right;
font-variant-numeric: tabular-nums;
}

View File

@@ -217,8 +217,9 @@
.statusCell {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
cursor: pointer;
padding-left: var(--spacing-2);
--badge-border-radius: 2px;
--badge-padding: 3px 6px;
@@ -408,6 +409,11 @@
}
}
.aiSpanIcon {
flex-shrink: 0;
margin: 0 4px;
}
.treeLabel {
color: var(--l1-foreground);
font-family: 'Inter';

View File

@@ -25,7 +25,6 @@ import {
} from '@tanstack/react-table';
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
import cx from 'classnames';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
@@ -33,7 +32,13 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
import {
ChevronDown,
ChevronRight,
Link,
ListPlus,
Sparkle,
} from '@signozhq/icons';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
@@ -44,6 +49,8 @@ import { toFixed } from 'utils/toFixed';
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
import SpanHintBadge from '../../../SpanHintBadge/SpanHintBadge';
import { isAiSpan } from '../../../utils/genAi';
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from '../../types';
@@ -328,14 +335,23 @@ const SpanOverview = memo(function SpanOverview({
</span>
{/* Colored service dot */}
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
style={
{
'--service-dot-color': effectiveColor,
} as React.CSSProperties
}
/>
{isAiSpan(span) ? (
<Sparkle
size={10}
color={effectiveColor}
fill={effectiveColor}
className={styles.aiSpanIcon}
/>
) : (
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
style={
{
'--service-dot-color': effectiveColor,
} as React.CSSProperties
}
/>
)}
{/* Span name + service name */}
<span className={styles.treeLabel}>
@@ -971,9 +987,7 @@ function Success(props: ISuccessProps): JSX.Element {
onMouseLeave={(): void => applyHoverClass(null)}
onClick={(): void => handleSpanClick(span)}
>
{span.response_status_code && (
<HttpStatusBadge statusCode={span.response_status_code} />
)}
<SpanHintBadge span={span} />
</div>
);
})}

View File

@@ -53,3 +53,8 @@ export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
);
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];
export enum TraceDetailsTab {
Overview = 'overview',
Thread = 'thread',
}

View File

@@ -0,0 +1,15 @@
import { parseAsStringEnum, useQueryState, UseQueryStateReturn } from 'nuqs';
import { TraceDetailsTab } from '../constants';
export function useTraceDetailsTab(): UseQueryStateReturn<
TraceDetailsTab,
TraceDetailsTab
> {
return useQueryState(
'tab',
parseAsStringEnum(Object.values(TraceDetailsTab)).withDefault(
TraceDetailsTab.Overview,
),
);
}

View File

@@ -1,89 +1,18 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
Info,
TriangleAlert,
} from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { useCallback, useEffect, useState } from 'react';
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
import NoData from './NoData/NoData';
import TraceStoreSync from './stores/TraceStoreSync';
import { useTraceStore } from './stores/traceStore';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
import { TraceDetailsTab } from './constants';
import { useTraceDetailsTab } from './hooks/useTraceDetailsTab';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from './TraceWaterfall/types';
import { getAncestorSpanIds } from './TraceWaterfall/utils';
import { getAvailableColorByFieldNames } from './utils';
import cx from 'classnames';
import styles from './TraceDetailsV3.module.scss';
// Lucide chevrons for the flame/waterfall accordion headers, matching the
// span-tree chevrons in the waterfall.
function renderPanelExpandIcon({
isActive,
}: {
isActive?: boolean;
}): JSX.Element {
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
}
import TraceDetailsOverview from './TraceDetailsOverview/TraceDetailsOverview';
import TraceDetailsThread from './TraceDetailsThread/TraceDetailsThread';
import TraceDetailsWrapper from './TraceDetailsWrapper/TraceDetailsWrapper';
function TraceDetailsV3(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
}),
);
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
Set<string>
>(new Set());
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
const [tab] = useTraceDetailsTab();
const isOverview = tab === TraceDetailsTab.Overview;
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState(false);
const selectedSpanId = urlQuery.get('spanId') || undefined;
const { safeNavigate } = useSafeNavigate();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
// Tracks which traceId the load event already fired for, so navigating
// between traces (the route component stays mounted) re-fires it once each.
const dataLoadedFiredForRef = useRef('');
const colorByField = useTraceStore((s) => s.colorByField);
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
const handleSpanDetailsClose = useCallback((): void => {
urlQuery.delete('spanId');
safeNavigate({ search: urlQuery.toString() }, { replace: true });
}, [urlQuery, safeNavigate]);
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean): void => {
setFilteredSpanIds(spanIds);
@@ -92,453 +21,26 @@ function TraceDetailsV3(): JSX.Element {
[],
);
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleSpanDetailsClose,
});
const allSpansRef = useRef<SpanV3[]>([]);
const deepLinkResolvedRef = useRef(false);
// Refetch only when the URL target isn't already loaded. Keeps row clicks
// and other in-window URL navigation from triggering a backend window slide.
// The filter query lives in Filters' local state, which unmounts with the
// Overview tab; drop its result too so both start empty on return.
useEffect(() => {
const spanId = urlQuery.get('spanId') || '';
if (!spanId) {
return;
if (!isOverview) {
handleFilteredSpansChange([], false);
}
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
if (idx !== -1) {
setSelectedSpan(allSpansRef.current[idx]);
return;
}
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
const frozenParamsRef = useRef({
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
});
const queryParams = fullDataLoadedRef.current
? frozenParamsRef.current
: {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV4({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
});
const allSpans = traceData?.payload?.spans || [];
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
frozenParamsRef.current = {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
}
useEffect(() => {
allSpansRef.current = allSpans;
}, [allSpans]);
useEffect(() => {
if (
!traceId ||
dataLoadedFiredForRef.current === traceId ||
!userPrefsReady
) {
return;
}
const payload = traceData?.payload;
if (!payload?.spans?.length) {
return;
}
dataLoadedFiredForRef.current = traceId;
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
[TraceDetailEventKeys.NumServices]: numServices,
[TraceDetailEventKeys.TraceDurationMs]:
payload.endTimestampMillis - payload.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
[TraceDetailEventKeys.FlamegraphSampled]:
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
[TraceDetailEventKeys.SpanPanelVariant]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
});
}, [
traceId,
userPrefsReady,
traceData,
totalSpansCount,
colorByField,
previewFieldsCount,
logTraceEvent,
]);
// Frontend mode: expand all parents by default when full data arrives
useEffect(() => {
if (isFullDataLoaded && allSpans.length > 0) {
const parentIds = new Set(
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
);
setLocalUncollapsedNodes(parentIds);
}
}, [isFullDataLoaded, allSpans]);
// Backend mode: sync uncollapsed state from API response
useEffect(() => {
if (
!isFullDataLoaded &&
traceData &&
traceData.payload &&
traceData.payload.uncollapsedSpans
) {
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
}
}, [traceData, isFullDataLoaded]);
// Tracks whether we've already done the initial URL→selectedSpan handoff
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
useEffect(() => {
if (deepLinkResolvedRef.current) {
return;
}
if (allSpans.length === 0) {
return;
}
if (selectedSpanId) {
const span = allSpans.find((s) => s.span_id === selectedSpanId);
if (!span) {
// Span not in the current window — wait for more data (backend
// pagination) before marking resolved.
return;
}
setSelectedSpan(span);
} else {
setSelectedSpan((prev) => prev ?? allSpans[0]);
}
deepLinkResolvedRef.current = true;
}, [selectedSpanId, allSpans]);
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
// visible. Keyed on URL `spanId`(selectedSpanId).
useEffect(() => {
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
return;
}
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
if (ancestors.size === 0) {
return;
}
setLocalUncollapsedNodes((prev) => {
// Check if all ancestors are already expanded — avoid unnecessary state update
let allPresent = true;
for (const id of ancestors) {
if (!prev.has(id)) {
allPresent = false;
break;
}
}
if (allPresent) {
return prev;
}
const next = new Set(prev);
for (const id of ancestors) {
next.add(id);
}
return next;
});
}, [isFullDataLoaded, selectedSpanId, allSpans]);
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
const handleCollapseChange = (key: string): void => {
logTraceEvent(
key === 'flame'
? TraceDetailEvents.FlameGraphToggled
: TraceDetailEvents.WaterfallToggled,
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
);
setActiveKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
};
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
() =>
(getLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
);
const RIGHT_DOCK_MIN = 480;
const RIGHT_DOCK_MAX = 720;
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
const handleVariantChange = useCallback(
(newVariant: SpanDetailVariant): void => {
setLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
newVariant,
);
setSpanDetailVariant(newVariant);
},
[],
);
const filterMetadata = useMemo(
() => ({
startTime: (traceData?.payload?.startTimestampMillis || 0) / 1e3,
endTime: (traceData?.payload?.endTimestampMillis || 0) / 1e3,
traceId: traceId || '',
}),
[
traceData?.payload?.startTimestampMillis,
traceData?.payload?.endTimestampMillis,
traceId,
],
);
const traceMetadataForHeader = useMemo(():
| TraceMetadataForHeader
| undefined => {
const payload = traceData?.payload;
if (!payload) {
return undefined;
}
const rootSpan = payload.spans?.find((s) => s.level === 0);
return {
startTimestampMillis: payload.startTimestampMillis,
endTimestampMillis: payload.endTimestampMillis,
rootServiceName: payload.rootServiceName,
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
hasMissingSpans: payload.hasMissingSpans || false,
totalSpansCount: payload.totalSpansCount || 0,
};
}, [traceData?.payload]);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
defaultHeight={300}
minHeight={150}
disabled={!isWaterfallDocked}
>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId || ''}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
isFullDataLoaded={isFullDataLoaded}
localUncollapsedNodes={localUncollapsedNodes}
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
);
}, [isOverview, handleFilteredSpansChange]);
return (
<TraceStoreSync availableColorByFields={availableColorByFields}>
<div className={styles.root}>
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
traceMetadata={traceMetadataForHeader}
<TraceDetailsWrapper>
<TraceDetailsHeader onFilteredSpansChange={handleFilteredSpansChange} />
{isOverview ? (
<TraceDetailsOverview
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
{showNoData ? (
<NoData />
) : (
<>
<div className={styles.layoutRow}>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
>
<Info
size={16}
color="var(--l2-foreground)"
style={{ cursor: 'pointer' }}
/>
</WarningPopover>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors:{' '}
{traceData.payload.totalErrorSpansCount ?? (
<span className="translate-safe">{0}</span>
)}
</span>
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall')
? waterfallChildren
: null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{showRightDock && (
<ResizableBox
handle="left"
defaultWidth={rightDockWidth}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setRightDockWidth}
className={styles.rightDock}
>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED_RIGHT}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</ResizableBox>
)}
</div>
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
)}
</>
)}
</div>
</TraceStoreSync>
) : (
<TraceDetailsThread />
)}
</TraceDetailsWrapper>
);
}

View File

@@ -4,36 +4,28 @@ import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { useAppContext } from 'providers/App/App';
import {
setTraceStoreAvailableColorByFields,
setTraceStoreCallbacks,
setTraceStoreUserPreferences,
} from './traceStore';
interface TraceStoreSyncProps {
availableColorByFields: string[] | undefined;
children: ReactNode;
}
/**
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
* from AppContext, and the user-pref mutation hook) into the Zustand store.
* Bridges React-managed inputs (`userPreferences` from AppContext and the
* user-pref mutation hook) into the Zustand store. `availableColorByFields`
* is pushed by TraceDetailsOverview, which owns the span data.
*
* Renders nothing until `userPreferences` resolves so the flamegraph never
* paints with the default colour first and then swaps to the user's
* persisted choice. AppProvider fires the prefs query as soon as the user
* is logged in, so this gate is usually already settled by mount time.
*/
function TraceStoreSync({
availableColorByFields,
children,
}: TraceStoreSyncProps): JSX.Element | null {
function TraceStoreSync({ children }: TraceStoreSyncProps): JSX.Element | null {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
useEffect(() => {
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
useEffect(() => {
setTraceStoreUserPreferences(userPreferences ?? null);
}, [userPreferences]);

View File

@@ -37,7 +37,7 @@ interface TraceStoreState {
availableColorByOptions: ColorByOption[];
previewFields: TelemetryFieldKey[];
// --- Setters used only by TraceStoreSync ---
// --- Setters used only by TraceStoreSync and TraceDetailsOverview ---
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
setCallbacks: (callbacks: {

View File

@@ -0,0 +1,32 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { AiSpanKind, getAiSpanKind, isAiSpan } from '../genAi';
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
({ attributes, resource: {} }) as unknown as SpanV3;
describe('getAiSpanKind', () => {
it.each<[Record<string, string | number>, AiSpanKind]>([
[{ 'gen_ai.request.model': 'gpt-4o' }, AiSpanKind.Llm],
[{ 'gen_ai.usage.input_tokens': 0 }, AiSpanKind.Llm],
[{ 'gen_ai.usage.reasoning.output_tokens': 12 }, AiSpanKind.Llm],
[{ 'gen_ai.tool.name': 'search' }, AiSpanKind.Tool],
[{ 'gen_ai.agent.name': 'planner' }, AiSpanKind.Agent],
[
{ 'gen_ai.tool.name': 'search', 'gen_ai.request.model': 'gpt-4o' },
AiSpanKind.Tool,
],
[
{ 'gen_ai.agent.name': 'planner', 'gen_ai.usage.input_tokens': 10 },
AiSpanKind.Agent,
],
])('classifies %p as %s', (attributes, kind) => {
expect(getAiSpanKind(spanWith(attributes))).toBe(kind);
});
it('returns undefined for non-AI spans and empty values', () => {
expect(getAiSpanKind(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
expect(getAiSpanKind(spanWith({ 'gen_ai.tool.name': '' }))).toBeUndefined();
expect(isAiSpan(spanWith({}))).toBe(false);
});
});

View File

@@ -0,0 +1,54 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
export const GEN_AI_KEYS = {
providerName: 'gen_ai.provider.name',
system: 'gen_ai.system',
requestModel: 'gen_ai.request.model',
responseModel: 'gen_ai.response.model',
toolName: 'gen_ai.tool.name',
agentName: 'gen_ai.agent.name',
inputTokens: 'gen_ai.usage.input_tokens',
outputTokens: 'gen_ai.usage.output_tokens',
cacheReadTokens: 'gen_ai.usage.cache_read.input_tokens',
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
cost: 'signoz.gen_ai.usage.tokens.cost',
} as const;
export enum AiSpanKind {
Llm = 'llm',
Tool = 'tool',
Agent = 'agent',
}
const LLM_KEYS = [
GEN_AI_KEYS.requestModel,
GEN_AI_KEYS.inputTokens,
GEN_AI_KEYS.outputTokens,
GEN_AI_KEYS.cacheReadTokens,
GEN_AI_KEYS.cacheCreationTokens,
GEN_AI_KEYS.reasoningTokens,
];
function hasAttribute(span: SpanV3, key: string): boolean {
const value = span.attributes?.[key] ?? span.resource?.[key];
return value !== undefined && value !== null && value !== '';
}
/** Tool and agent spans may also carry model or usage keys, so they win over LLM. */
export function getAiSpanKind(span: SpanV3): AiSpanKind | undefined {
if (hasAttribute(span, GEN_AI_KEYS.toolName)) {
return AiSpanKind.Tool;
}
if (hasAttribute(span, GEN_AI_KEYS.agentName)) {
return AiSpanKind.Agent;
}
if (LLM_KEYS.some((key) => hasAttribute(span, key))) {
return AiSpanKind.Llm;
}
return undefined;
}
export function isAiSpan(span: SpanV3): boolean {
return getAiSpanKind(span) !== undefined;
}

View File

@@ -5,6 +5,7 @@ import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
@@ -285,6 +286,7 @@ function TracesExplorer(): JSX.Element {
<QuickFiltersLayout
className="trace-explorer-page"
showFilters={isOpen}
savedViewProps={{ source: SavedviewtypesSourceDTO.traces }}
quickFilterProps={{
className: 'qf-traces-explorer',
source: QuickFiltersSource.TRACES_EXPLORER,

View File

@@ -10,6 +10,23 @@ import (
)
func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/traces/{traceID}/summary", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetTraceSummary),
handler.OpenAPIDef{
ID: "GetTraceSummary",
Tags: []string{"tracedetail"},
Summary: "Get summary for a trace",
Description: "Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.",
Response: new(spantypes.GettableTraceSummary),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
handler.OpenAPIDef{

View File

@@ -6,7 +6,9 @@ import (
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -18,6 +20,22 @@ func NewHandler(module tracedetail.Module) tracedetail.Handler {
return &handler{module: module}
}
func (h *handler) GetTraceSummary(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
stats, err := h.module.GetTraceSummary(r.Context(), valuer.MustNewUUID(claims.OrgID), mux.Vars(r)["traceID"])
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, spantypes.NewGettableTraceSummary(stats))
}
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableWaterfall)
if err := binding.JSON.BindBody(r.Body, req); err != nil {

View File

@@ -8,6 +8,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"go.opentelemetry.io/otel/metric"
)
@@ -39,26 +40,41 @@ func NewModule(traceStore spantypes.TraceStore, providerSettings factory.Provide
return m
}
func (m *module) GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error) {
bounds, err := m.store.GetTraceBounds(ctx, traceID)
if err != nil {
return nil, err
}
stats, err := m.store.GetTraceStats(ctx, orgID, traceID, bounds)
if err != nil {
return nil, err
}
if stats.TotalSpans == 0 {
return nil, spantypes.ErrTraceNotFound
}
return stats, nil
}
// GetWaterfallV4 is the OOM-safe V4 waterfall.
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
// minimal fields for all spans to build the tree, then full fields for the
// visible window only. Aggregations are not returned.
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
bounds, err := m.store.GetTraceBounds(ctx, traceID)
if err != nil {
return nil, err
}
if summary.NumSpans > uint64(m.config.Waterfall.MaxLimitToSelectAllSpans) {
if bounds.NumSpans > uint64(m.config.Waterfall.MaxLimitToSelectAllSpans) {
attrs := metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed))
m.metrics.waterfallRequestCount.Add(ctx, 1, attrs)
m.metrics.waterfallSpanCount.Add(ctx, int64(summary.NumSpans), attrs)
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, summary.Start, summary.End)
m.metrics.waterfallSpanCount.Add(ctx, int64(bounds.NumSpans), attrs)
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, bounds.Start, bounds.End)
}
return m.getFullWaterfall(ctx, traceID, summary)
return m.getFullWaterfall(ctx, traceID, bounds)
}
func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *spantypes.TraceSummary) (*spantypes.GettableWaterfallTrace, error) {
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
func (m *module) getFullWaterfall(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) (*spantypes.GettableWaterfallTrace, error) {
spanItems, err := m.store.GetTraceSpans(ctx, traceID, bounds)
if err != nil {
return nil, err
}
@@ -78,24 +94,24 @@ func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *
}
func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
bounds, err := m.store.GetTraceBounds(ctx, traceID)
if err != nil {
return nil, err
}
traceDurationNs := uint64(summary.End.UnixNano()) - uint64(summary.Start.UnixNano())
traceDurationNs := uint64(bounds.End.UnixNano()) - uint64(bounds.Start.UnixNano())
results := make([]spantypes.SpanAggregationResult, 0, len(req.Aggregations))
for _, agg := range req.Aggregations {
result := spantypes.SpanAggregationResult{Field: agg.Field, Aggregation: agg.Aggregation}
switch agg.Aggregation {
case spantypes.SpanAggregationSpanCount:
result.Value, err = m.store.GetSpanCountByField(ctx, traceID, summary, agg.Field)
result.Value, err = m.store.GetSpanCountByField(ctx, traceID, bounds, agg.Field)
if err != nil {
return nil, err
}
case spantypes.SpanAggregationDuration:
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, summary, agg.Field)
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, bounds, agg.Field)
if err2 != nil {
return nil, err2
}
@@ -104,7 +120,7 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
result.Value[k] = ns / 1_000_000
}
case spantypes.SpanAggregationExecutionTimePercentage:
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, summary, agg.Field)
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, bounds, agg.Field)
if err2 != nil {
return nil, err2
}
@@ -121,15 +137,15 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
}
func (m *module) GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
bounds, err := m.store.GetTraceBounds(ctx, traceID)
if err != nil {
return nil, err
}
if summary.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
return m.getFullFlamegraph(ctx, traceID, summary, selectFields)
if bounds.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
return m.getFullFlamegraph(ctx, traceID, bounds, selectFields)
}
m.metrics.flamegraphRequestCount.Add(ctx, 1, metric.WithAttributes(attrResponseType.String(attrResponseTypeSampled)))
return m.getWindowedFlamegraph(ctx, traceID, selectedSpanID, summary, selectFields)
return m.getWindowedFlamegraph(ctx, traceID, selectedSpanID, bounds, selectFields)
}
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
@@ -173,8 +189,8 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
), nil
}
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, nil)
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, bounds.Start, bounds.End, nil)
if err != nil {
return nil, err
}
@@ -187,8 +203,8 @@ func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary
}
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary.Start, summary.End)
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, bounds *spantypes.TraceBounds, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, bounds.Start, bounds.End)
if err != nil {
return nil, err
}
@@ -207,7 +223,7 @@ func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpa
return nil, spantypes.ErrTraceNotFound
}
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, spantypes.FlamegraphWindowSpanIDs(selectedSpans))
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, bounds.Start, bounds.End, spantypes.FlamegraphWindowSpanIDs(selectedSpans))
if err != nil {
return nil, err
}

View File

@@ -4,15 +4,22 @@ import (
"context"
"database/sql"
"fmt"
"slices"
"time"
sqlbuilder "github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
@@ -38,13 +45,21 @@ type spanDurationRow struct {
type traceStore struct {
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
storage qbtypes.Storage
flagger flagger.Flagger
}
func NewTraceStore(ts telemetrystore.TelemetryStore) *traceStore {
return &traceStore{telemetryStore: ts}
func NewTraceStore(ts telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, fl flagger.Flagger) *traceStore {
return &traceStore{
telemetryStore: ts,
metadataStore: metadataStore,
storage: tracestelemetryschema.NewStorage(),
flagger: fl,
}
}
func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*spantypes.TraceSummary, error) {
func (s *traceStore) GetTraceBounds(ctx context.Context, traceID string) (*spantypes.TraceBounds, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("trace_id", "min(start) AS start", "max(end) AS end", "sum(num_spans) AS num_spans")
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceSummaryTable))
@@ -52,20 +67,148 @@ func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*span
sb.GroupBy("trace_id")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var summary spantypes.TraceSummary
var bounds spantypes.TraceBounds
err := s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(
&summary.TraceID, &summary.Start, &summary.End, &summary.NumSpans,
&bounds.TraceID, &bounds.Start, &bounds.End, &bounds.NumSpans,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, spantypes.ErrTraceNotFound
}
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace summary")
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace bounds")
}
return &summary, nil
return &bounds, nil
}
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary) ([]spantypes.StorableSpan, error) {
func (s *traceStore) GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds) (*spantypes.TraceStats, error) {
table := fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable)
spans := sqlbuilder.NewSelectBuilder()
genAIColumns, err := s.genAISpanColumns(ctx, orgID, bounds, spans)
if err != nil {
return nil, err
}
// Spans whose parent is absent from the trace; these also count as roots and surface as "Missing Span".
ids := sqlbuilder.NewSelectBuilder()
ids.Select("span_id")
ids.From(table)
ids.Where(
ids.E("trace_id", traceID),
ids.GE("ts_bucket_start", bounds.Start.Unix()-1800),
ids.LE("ts_bucket_start", bounds.End.Unix()),
)
missingParent := fmt.Sprintf("parent_span_id <> '' AND parent_span_id GLOBAL NOT IN (%s)", spans.Var(ids))
spans.Select(
"toUnixTimestamp64Nano(timestamp) AS span_start_ns",
"span_start_ns + duration_nano AS span_end_ns",
"span_id",
"has_error",
"("+missingParent+") AS has_missing_parent",
"(parent_span_id = '' OR has_missing_parent) AS is_root",
"if(parent_span_id = '', name, 'Missing Span') AS root_name",
"if(parent_span_id = '', "+colServiceName+", '') AS root_service",
"if(parent_span_id = '', response_status_code, '') AS root_status_code",
)
spans.SelectMore(genAIColumns...)
spans.From(table)
spans.Where(
spans.E("trace_id", traceID),
spans.GE("ts_bucket_start", bounds.Start.Unix()-1800),
spans.LE("ts_bucket_start", bounds.End.Unix()),
)
// A span can be ingested more than once; keep one row per span_id.
spans.SQL("LIMIT 1 BY span_id")
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"toUInt64(min(span_start_ns)) AS start_ns",
"toUInt64(max(span_end_ns)) AS end_ns",
"count() AS total_spans",
"countIf(has_error) AS total_error_spans",
"countIf(has_missing_parent) > 0 AS has_missing_spans",
"argMinIf(root_service, (span_start_ns, root_name), is_root) AS root_service_name",
"argMinIf(root_name, (span_start_ns, root_name), is_root) AS root_entry_point",
"argMinIf(root_status_code, (span_start_ns, root_name), is_root) AS root_span_status_code",
"countIf(is_gen_ai) AS gen_ai_span_count",
"toUInt64(coalesce(sum(input_tokens_value), 0)) AS input_tokens",
"toUInt64(coalesce(sum(output_tokens_value), 0)) AS output_tokens",
"toUInt64(coalesce(sum(cache_read_tokens_value), 0)) AS cache_read_tokens",
"toUInt64(coalesce(sum(cache_write_tokens_value), 0)) AS cache_write_tokens",
"toUInt64(coalesce(sum(reasoning_tokens_value), 0)) AS reasoning_tokens",
"sum(total_cost_value) AS total_cost",
)
sb.From(sb.BuilderAs(spans, "spans"))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var stats spantypes.TraceStats
err = s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(
&stats.StartNs, &stats.EndNs, &stats.TotalSpans, &stats.TotalErrorSpans, &stats.HasMissingSpans,
&stats.RootServiceName, &stats.RootEntryPoint, &stats.RootSpanStatusCode, &stats.GenAISpanCount,
&stats.Tokens.Input, &stats.Tokens.Output, &stats.Tokens.CacheRead, &stats.Tokens.CacheWrite, &stats.Tokens.Reasoning,
&stats.TotalCost,
)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace stats")
}
return &stats, nil
}
// genAISpanColumns returns the gen_ai columns aggregated per span, resolved across attribute evolutions.
func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bounds *spantypes.TraceBounds, sb *sqlbuilder.SelectBuilder) ([]string, error) {
attributeKey := func(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}
}
values := []struct{ key, alias string }{
{aiobservabilitytypes.GenAIUsageInputTokens, "input_tokens_value"},
{aiobservabilitytypes.GenAIUsageOutputTokens, "output_tokens_value"},
{aiobservabilitytypes.GenAIUsageCacheReadInputTokens, "cache_read_tokens_value"},
{aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, "cache_write_tokens_value"},
{aiobservabilitytypes.GenAIUsageReasoningOutputTokens, "reasoning_tokens_value"},
{aiobservabilitytypes.SignozGenAITotalCost, "total_cost_value"},
}
names := slices.Clone(aiobservabilitytypes.GenAISpanGateKeys)
for _, value := range values {
names = append(names, value.key)
}
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(names))
for _, name := range names {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact})
}
keys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, s.flagger, selectors))
if err != nil {
return nil, err
}
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
gate := make([]string, 0, len(aiobservabilitytypes.GenAISpanGateKeys))
for _, name := range aiobservabilitytypes.GenAISpanGateKeys {
conds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name, telemetrytypes.FieldDataTypeString), qbtypes.FilterOperatorExists, nil, keys, false, sb)
if err != nil {
return nil, err
}
gate = append(gate, conds...)
}
columns := []string{sb.Or(gate...) + " AS is_gen_ai"}
for _, value := range values {
// lookup by number, the type metadata stores numeric attributes under; float64 is only the output cast
expr, err := querybuilder.ResolveColumn(ctx, q, s.storage, attributeKey(value.key, telemetrytypes.FieldDataTypeNumber), telemetrytypes.FieldDataTypeFloat64, keys)
if err != nil {
return nil, err
}
// a materialized column name carries `$$`, which Build would otherwise unescape
columns = append(columns, sqlbuilder.Escape(expr)+" AS "+value.alias)
}
return columns, nil
}
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) ([]spantypes.StorableSpan, error) {
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder
query := fmt.Sprintf(`
SELECT DISTINCT ON (span_id)
@@ -85,8 +228,8 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
err := s.telemetryStore.ClickhouseDB().Select(
ctx, &spanItems, query,
traceID,
summary.Start.Unix()-1800,
summary.End.Unix(),
bounds.Start.Unix()-1800,
bounds.End.Unix(),
)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace spans")
@@ -196,7 +339,7 @@ func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, sta
return spans, nil
}
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
fieldExpr, err := buildFieldExpr(fieldKey)
if err != nil {
return nil, err
@@ -206,8 +349,8 @@ func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, su
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
sb.Where(
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
sb.LE("ts_bucket_start", summary.End.Unix()),
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
sb.LE("ts_bucket_start", bounds.End.Unix()),
"notEmpty("+fieldExpr+")",
)
sb.GroupBy("field_value")
@@ -224,7 +367,7 @@ func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, su
return result, nil
}
func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
fieldExpr, err := buildFieldExpr(fieldKey)
if err != nil {
return nil, err
@@ -240,8 +383,8 @@ func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string,
allSpansSB.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
allSpansSB.Where(
allSpansSB.E("trace_id", traceID),
allSpansSB.GE("ts_bucket_start", summary.Start.Unix()-1800),
allSpansSB.LE("ts_bucket_start", summary.End.Unix()),
allSpansSB.GE("ts_bucket_start", bounds.Start.Unix()-1800),
allSpansSB.LE("ts_bucket_start", bounds.End.Unix()),
"notEmpty(field_value)",
)
allSpansSB.OrderByAsc("timestamp")

File diff suppressed because one or more lines are too long

View File

@@ -6,10 +6,12 @@ import (
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// Handler exposes HTTP handlers for trace detail APIs.
type Handler interface {
GetTraceSummary(http.ResponseWriter, *http.Request)
GetWaterfallV4(http.ResponseWriter, *http.Request)
GetTraceAggregations(http.ResponseWriter, *http.Request)
GetFlamegraph(http.ResponseWriter, *http.Request)
@@ -17,6 +19,7 @@ type Handler interface {
// Module defines the business logic for trace detail operations.
type Module interface {
GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error)
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)

View File

@@ -161,7 +161,7 @@ func NewModules(
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, fl), providerSettings, config.TraceDetail),
SpanMapper: spanMapper,
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
Tag: tagModule,

View File

@@ -15,11 +15,12 @@ var (
aiobservabilitytypes.GenAIAgentName: genAIAttribute(aiobservabilitytypes.GenAIAgentName, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIProviderName: genAIAttribute(aiobservabilitytypes.GenAIProviderName, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIUsageInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageInputTokens, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.GenAIUsageOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageOutputTokens, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.GenAIUsageCacheReadInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheReadInputTokens, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.GenAIUsageCacheCreationInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.SignozGenAITotalCost: genAIAttribute(aiobservabilitytypes.SignozGenAITotalCost, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.GenAIUsageInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageInputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.GenAIUsageOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageOutputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.GenAIUsageCacheReadInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheReadInputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.GenAIUsageCacheCreationInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.GenAIUsageReasoningOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageReasoningOutputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.SignozGenAITotalCost: genAIAttribute(aiobservabilitytypes.SignozGenAITotalCost, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.GenAIInputMessages: genAIAttribute(aiobservabilitytypes.GenAIInputMessages, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIOutputMessages: genAIAttribute(aiobservabilitytypes.GenAIOutputMessages, telemetrytypes.FieldDataTypeString),

View File

@@ -15,6 +15,7 @@ const (
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
GenAIUsageReasoningOutputTokens = "gen_ai.usage.reasoning.output_tokens"
GenAIInputMessages = "gen_ai.input.messages"
GenAIOutputMessages = "gen_ai.output.messages"

View File

@@ -31,12 +31,13 @@ type SpanMapperStore interface {
// TraceStore defines the data access interface for trace detail queries.
type TraceStore interface {
GetTraceSummary(ctx context.Context, traceID string) (*TraceSummary, error)
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
GetTraceBounds(ctx context.Context, traceID string) (*TraceBounds, error)
GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds) (*TraceStats, error)
GetTraceSpans(ctx context.Context, traceID string, bounds *TraceBounds) ([]StorableSpan, error)
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
GetSpanCountByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
GetSpanDurationByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
GetSpanCountByField(ctx context.Context, traceID string, bounds *TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
GetSpanDurationByField(ctx context.Context, traceID string, bounds *TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
}

View File

@@ -0,0 +1,62 @@
package spantypes
// TraceStats is the single-row result of the trace summary aggregate query.
type TraceStats struct {
StartNs uint64
EndNs uint64
RootServiceName string
RootEntryPoint string
RootSpanStatusCode string
TotalSpans uint64
TotalErrorSpans uint64
HasMissingSpans bool
GenAISpanCount uint64
Tokens TraceAITokens
TotalCost *float64
}
// GettableTraceSummary is the response for the trace summary API; the trace-level
// fields match the waterfall response.
type GettableTraceSummary struct {
StartTimestampMillis uint64 `json:"startTimestampMillis" required:"true"`
EndTimestampMillis uint64 `json:"endTimestampMillis" required:"true"`
RootServiceName string `json:"rootServiceName" required:"true"`
RootServiceEntryPoint string `json:"rootServiceEntryPoint" required:"true"`
RootSpanStatusCode string `json:"rootSpanStatusCode" required:"true"`
TotalSpansCount uint64 `json:"totalSpansCount" required:"true"`
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount" required:"true"`
HasMissingSpans bool `json:"hasMissingSpans" required:"true"`
AI *TraceAISummary `json:"ai,omitempty"`
}
// TraceAISummary is present when any span carries a gen_ai gate key.
type TraceAISummary struct {
Tokens TraceAITokens `json:"tokens" required:"true"`
// TotalCost is omitted when no span carries a cost attribute.
TotalCost *float64 `json:"totalCost,omitempty" nullable:"false"`
}
type TraceAITokens struct {
Input uint64 `json:"input" required:"true"`
Output uint64 `json:"output" required:"true"`
CacheRead uint64 `json:"cacheRead" required:"true"`
CacheWrite uint64 `json:"cacheWrite" required:"true"`
Reasoning uint64 `json:"reasoning" required:"true"`
}
func NewGettableTraceSummary(stats *TraceStats) *GettableTraceSummary {
summary := &GettableTraceSummary{
StartTimestampMillis: stats.StartNs / 1_000_000,
EndTimestampMillis: stats.EndNs / 1_000_000,
RootServiceName: stats.RootServiceName,
RootServiceEntryPoint: stats.RootEntryPoint,
RootSpanStatusCode: stats.RootSpanStatusCode,
TotalSpansCount: stats.TotalSpans,
TotalErrorSpansCount: stats.TotalErrorSpans,
HasMissingSpans: stats.HasMissingSpans,
}
if stats.GenAISpanCount > 0 {
summary.AI = &TraceAISummary{Tokens: stats.Tokens, TotalCost: stats.TotalCost}
}
return summary
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/cachetypes"
)
type TraceSummary struct {
type TraceBounds struct {
TraceID string `ch:"trace_id"`
Start time.Time `ch:"start"`
End time.Time `ch:"end"`

View File

@@ -13,10 +13,3 @@ SIGNOZ_E2E_BASE_URL=https://app.us.staging.signoz.cloud
# Test credentials (required only when SIGNOZ_E2E_BASE_URL is set — i.e. staging mode)
SIGNOZ_E2E_USERNAME=
SIGNOZ_E2E_PASSWORD=
# Base path mode. `pnpm env:start:base-path` serves SigNoz under /signoz, the
# way SIGNOZ_GLOBAL_EXTERNAL__URL deploys it, and writes this to .env.local;
# specs then run unchanged (see helpers/base-path.ts). The root and base path
# stacks share one database, so stop one and run `pnpm env:clean` before
# starting the other.
# SIGNOZ_E2E_BASE_PATH=/signoz

View File

@@ -1,55 +0,0 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.auth import license_applier, register_admin
from fixtures.signoz import create_signoz
# The e2e stack served under a URL path prefix, as SIGNOZ_GLOBAL_EXTERNAL__URL
# deploys it. These shadow the root fixtures of the same name; the separate
# cache keys keep the two stacks from restoring each other's containers.
BASE_PATH = "/signoz"
@pytest.fixture(name="base_path", scope="package")
def base_path() -> str:
return BASE_PATH
@pytest.fixture(name="signoz", scope="package")
def signoz_base_path( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
e2e_env: dict,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz_base_path",
env_overrides=e2e_env | {"SIGNOZ_GLOBAL_EXTERNAL__URL": f"http://localhost:8080{BASE_PATH}"},
)
@pytest.fixture(name="create_user_admin", scope="package")
def create_user_admin_base_path(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
return register_admin(signoz, request, pytestconfig, cache_key="create_user_admin_base_path", base_path=BASE_PATH)
@pytest.fixture(name="apply_license", scope="package")
def apply_license_base_path(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.Operation:
return license_applier(signoz, request, pytestconfig, cache_key="apply_license_base_path", base_path=BASE_PATH)

View File

@@ -1,5 +0,0 @@
# The root bootstrap's tests, collected again here so they resolve the
# base-path fixtures from this package's conftest.py.
from e2e.bootstrap.setup import test_setup, test_teardown
__all__ = ["test_setup", "test_teardown"]

View File

@@ -1,45 +0,0 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="base_path", scope="package")
def base_path() -> str:
"""URL path prefix SigNoz is served under. Empty serves it at the root;
basepath/conftest.py overrides it."""
return ""
@pytest.fixture(name="e2e_env", scope="package")
def e2e_env() -> dict:
"""Environment every e2e SigNoz gets. Each Playwright worker logs in once,
and the default cap of 5 sessions per user evicts the oldest, which turns a
worker's requests into 401s mid-run."""
return {"SIGNOZ_TOKENIZER_OPAQUE_TOKEN_MAX__PER__USER": 100}
@pytest.fixture(name="signoz", scope="package")
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
tls: types.TLS,
e2e_env: dict,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
tls=tls,
env_overrides=e2e_env,
)

View File

@@ -15,12 +15,11 @@ def _env_file(pytestconfig: pytest.Config) -> Path:
return pytestconfig.rootpath / "e2e" / ".env.local"
def test_setup( # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_setup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
apply_license: types.Operation, # pylint: disable=unused-argument
seeder: types.TestContainerDocker,
base_path: str,
pytestconfig: pytest.Config,
) -> None:
"""Bring the backend up and write e2e coordinates to .env.local."""
@@ -34,8 +33,6 @@ def test_setup( # pylint: disable=too-many-arguments,too-many-positional-argume
f.write(f"SIGNOZ_E2E_USERNAME={USER_ADMIN_EMAIL}\n")
f.write(f"SIGNOZ_E2E_PASSWORD={USER_ADMIN_PASSWORD}\n")
f.write(f"SIGNOZ_E2E_SEEDER_URL={seeder_cfg.base()}\n")
if base_path:
f.write(f"SIGNOZ_E2E_BASE_PATH={base_path}\n")
def test_teardown(

View File

@@ -1,93 +0,0 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import type { Browser, Page, TestType } from '@playwright/test';
import { newAdminContext } from '../helpers/auth';
export { expect, test as kit } from '../fixtures/auth';
export interface DriverContext {
page: Page;
}
type Fixtures = Record<string, unknown>;
/**
* Adds test-scoped fixtures to any test carrying `authedPage`. `T` is inferred
* from the argument: TypeScript cannot invert `T & { authedPage: Page }`, so
* constraining that way widens every existing fixture to `unknown`.
*/
export type Layer<Adds> = <T extends { authedPage: Page }, W extends object>(
base: TestType<T, W>,
) => TestType<T & Adds, W>;
export type WorkerLayer<Adds> = <T extends object, W extends object>(
base: TestType<T, W>,
) => TestType<T, W & Adds>;
/** A `withX` layer binding `build` to the authenticated page. */
export function driver<Name extends string, T>(
name: Name,
build: (ctx: DriverContext) => T,
): Layer<{ [K in Name]: T }> {
const fixture = {
// Playwright reads fixture dependencies from the source text, so
// `authedPage` must stay destructured literally.
[name]: async (
{ authedPage }: { authedPage: Page },
use: (value: T) => Promise<void>,
) => {
await use(build({ page: authedPage }));
},
};
return ((test: TestType<Fixtures, Fixtures>) =>
test.extend(fixture as never)) as Layer<{ [K in Name]: T }>;
}
/**
* A `withX` layer that seeds once per worker and tears down after. `open` and
* `close` run on a throwaway admin page: worker fixtures cannot reach
* `authedPage`.
*/
export function seeded<Name extends string, T>(
name: Name,
open: (ctx: { page: Page; workerIndex: number }) => Promise<T>,
close?: (ctx: { page: Page; value: T }) => Promise<void>,
options: { timeout?: number } = {},
): WorkerLayer<{ [K in Name]: T }> {
const fixture = {
[name]: [
async (
{ browser }: { browser: Browser },
use: (value: T) => Promise<void>,
workerInfo: { workerIndex: number },
) => {
const value = await onAdminPage(browser, (page) =>
open({ page, workerIndex: workerInfo.workerIndex }),
);
await use(value);
if (close) {
await onAdminPage(browser, (page) => close({ page, value }));
}
},
{ scope: 'worker', timeout: options.timeout },
],
};
return ((test: TestType<Fixtures, Fixtures>) =>
test.extend(fixture as never)) as WorkerLayer<{ [K in Name]: T }>;
}
/** Run `body` on a throwaway page authenticated as the admin. */
export async function onAdminPage<T>(
browser: Browser,
body: (page: Page) => Promise<T>,
): Promise<T> {
const ctx = await newAdminContext(browser);
try {
return await body(await ctx.newPage());
} finally {
await ctx.close();
}
}

View File

@@ -1,392 +0,0 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { randomBytes } from 'crypto';
import { expect, type Locator, type Page } from '@playwright/test';
import { ADMIN } from '../helpers/auth';
import { authToken } from '../helpers/common';
import {
createDashboardViaApi,
deleteDashboardViaApi,
} from '../helpers/dashboards';
import { seedPodMetricsViaSeeder } from '../helpers/infra-monitoring';
import { gotoLogsExplorer } from '../helpers/logs-explorer';
import {
gotoTraceUntilLoaded,
type LargeTrace,
loadLargeTrace,
seedTracesViaSeeder,
} from '../helpers/trace-details';
import { driver, kit, seeded } from './kit';
// The react-router v5 -> v7 regression net. Router-sensitive mechanics (history
// depth, POP, same-url suppression, the `newTab` branch) live here so the specs
// read as the property they guard.
export const PATH = {
alerts: '/alerts',
dashboards: '/dashboard',
home: '/home',
k8s: '/infrastructure-monitoring/kubernetes',
login: '/login',
metricsSummary: '/metrics-explorer/summary',
metricsViews: '/metrics-explorer/views',
roles: '/settings/roles',
roleCreate: '/settings/roles/new',
services: '/services',
tracesExplorer: '/traces-explorer',
/** Matches no entry of `AppRoutes/routes.ts`. */
unknown: '/definitely-not-a-route',
} as const;
/** A resource attribute every seeded pod carries. */
const NAMESPACE_ATTR = 'k8s.namespace.name';
const RELATIVE_TIME_LABELS: Record<string, string> = {
'30m': 'Last 30 minutes',
'1h': 'Last 1 hour',
'6h': 'Last 6 hours',
};
/** Three browser projects share one backend, so names need more than a timestamp. */
function uniqueSuffix(): string {
return randomBytes(4).toString('hex');
}
/** Role names allow lowercase letters and hyphens only. */
export function uniqueAlphaSuffix(): string {
return [...randomBytes(6)]
.map((byte) => String.fromCharCode(97 + (byte % 26)))
.join('');
}
export function dashboardListParams(title: string): Record<string, string> {
return { search: title, columnKey: 'updatedAt', order: 'descend', page: '1' };
}
function groupByOf(url: URL): string {
return url.searchParams.get('groupBy') ?? '';
}
export interface Routing {
goto(path: string): Promise<void>;
reload(): Promise<void>;
goBack(): Promise<void>;
goForward(): Promise<void>;
/** Go back one entry and wait for `pathname`. */
goBackTo(pathname: string): Promise<void>;
url(): URL;
params(): Record<string, string>;
waitForPath(pathname: string): Promise<void>;
waitForParam(key: string, value: string): Promise<void>;
/** `history.length`: Chromium keeps it on POP and REPLACE, grows it on PUSH. */
historyDepth(): Promise<number>;
/** Complete the login form on the page as it stands, without navigating. */
login(): Promise<void>;
/** Sidebar entries are `div.nav-item`, not links. */
sidebarItem(label: string): Locator;
/** antd puts a `RouteTab` item's route on `data-node-key`. */
routeTab(route: string): Locator;
activeRouteTab(route: string): Locator;
/** The picker `TopNav` renders. Several pages mount their own. */
globalTimePicker(): Locator;
selectRelativeTime(value: string): Promise<void>;
expectRelativeTime(value: string): Promise<void>;
/** Ctrl/Cmd-click `target`, run `body` on the tab it opens, then close it. */
inNewTab(
target: Locator,
body: (tab: Routing) => Promise<void>,
): Promise<void>;
/** Hold every script requested during `body`, so lazy route chunks suspend. */
whileScriptsHeld(body: () => Promise<void>): Promise<void>;
gotoLogsExplorer(): Promise<void>;
dashboards: {
gotoList(title?: string): Promise<void>;
/** Not `dashboard-title-0`: the search tokenises on hyphens and matches leftovers. */
row(title: string): Locator;
landmark(): Locator;
title(): Locator;
};
k8s: {
/** Open the pods list once its mount-time `compositeQuery` rewrite landed. */
goto(params: Record<string, string>): Promise<void>;
/** One nuqs push: `groupBy`, `page` and `orderBy` flush together. */
groupBy(attribute: string): Promise<void>;
waitUntilGrouped(attribute: string, grouped?: boolean): Promise<void>;
groupBySelect(): Locator;
};
trace: {
path(trace: LargeTrace): string;
goto(trace: LargeTrace): Promise<void>;
rootRow(trace: LargeTrace): Locator;
back(): Promise<void>;
};
roles: {
/** Fill the create form far enough to arm the navigation blocker. */
openCreateForm(name: string): Promise<void>;
nameInput(): Locator;
cancel(): Promise<void>;
save(): Promise<void>;
discardDialog(): Locator;
settings(): Locator;
removeByName(names: string[]): Promise<void>;
};
}
export function routingDriver(page: Page): Routing {
const timePicker = (): Locator => page.getByTestId('dropDown');
const routing: Routing = {
goto: async (path) => {
await page.goto(path);
},
reload: async () => {
await page.reload();
},
goBack: async () => {
await page.goBack();
},
goForward: async () => {
await page.goForward();
},
goBackTo: async (pathname) => {
await page.goBack();
await routing.waitForPath(pathname);
},
url: () => new URL(page.url()),
params: () => Object.fromEntries(routing.url().searchParams),
waitForPath: (pathname) =>
page.waitForURL((url) => url.pathname === pathname),
waitForParam: (key, value) =>
page.waitForURL((url) => url.searchParams.get(key) === value),
historyDepth: () =>
page.evaluate(
() =>
(globalThis as unknown as { history: { length: number } }).history.length,
),
login: async () => {
await page.getByTestId('email').fill(ADMIN.email);
await page.getByTestId('initiate_login').click();
await page.getByTestId('password').fill(ADMIN.password);
await page.getByTestId('password_authn_submit').click();
},
sidebarItem: (label) =>
page.locator('.nav-item').filter({ hasText: new RegExp(`^${label}$`) }),
routeTab: (route) => page.locator(`.ant-tabs-tab[data-node-key="${route}"]`),
activeRouteTab: (route) =>
routing.routeTab(route).getByRole('tab', { selected: true }),
globalTimePicker: () =>
page.locator('.top-nav-container').getByTestId('dropDown'),
// A click before the picker is interactive is dropped silently, so retry the
// open. The popover unmounts on close, which is what proves the selection
// committed: the url may legitimately stay the same.
selectRelativeTime: async (value) => {
const option = page.getByTestId(`time-option-${value}`);
await expect(async () => {
if (!(await option.isVisible())) {
await timePicker().click();
}
await expect(option).toBeVisible({ timeout: 3_000 });
}).toPass({ timeout: 30_000 });
await option.click();
await expect(option).toBeHidden();
},
expectRelativeTime: async (value) => {
await expect(timePicker()).toHaveAccessibleName(RELATIVE_TIME_LABELS[value]);
},
inNewTab: async (target, body) => {
const [tab] = await Promise.all([
page.context().waitForEvent('page'),
target.click({ modifiers: ['ControlOrMeta'] }),
]);
try {
await tab.waitForLoadState();
await body(routingDriver(tab));
} finally {
await tab.close();
}
},
// The handler stays installed after the release: `unroute` drops the
// requests still parked in it.
whileScriptsHeld: async (body) => {
let release = (): void => {};
const held = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/*.js', async (route) => {
await held;
await route.continue();
});
try {
await body();
} finally {
release();
}
},
gotoLogsExplorer: () => gotoLogsExplorer(page),
dashboards: {
gotoList: async (title) => {
if (!title) {
await page.goto(PATH.dashboards);
await expect(routing.dashboards.landmark()).toBeVisible();
return;
}
const search = new URLSearchParams(dashboardListParams(title));
await page.goto(`${PATH.dashboards}?${search.toString()}`);
await expect(routing.dashboards.row(title)).toBeVisible();
},
row: (title) =>
page
.locator('[data-testid^="dashboard-title-"]')
.filter({ hasText: title }),
// The views rail renders whether or not the list is empty.
landmark: () => page.getByTestId('dashboards-view-search'),
title: () => page.getByTestId('dashboard-title'),
},
k8s: {
goto: async (params) => {
const search = new URLSearchParams({ category: 'pods', ...params });
await page.goto(`${PATH.k8s}?${search.toString()}`);
await page.waitForURL((url) => url.searchParams.has('compositeQuery'));
await expect(routing.k8s.groupBySelect()).toBeVisible();
},
// The search input stays readonly until the dropdown opens, and `hasText`
// would also match longer keys, so type after opening and match the title.
groupBy: async (attribute) => {
await routing.k8s.groupBySelect().click();
await page.keyboard.type(attribute);
await page.locator(`.ant-select-item-option[title="${attribute}"]`).click();
await page.keyboard.press('Escape');
await routing.k8s.waitUntilGrouped(attribute);
},
waitUntilGrouped: (attribute, grouped = true) =>
page.waitForURL((url) => groupByOf(url).includes(attribute) === grouped),
groupBySelect: () => page.getByTestId('k8s-table-group-by-select'),
},
trace: {
path: (trace) => `/trace/${trace.traceId}`,
goto: (trace) =>
gotoTraceUntilLoaded(
page,
routing.trace.path(trace),
`cell-0-${trace.landmarks.root}`,
),
rootRow: (trace) => page.getByTestId(`cell-0-${trace.landmarks.root}`),
back: async () => {
await page.getByRole('button', { name: 'Back' }).click();
},
},
roles: {
openCreateForm: async (name) => {
await page.goto(PATH.roleCreate);
await routing.roles.nameInput().fill(name);
await expect(page.getByText('Unsaved changes')).toBeVisible();
},
nameInput: () => page.getByTestId('role-name-input'),
cancel: async () => {
await page.getByTestId('cancel-button').click();
},
save: async () => {
await page.getByTestId('save-button').click();
},
discardDialog: () => page.getByTestId('discard-changes-dialog'),
settings: () => page.getByTestId('roles-settings'),
removeByName: async (names) => {
const headers = { Authorization: `Bearer ${await authToken(page)}` };
const res = await page.request.get('/api/v1/roles', { headers });
const { data } = (await res.json()) as {
data: { id: string; name: string }[];
};
await Promise.all(
data
.filter((role) => names.includes(role.name))
.map((role) =>
page.request.delete(`/api/v1/roles/${role.id}`, { headers }),
),
);
},
},
};
return routing;
}
// The seeder serves inserts on one ClickHouse session, so parallel workers
// collide with a transient `500 concurrent queries within the same session`.
const SEED_RETRY_MS = 120_000;
const SEED_TIMEOUT_MS = 180_000;
async function seedWithRetry(seed: () => Promise<void>): Promise<void> {
await expect(seed).toPass({
timeout: SEED_RETRY_MS,
intervals: [1_000, 2_000, 5_000],
});
}
const withRouting = driver('routing', ({ page }) => routingDriver(page));
const withDashboard = seeded(
'dashboard',
async ({ page }) => {
const title = `routing-${uniqueSuffix()}`;
const id = await createDashboardViaApi(page, title);
return { id, title, path: `${PATH.dashboards}/${id}` };
},
async ({ page, value }) =>
deleteDashboardViaApi(page.request, value.id, await authToken(page)),
);
const withTrace = seeded(
'largeTrace',
async ({ page }) => {
const trace = loadLargeTrace();
await seedWithRetry(() => seedTracesViaSeeder(page.request, trace.spans));
return trace;
},
undefined,
{ timeout: SEED_TIMEOUT_MS },
);
// Group-by options come from `/fields/keys` over the queried window, so the
// select stays empty until pod metrics exist inside it.
const withPods = seeded(
'pods',
async ({ page }) => {
await seedWithRetry(() => seedPodMetricsViaSeeder(page));
return { groupBy: NAMESPACE_ATTR };
},
undefined,
{ timeout: SEED_TIMEOUT_MS },
);
export const test = withPods(
withTrace(withDashboard(withRouting(kit))),
).extend<{
/** The routing driver on an unauthenticated page. */
guest: Routing;
}>({
guest: async ({ browser }, use) => {
const ctx = await browser.newContext();
await use(routingDriver(await ctx.newPage()));
await ctx.close();
},
});
export { expect, onAdminPage } from './kit';

View File

@@ -1,42 +1,27 @@
import { test as base, expect, type Page } from '@playwright/test';
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
import { installBasePathMode } from '../helpers/base-path';
// The login flow and the per-worker session cache live in `helpers/auth.ts` so
// worker-scoped fixtures and suite hooks share one login with this fixture.
export { ADMIN };
export type { User };
export const test = base.extend<
{
/**
* User identity for this test. Override with `test.use({ user: ... })` at
* the describe or test level to run the suite as a different user.
* Defaults to ADMIN (the pytest-bootstrap-seeded admin).
*/
user: User;
/**
* A Page whose context is already authenticated as `user`. First request
* for a given user triggers one login per worker; the resulting
* storageState is held in memory and reused for all later requests.
*/
authedPage: Page;
},
{
/** Applies SIGNOZ_E2E_BASE_PATH before any page or login in the worker. */
basePathMode: void;
}
>({
basePathMode: [
async ({ browser }, use) => {
await installBasePathMode(browser);
await use();
},
{ scope: 'worker', auto: true },
],
export const test = base.extend<{
/**
* User identity for this test. Override with `test.use({ user: ... })` at
* the describe or test level to run the suite as a different user.
* Defaults to ADMIN (the pytest-bootstrap-seeded admin).
*/
user: User;
/**
* A Page whose context is already authenticated as `user`. First request
* for a given user triggers one login per worker; the resulting
* storageState is held in memory and reused for all later requests.
*/
authedPage: Page;
}>({
user: [ADMIN, { option: true }],
authedPage: async ({ browser, user }, use) => {

View File

@@ -72,7 +72,6 @@ export async function gotoAlertList(
await page.goto(`${ALERTS_LIST_PATH}?${query.toString()}`);
await expect(page.getByTestId('list-alerts-search-input')).toBeVisible();
if (expectRows) {
// Skeleton rows are empty and ignore clicks; wait for a loaded one.
await expect(alertRuleRows(page).first()).toHaveText(/\S/);
await expect(alertRuleRows(page).first()).toBeVisible();
}
}

View File

@@ -1,7 +1,5 @@
import type { Browser, BrowserContext, Page } from '@playwright/test';
import { storageKey } from './base-path';
export type User = { email: string; password: string };
/** Default user — admin from the pytest bootstrap (.env.local) or staging .env. */
@@ -59,8 +57,7 @@ async function login(page: Page, user: User): Promise<void> {
async function pinSidenav(page: Page): Promise<void> {
const token = await page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(key) => (globalThis as any).localStorage.getItem(key) || '',
storageKey('AUTH_TOKEN'),
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
data: { value: true },

View File

@@ -1,105 +0,0 @@
import type { APIRequestContext, Browser, Page } from '@playwright/test';
// Base path mode: with SIGNOZ_E2E_BASE_PATH set (`pnpm env:start:base-path`
// writes `/signoz`), specs run unchanged against SigNoz served under that URL
// prefix. `page.goto`, `page.waitForURL` string targets and `request.*` take
// app paths and get the prefix; `page.url()` and `waitForURL` predicates see
// the URL without it. `expect(page).toHaveURL` still sees the real URL.
export const BASE_PATH = (process.env.SIGNOZ_E2E_BASE_PATH ?? '').replace(
/\/$/,
'',
);
/** The app scopes its localStorage keys to the base path (`utils/storage.ts`). */
export function storageKey(key: string): string {
return BASE_PATH ? `${BASE_PATH}/${key}` : key;
}
function toServedPath(path: string): string {
return path.startsWith('/') && !path.startsWith('//')
? `${BASE_PATH}${path}`
: path;
}
function toAppUrl(href: string): string {
const url = new URL(href);
if (url.pathname === BASE_PATH || url.pathname.startsWith(`${BASE_PATH}/`)) {
url.pathname = url.pathname.slice(BASE_PATH.length) || '/';
}
return url.toString();
}
function patchMethod<T extends object, K extends keyof T>(
proto: T,
name: K,
wrap: (original: T[K]) => T[K],
): void {
proto[name] = wrap(proto[name]);
}
let installed = false;
/**
* Patches the Page and APIRequestContext prototypes once per worker. Playwright
* exports neither class, so the prototypes come from a throwaway page.
*/
export async function installBasePathMode(browser: Browser): Promise<void> {
if (!BASE_PATH || installed) {
return;
}
installed = true;
const context = await browser.newContext();
const page = await context.newPage();
const pageProto: Page = Object.getPrototypeOf(page);
const requestProto: APIRequestContext = Object.getPrototypeOf(page.request);
await context.close();
patchMethod(
pageProto,
'goto',
(goto) =>
function (this: Page, target, options) {
return goto.call(this, toServedPath(target), options);
},
);
patchMethod(
pageProto,
'url',
(url) =>
function (this: Page) {
return toAppUrl(url.call(this));
},
);
patchMethod(
pageProto,
'waitForURL',
(waitForURL) =>
function (this: Page, matcher, options) {
let appMatcher = matcher;
if (typeof matcher === 'function') {
appMatcher = (current: URL): boolean =>
matcher(new URL(toAppUrl(current.href)));
} else if (typeof matcher === 'string') {
appMatcher = toServedPath(matcher);
}
return waitForURL.call(this, appMatcher, options);
},
);
patchMethod(
requestProto,
'fetch',
(fetch) =>
function (this: APIRequestContext, target, options) {
return fetch.call(
this,
typeof target === 'string' ? toServedPath(target) : target,
options,
);
},
);
}

View File

@@ -1,7 +1,5 @@
import type { Page, Request } from '@playwright/test';
import { storageKey } from './base-path';
// Shared helpers used across feature-specific helper modules (dashboards,
// trace-details, …). Keep this to genuinely cross-feature utilities.
@@ -47,8 +45,6 @@ const HARNESS_CONSOLE_NOISE = [
'Request failed with status code 404',
'client never received a response, or request never left',
'ErrorResponseHandler: unclassified error',
// Google Fonts, which the chromium project fails at DNS (playwright.config.ts).
'Failed to load resource: net::ERR_NAME_NOT_RESOLVED',
];
export interface ConsoleWatch {
@@ -134,9 +130,7 @@ export function requestUrl(request: Request): URL {
export async function authToken(page: Page): Promise<string> {
const state = await page.context().storageState();
for (const origin of state.origins) {
const entry = origin.localStorage.find(
(e) => e.name === storageKey('AUTH_TOKEN'),
);
const entry = origin.localStorage.find((e) => e.name === 'AUTH_TOKEN');
if (entry) {
return entry.value;
}

View File

@@ -11,8 +11,6 @@ import apmMetricsTemplate from '../testdata/apm-metrics.json';
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
import variablesTemplate from '../testdata/variables-dashboard.json';
import { storageKey } from './base-path';
// ─── Constants ───────────────────────────────────────────────────────────
//
// UI strings and well-known values referenced both within this file and by
@@ -47,8 +45,7 @@ export async function authToken(page: Page): Promise<string> {
}
return page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(key) => (globalThis as any).localStorage.getItem(key) || '',
storageKey('AUTH_TOKEN'),
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
}
@@ -85,39 +82,12 @@ async function postDashboard(
return json.data.id;
}
/**
* Seed a minimally-named dashboard via API. Returns the new ID.
*
* v2 (Perses spec) only: `POST /api/v1/dashboards` now answers 501
* `dashboard_deprecated`. Payload mirrors what the New-dashboard modal sends
* (`BlankDashboardPanel.tsx`), `schemaVersion` included.
*/
/** Seed a minimally-named dashboard via API. Returns the new ID. */
export async function createDashboardViaApi(
page: Page,
title: string,
): Promise<string> {
const token = await authToken(page);
const res = await page.request.post('/api/v2/dashboards', {
data: {
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: {
display: { name: title },
layouts: [],
panels: {},
variables: [],
},
},
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`POST /api/v2/dashboards ${res.status()}: ${await res.text()}`,
);
}
const json = (await res.json()) as { data: { id: string } };
return json.data.id;
return postDashboard(page, { title, uploadedGrafana: false });
}
/**
@@ -372,7 +342,7 @@ export async function deleteDashboardViaApi(
token: string,
): Promise<void> {
await request
.delete(`/api/v2/dashboards/${id}`, {
.delete(`/api/v1/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
.catch(() => undefined);

View File

@@ -1,110 +0,0 @@
import type { Page } from '@playwright/test';
import { seederUrl } from './common';
// Seeding for the Kubernetes lists under /infrastructure-monitoring.
/**
* The metric the pods list queries. Its rows are what put the pod resource
* attributes into `/fields/keys`, which is where the group-by select gets its
* options, so this is the metric to seed, not an arbitrary one.
*
* Dotted, not `k8s_pod_cpu_usage`: the list passes this name straight through as
* `metricNamespace` and the backend matches it literally, so the underscored
* spelling returns an empty key set and the group-by select renders "No data".
*/
const POD_CPU_METRIC = 'k8s.pod.cpu.usage';
const POD_MEMORY_METRIC = 'k8s.pod.memory.usage';
const NAMESPACES = ['default', 'kube-system', 'signoz'];
const PODS_PER_NAMESPACE = 2;
const POINTS_PER_POD = 4;
const POINT_INTERVAL_MS = 60_000;
type SeededMetric = {
metric_name: string;
timestamp: string;
value: number;
type_: string;
temporality: string;
is_monotonic: boolean;
unit: string;
resource_attrs: Record<string, string>;
labels: Record<string, string>;
};
function podResourceAttrs(
namespace: string,
pod: string,
): Record<string, string> {
return {
'k8s.cluster.name': 'e2e-cluster',
'k8s.namespace.name': namespace,
'k8s.node.name': `node-${namespace}`,
'k8s.pod.name': pod,
'k8s.pod.uid': `${namespace}-${pod}`,
};
}
function podPoints(now: number): SeededMetric[] {
const points: SeededMetric[] = [];
for (const namespace of NAMESPACES) {
for (let p = 0; p < PODS_PER_NAMESPACE; p += 1) {
const pod = `${namespace}-pod-${p}`;
const resourceAttrs = podResourceAttrs(namespace, pod);
for (let i = 0; i < POINTS_PER_POD; i += 1) {
const timestamp = new Date(
now - (POINTS_PER_POD - i) * POINT_INTERVAL_MS,
).toISOString();
points.push({
metric_name: POD_CPU_METRIC,
timestamp,
value: 0.1 + i * 0.05,
type_: 'Gauge',
temporality: 'Unspecified',
is_monotonic: false,
unit: '1',
resource_attrs: resourceAttrs,
labels: resourceAttrs,
});
points.push({
metric_name: POD_MEMORY_METRIC,
timestamp,
value: 100_000_000 + i * 1_000_000,
type_: 'Gauge',
temporality: 'Unspecified',
is_monotonic: false,
unit: 'By',
resource_attrs: resourceAttrs,
labels: resourceAttrs,
});
}
}
}
return points;
}
/**
* Seed pod metrics so the k8s list has rows and its group-by select has
* options. Both matter: with no rows inside the queried window the select
* renders empty and every `setK8sGroupBy` call times out.
*
* The pod keys go in `labels` as well as `resource_attrs`: the list's queries
* reference some of them as `tag` and others as `resource`, and the series
* fingerprint is computed off labels alone. With an empty label map all six
* pods collapse into one series and the list comes back empty.
*
* Wrap the call in `seedWithRetry`: the seeder serves inserts on one
* ClickHouse session, so parallel workers collide with a transient 500.
*/
export async function seedPodMetricsViaSeeder(page: Page): Promise<void> {
const res = await page.request.post(`${seederUrl()}/telemetry/metrics`, {
data: podPoints(Date.now()),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok()) {
throw new Error(
`seeder POST /telemetry/metrics ${res.status()}: ${await res.text()}`,
);
}
}

View File

@@ -7,8 +7,6 @@
"preinstall": "npx only-allow pnpm",
"env:start": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/setup.py::test_setup",
"env:stop": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/setup.py::test_teardown",
"env:start:base-path": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/basepath/setup.py::test_setup",
"env:stop:base-path": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/basepath/setup.py::test_teardown",
"env:clean": "rm -rf ../tmp ../.pytest_cache .env.local artifacts && echo 'Cleaned. Run docker container prune if needed.'",
"test": "playwright test",
"test:local": "pnpm env:start && pnpm test",

View File

@@ -108,15 +108,7 @@ export default defineConfig({
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
launchOptions: {
// Google Fonts CSS blocks the app script and `load` until it settles,
// so a stalled request blanks the page. Failing DNS settles it at once;
// a `route` would too, but routing turns off the HTTP cache.
args: ['--host-resolver-rules=MAP fonts.googleapis.com ~NOTFOUND'],
},
},
use: devices['Desktop Chrome'],
dependencies: ['setup'],
},
{

View File

@@ -26,7 +26,6 @@ import {
v2SaveTooltip,
v2TestButton,
} from '../../../helpers/alert-forms/v2';
import { BASE_PATH } from '../../../helpers/base-path';
// CV2-* — the v2 create builder.
//
@@ -447,7 +446,7 @@ test.describe('Alert create — v2 builder', () => {
await ownedRules.register(response);
expect(response.status()).toBe(201);
expect(new URL(response.url()).pathname).toBe(`${BASE_PATH}/api/v2/rules`);
expect(new URL(response.url()).pathname).toBe('/api/v2/rules');
const body = response.request().postDataJSON();
expect(body.schemaVersion).toBe('v2alpha1');

Some files were not shown because too many files have changed in this diff Show More