Compare commits

..

4 Commits

Author SHA1 Message Date
Ashwin Bhatkal
b3f7970b96 ci(e2e): save main's server binary in cacheci 2026-11-03 22:58:22 +05:30
Ashwin Bhatkal
47a2ce14a6 feat(tests): add --prebuilt-signoz to run an existing signoz image 2026-11-03 22:58:22 +05:30
Ashwin Bhatkal
3be463e06a ci(e2e): pin Node 22 2026-11-03 22:58:22 +05:30
Ashwin Bhatkal
e11224105c ci(e2e): run the suite on 10 shards 2026-11-03 22:58:21 +05:30
92 changed files with 810 additions and 3620 deletions

5
.github/CODEOWNERS vendored
View File

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

View File

@@ -46,8 +46,34 @@ jobs:
docker build -f "$RUNNER_TEMP/inject.Dockerfile" "$RUNNER_TEMP/cacheci"
- name: build
run: |
docker build -f cmd/enterprise/Dockerfile.integration --build-arg TARGETARCH=amd64 --build-arg ZEUSURL=http://zeus:8080 .
docker build -f cmd/enterprise/Dockerfile.integration --tag signoz:integration --build-arg TARGETARCH=amd64 --build-arg ZEUSURL=http://zeus:8080 .
docker build -f cmd/enterprise/Dockerfile.with-web.integration --build-arg TARGETARCH=amd64 --build-arg ZEUSURL=http://zeus:8080 .
# e2eci runs this server binary for changes that leave the server as it
# is here, and builds only the web.
- name: e2e-signoz-key
id: e2e-signoz-key
run: |
echo "key=$(tests/e2e/scripts/signoz-key.sh)" >> "$GITHUB_OUTPUT"
- name: e2e-signoz
run: |
mkdir -p "$RUNNER_TEMP/e2e-signoz"
id=$(docker create signoz:integration)
docker cp "$id":/root/signoz "$RUNNER_TEMP/e2e-signoz/signoz"
docker rm "$id"
- name: save-e2e-signoz
uses: actions/cache/save@v4
with:
path: ${{ runner.temp }}/e2e-signoz
key: ${{ steps.e2e-signoz-key.outputs.key }}
# Older keys only match commits main has moved past.
- name: prune-e2e-signoz
env:
GH_TOKEN: ${{ github.token }}
KEY: ${{ steps.e2e-signoz-key.outputs.key }}
run: |
gh cache list --repo "$GITHUB_REPOSITORY" --key e2e-signoz- --json key --jq '.[].key' \
| grep -vxF "$KEY" \
| xargs -r -n 1 gh cache delete --repo "$GITHUB_REPOSITORY"
# docker cp instead of --output type=local (the local exporter stalls on
# multi-GB outputs); tarballs instead of raw trees so the host never hits
# the permission and symlink semantics that broke docker cp.

View File

@@ -47,7 +47,7 @@ jobs:
- name: node
uses: actions/setup-node@v4
with:
node-version: lts/*
node-version: "22"
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
@@ -67,7 +67,7 @@ jobs:
matrix:
project:
- chromium
shard: [1, 2, 3, 4]
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
needs: changes
if: needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
@@ -105,7 +105,7 @@ jobs:
- name: node
uses: actions/setup-node@v4
with:
node-version: lts/*
node-version: "22"
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
@@ -162,7 +162,7 @@ jobs:
- name: playwright-test
run: |
cd tests/e2e && \
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/10
- name: teardown-stack
if: always()
run: |

View File

@@ -352,6 +352,7 @@ The same pytest flags integration tests expose work here, since E2E reuses the s
- `--teardown` — tear everything down.
- `--clean` — prune the docker build caches, forcing the next image build to start cold.
- `--with-web` — build the frontend into the SigNoz container. **Required for E2E**; integration tests don't need it.
- `--prebuilt-signoz` — run the `signoz:integration` image already in the local image store instead of building it. The image must be built with `ZEUSURL=http://zeus:8080`. CI uses it when main's server binary matches the change.
- `--sqlstore-provider`, `--postgres-version`, `--clickhouse-version`, etc. — see `docs/contributing/tests/integration.md`.
## What should I remember?

View File

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

View File

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

465
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,6 @@ function InputWithLabel({
className,
closeIcon,
disabled,
sanitize,
}: {
label: string;
initialValue?: string | number | null;
@@ -30,17 +29,14 @@ function InputWithLabel({
className?: string;
closeIcon?: React.ReactNode;
disabled?: boolean;
/** Rewrites the typed value before it is shown and reported. */
sanitize?: (value: string) => string;
}): JSX.Element {
const [inputValue, setInputValue] = useState<string>(
initialValue ? initialValue.toString() : '',
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const value = sanitize ? sanitize(e.target.value) : e.target.value;
setInputValue(value);
onChange?.(value);
setInputValue(e.target.value);
onChange?.(e.target.value);
};
return (
@@ -82,7 +78,6 @@ InputWithLabel.defaultProps = {
initialValue: undefined,
className: undefined,
closeIcon: undefined,
sanitize: undefined,
};
export default InputWithLabel;

View File

@@ -26,7 +26,6 @@ import {
RAW_QUERY_FIELDS,
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import { parseLimit, sanitizeLimit } from '../../utils';
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
@@ -279,7 +278,7 @@ function QueryAddOns({
const handleChangeLimit = useCallback(
(value: string) => {
handleChangeQueryData('limit', parseLimit(value));
handleChangeQueryData('limit', Number(value) || null);
},
[handleChangeQueryData],
);
@@ -375,7 +374,6 @@ function QueryAddOns({
label="Limit"
type="number"
onChange={handleChangeLimit}
sanitize={sanitizeLimit}
initialValue={query?.limit ?? undefined}
placeholder={placeholderOf(QueryBuilderField.Limit)}
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}

View File

@@ -169,15 +169,8 @@ describe('QueryAddOns', () => {
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(input.value).toBe('5');
const user = userEvent.setup();
await user.clear(input);
await user.type(input, '10');
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 10);
await user.clear(input);
await user.type(input, '-5');
expect(input.value).toBe('5');
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 5);
fireEvent.change(input, { target: { value: '10' } });
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it.each([

View File

@@ -12,62 +12,10 @@ import {
convertExpressionToFilters,
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
formatValueForExpression,
parseLimit,
removeKeysFromExpression,
sanitizeLimit,
} from '../utils';
describe('convertHavingToExpression', () => {
it.each([
['COUNT()', 'count() > 5'],
['COUNT_DISTINCT(service.name)', 'count_distinct(service.name) > 5'],
['P99(duration_nano)', 'p99(duration_nano) > 5'],
['SUM(http.Request.Size)', 'sum(http.Request.Size) > 5'],
['count()', 'count() > 5'],
])('references %s by its lower-case function', (columnName, expected) => {
expect(
convertHavingToExpression([{ columnName, op: '>', value: 5 }]),
).toStrictEqual({ expression: expected });
});
it('joins several conditions and formats list values', () => {
expect(
convertHavingToExpression([
{ columnName: 'COUNT()', op: '>', value: 5 },
{ columnName: 'COUNT()', op: 'NOT IN', value: [1, 2] },
]),
).toStrictEqual({ expression: 'count() > 5 AND count() NOT IN [1, 2]' });
});
});
describe('sanitizeLimit', () => {
it.each([
['10', '10'],
['-10', '10'],
['2.5', '25'],
['1e3', '13'],
['', ''],
])('sanitizes %j to %j', (value, expected) => {
expect(sanitizeLimit(value)).toBe(expected);
});
});
describe('parseLimit', () => {
it.each([
['10', 10],
['1', 1],
['', null],
['0', null],
['-5', null],
['2.5', null],
['abc', null],
])('parses %j as %j', (value, expected) => {
expect(parseLimit(value)).toBe(expected);
});
});
describe('convertFiltersToExpression', () => {
beforeEach(() => {
jest.clearAllMocks();

View File

@@ -840,16 +840,6 @@ export const appendAndClause = (
return hasTopLevelOr ? `(${base}) AND ${clause}` : `${base} AND ${clause}`;
};
/** Strips everything but digits, so a limit input can't hold a sign or decimal. */
export const sanitizeLimit = (value: string): string =>
value.replace(/\D/g, '');
/** A query or formula limit: a positive integer, otherwise none. */
export const parseLimit = (value: string): number | null => {
const limit = Number(value);
return Number.isInteger(limit) && limit > 0 ? limit : null;
};
/**
* Convert old having format to new having format
* @param having - Array of old having objects with columnName, op, and value
@@ -881,14 +871,7 @@ export const convertHavingToExpression = (
formattedValue = String(value);
}
// The old having filter upper-cased the function (`COUNT()`); v5 only
// resolves the aggregation's own lower-case expression.
const reference = columnName.replace(
/^\s*([A-Za-z_]\w*)\(/,
(_, fn: string) => `${fn.toLowerCase()}(`,
);
return `${reference} ${op} ${formattedValue}`;
return `${columnName} ${op} ${formattedValue}`;
})
.filter((expression) => expression !== ''); // Remove empty expressions

View File

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

View File

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

View File

@@ -26,7 +26,6 @@ import {
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -48,10 +47,6 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
@@ -338,11 +333,6 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
);
return;
}
// Read before toUrlCompositeQuery, which drops the requestType envelope.
const panelType = getPanelTypeForRequestType(
requestTypeFromActionQuery(action.query),
action.signal,
);
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
@@ -352,9 +342,7 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized, {
[QueryParams.panelTypes]: panelType,
});
deps.redirectWithQueryBuilderData(normalized);
return;
}
const base = explorerRouteForSignal(action.signal);
@@ -365,10 +353,8 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → navigate', base);
const url = buildExplorerNavigationUrl(base, normalized, {
[QueryParams.panelTypes]: panelType,
});
navigate(url);
const encoded = encodeURIComponent(JSON.stringify(normalized));
navigate(`${base}?${QueryParams.compositeQuery}=${encoded}`);
}
/** Picks the right rollback API call for a given action kind. */
@@ -627,7 +613,6 @@ export default function ActionsSection({
disabled={isSuccess}
loading={isLoading}
aria-label={action.label}
testId={`ai-action-chip-${action.kind}`}
prefix={icon}
>
<span className={styles.chipLabel}>{action.label}</span>

View File

@@ -1,16 +1,7 @@
import { undoExecution } from 'api/ai-assistant/chat';
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getCurrentLocation } from 'lib/router/navigation';
import { MessageActionKindDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import ActionsSection from '../ActionsSection';
@@ -37,37 +28,6 @@ function renderUndoChip(): HTMLElement {
return screen.getByRole('button', { name: 'Undo change' });
}
// Labels differ per test: auto-applied filters are deduped module-wide by label.
function countByServiceAction(label: string): MessageActionDTO {
return {
kind: MessageActionKindDTO.apply_filter,
label,
signal: ApplyFilterSignalDTO.logs,
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
},
};
}
function currentPanelTypesParam(): string | null {
return new URLSearchParams(getCurrentLocation().search).get(
QueryParams.panelTypes,
);
}
describe('ActionsSection rollback chip', () => {
beforeEach(() => {
mockUndo.mockReset();
@@ -121,52 +81,3 @@ describe('ActionsSection rollback chip', () => {
).resolves.toBeInTheDocument();
});
});
describe('ActionsSection apply_filter', () => {
it('opens the explorer in the Table view when the chip is clicked from another page', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[countByServiceAction('Open in Logs Explorer')]}
messageId="message-1"
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
});
it('switches the open explorer to the Table view when applied on it', async () => {
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
const handleSetQueryData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
>;
render(
<ActionsSection
actions={[countByServiceAction('Apply on Logs Explorer')]}
messageId="message-2"
/>,
undefined,
{
initialRoute: ROUTES.LOGS_EXPLORER,
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
},
);
await waitFor(() =>
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
);
});
});

View File

@@ -1,106 +0,0 @@
import { ApplyFilterSignalDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from '../getPanelTypeForRequestType';
describe('getPanelTypeForRequestType', () => {
it.each([
[
Querybuildertypesv5RequestTypeDTO.scalar,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TABLE,
],
[
Querybuildertypesv5RequestTypeDTO.time_series,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TIME_SERIES,
],
[
Querybuildertypesv5RequestTypeDTO.raw,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.traces,
PANEL_TYPES.TRACE,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.metrics,
PANEL_TYPES.LIST,
],
])('maps %s on the %s signal to the %s view', (requestType, signal, view) => {
expect(getPanelTypeForRequestType(requestType, signal)).toBe(view);
});
it.each([
undefined,
null,
'',
Querybuildertypesv5RequestTypeDTO.raw_stream,
Querybuildertypesv5RequestTypeDTO.heatmap,
'distribution',
'nonsense',
'toString',
'constructor',
42,
{},
])(
'falls back to the List view for unmapped or invalid requestType (%p)',
(value) => {
expect(getPanelTypeForRequestType(value, ApplyFilterSignalDTO.traces)).toBe(
PANEL_TYPES.LIST,
);
},
);
});
describe('requestTypeFromActionQuery', () => {
it('reads the requestType envelope field and returns undefined when absent', () => {
expect(
requestTypeFromActionQuery({
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: { queries: [] },
}),
).toBe('scalar');
expect(requestTypeFromActionQuery({})).toBeUndefined();
expect(requestTypeFromActionQuery(null)).toBeUndefined();
expect(requestTypeFromActionQuery(undefined)).toBeUndefined();
});
it('maps a grouped count() payload to the Table view', () => {
const query = {
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
};
expect(
getPanelTypeForRequestType(
requestTypeFromActionQuery(query),
ApplyFilterSignalDTO.logs,
),
).toBe(PANEL_TYPES.TABLE);
});
});

View File

@@ -1,30 +0,0 @@
import {
ApplyFilterSignalDTO,
type MessageActionDTOQuery,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
export function getPanelTypeForRequestType(
requestType: unknown,
signal: ApplyFilterSignalDTO,
): PANEL_TYPES {
switch (requestType) {
case Querybuildertypesv5RequestTypeDTO.scalar:
return PANEL_TYPES.TABLE;
case Querybuildertypesv5RequestTypeDTO.time_series:
return PANEL_TYPES.TIME_SERIES;
case Querybuildertypesv5RequestTypeDTO.trace:
return signal === ApplyFilterSignalDTO.traces
? PANEL_TYPES.TRACE
: PANEL_TYPES.LIST;
default:
return PANEL_TYPES.LIST;
}
}
export function requestTypeFromActionQuery(
query: MessageActionDTOQuery | undefined,
): unknown {
return query?.requestType;
}

View File

@@ -2,7 +2,6 @@ import { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { Col, Input, Row, Select } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
import { parseLimit, sanitizeLimit } from 'components/QueryBuilderV2/utils';
import { LEGEND } from 'constants/global';
import { PANEL_TYPES } from 'constants/queryBuilder';
// ** Hooks
@@ -172,8 +171,7 @@ export function Formula({
<InputWithLabel
label="Limit"
type="number"
onChange={(value): void => handleChangeLimit(parseLimit(value))}
sanitize={sanitizeLimit}
onChange={(value): void => handleChangeLimit(Number(value))}
initialValue={formula?.limit ?? undefined}
placeholder="Enter limit"
/>

View File

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

View File

@@ -1,113 +0,0 @@
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import { mockSuccess, mockTraceSummary, renderListView } from './testUtils';
// globalTime starts with loading:true, which gates the list query. Force just that
// slice's loading to false so the query fires; every other selector is untouched.
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
return {
...actual,
useSelector: (selector: (state: unknown) => unknown): unknown => {
const result = actual.useSelector(selector);
if (result && typeof result === 'object' && 'loading' in result) {
return { ...result, loading: false };
}
return result;
},
};
});
// List columns come from the options menu (server-synced preferences). Pin them
// so the expected columns render, independent of that API.
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
__esModule: true,
default: (): unknown => ({
options: {
selectColumns: [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name', fieldContext: 'span' },
{ name: 'duration_nano', fieldContext: 'span' },
{ name: 'http_method', fieldContext: 'span' },
{ name: 'response_status_code', fieldContext: 'span' },
{ name: 'http.route', fieldContext: 'attribute' },
{ name: 'cache.hit', fieldContext: 'attribute' },
],
},
config: { addColumn: { onRemove: jest.fn() } },
}),
}));
describe('Traces ListView - span drawer navigation', () => {
beforeEach(() => {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
mockSuccess();
mockTraceSummary({});
});
afterEach(() => {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
server.resetHandlers();
});
it('opens the clicked span and steps through rows with the arrow keys', async () => {
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
const drawer = await screen.findByTestId('span-details-drawer');
expect(within(drawer).getByText('HTTP GET')).toBeInTheDocument();
expect(within(drawer).getByTestId('span-details-trace-id')).toHaveTextContent(
'0000000000000000344ded1387b08a7e',
);
await user.keyboard('{ArrowDown}');
await expect(
within(drawer).findByText('authenticate_check_db'),
).resolves.toBeInTheDocument();
await user.keyboard('{ArrowUp}');
await expect(
within(drawer).findByText('HTTP GET'),
).resolves.toBeInTheDocument();
});
it('closes the drawer', async () => {
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
await screen.findByTestId('span-details-drawer');
await user.click(screen.getByTestId('span-drawer-close'));
await waitFor(() =>
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument(),
);
});
it('stays open on a press in the drawer or the list, and closes on one outside', async () => {
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
const drawer = await screen.findByTestId('span-details-drawer');
await user.click(drawer);
// Hovering re-renders the row, so the press needs a fresh query.
await user.hover(screen.getByText('authenticate_check_db'));
await user.click(screen.getByText('authenticate_check_db'));
await expect(
within(drawer).findByText('authenticate_check_db'),
).resolves.toBeInTheDocument();
await user.click(document.body);
await waitFor(() =>
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument(),
);
});
});

View File

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

View File

@@ -1,110 +0,0 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import ListView from '../index';
const BASE_URL = ENVIRONMENT.baseURL;
export const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
export const listRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
name: 'HTTP GET',
duration_nano: 55306000,
http_method: 'GET',
response_status_code: '200',
span_id: '772c4d29dd9076ac',
trace_id: '0000000000000000344ded1387b08a7e',
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
resource: { 'service.name': 'frontend' },
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
name: 'authenticate_check_db',
duration_nano: 790949390,
// empty status fields to assert the "-" cell
http_method: '',
response_status_code: '',
span_id: '5704353737b6778e',
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
attributes: {},
resource: { 'service.name': 'demo-app' },
},
},
];
export const listResponse = (rows: unknown[]): Record<string, unknown> => ({
data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } },
});
export const TRACE_SUMMARY_URL = `${BASE_URL}/api/v1/traces/:traceId/summary`;
export const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
export type TraceBounds = Record<string, { start: number; end: number }>;
// Traces missing from `bounds` get a 404, like a trace with no summary row.
export const mockTraceSummary = (bounds: TraceBounds): void => {
server.use(
rest.get(TRACE_SUMMARY_URL, (req, res, ctx) => {
const traceBounds = bounds[req.params.traceId as string];
if (!traceBounds) {
return res(ctx.status(404), ctx.json({ status: 'error' }));
}
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
startTimestampMillis: traceBounds.start,
endTimestampMillis: traceBounds.end,
hasMissingSpans: false,
rootServiceEntryPoint: '',
rootServiceName: '',
rootSpanStatusCode: '',
totalErrorSpansCount: 0,
totalSpansCount: 1,
},
}),
);
}),
);
};
export const mockSuccess = (rows: unknown[] = listRows): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listResponse(rows))),
),
);
};
export const renderListView = (
stagedQuery: Query = initialQueriesMap.traces,
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<ListView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>
</VirtuosoMockContext.Provider>,
{},
{
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.LIST,
stagedQuery,
currentQuery: stagedQuery,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
);

View File

@@ -1,20 +1,17 @@
import { ENVIRONMENT } from 'constants/env';
import {
initialQueriesMap,
initialQueryBuilderFormTraceOperatorValues,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen } from 'tests/test-utils';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import {
listResponse,
listRows,
mockSuccess,
QUERY_RANGE_URL,
renderListView,
} from './__tests__/testUtils';
import ListView from './index';
// globalTime starts with loading:true, which gates the list query. Force just that
// slice's loading to false so the query fires; every other selector is untouched.
@@ -52,6 +49,74 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
}),
}));
const BASE_URL = ENVIRONMENT.baseURL;
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
const listRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
name: 'HTTP GET',
duration_nano: 55306000,
http_method: 'GET',
response_status_code: '200',
span_id: '772c4d29dd9076ac',
trace_id: '0000000000000000344ded1387b08a7e',
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
resource: { 'service.name': 'frontend' },
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
name: 'authenticate_check_db',
duration_nano: 790949390,
// empty status fields to assert the "-" cell
http_method: '',
response_status_code: '',
span_id: '5704353737b6778e',
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
attributes: {},
resource: { 'service.name': 'demo-app' },
},
},
];
const listResponse = (rows: unknown[]): Record<string, unknown> => ({
data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } },
});
const mockSuccess = (rows: unknown[] = listRows): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listResponse(rows))),
),
);
};
const renderListView = (
stagedQuery: Query = initialQueriesMap.traces,
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<ListView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>
</VirtuosoMockContext.Provider>,
{},
{
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.LIST,
stagedQuery,
currentQuery: stagedQuery,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
);
describe('Traces ListView - Data Loaded', () => {
afterEach(() => {
server.resetHandlers();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -475,17 +475,12 @@ export function QueryBuilderProvider({
const createNewBuilderQuery = useCallback(
(queries: IBuilderQuery[]): IBuilderQuery => {
const existNames = queries.map((item) => item.queryName);
// Hosts without a fixed data source (the panel editor) continue the
// previous query's signal rather than resetting to metrics.
const previous = queries[queries.length - 1];
const initialBuilderQuery =
initialQueryBuilderFormValuesMap[
initialDataSource || previous?.dataSource || DataSource.METRICS
];
initialQueryBuilderFormValuesMap[initialDataSource || DataSource.METRICS];
const newQuery: IBuilderQuery = {
...initialBuilderQuery,
source: previous?.source || '',
source: queries?.[0]?.source || '',
builderQueryType: queries?.[0]?.builderQueryType,
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
expression: createNewBuilderItemName({

View File

@@ -1,104 +0,0 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
const renderQueryBuilder = (
initialQuery: Query,
): ReturnType<
typeof renderHook<ReturnType<typeof useQueryBuilder>, unknown>
> => {
const hook = renderHook(() => useQueryBuilder(), {
wrapper: AllTheProviders,
});
act(() => {
hook.result.current.initQueryBuilderData(initialQuery);
});
return hook;
};
describe('createNewBuilderQuery data source', () => {
it.each([DataSource.LOGS, DataSource.TRACES, DataSource.METRICS])(
'continues the previous %s query when the host fixes no data source',
(dataSource) => {
const { result } = renderQueryBuilder(initialQueriesMap[dataSource]);
act(() => {
result.current.addNewBuilderQuery();
});
const [, added] = result.current.currentQuery.builder.queryData;
expect(added.dataSource).toBe(dataSource);
expect(added.aggregations).toStrictEqual(
initialQueryBuilderFormValuesMap[dataSource].aggregations,
);
},
);
it('follows the last query when queries mix signals', () => {
const { result } = renderQueryBuilder(initialQueriesMap.logs);
act(() => {
result.current.addNewBuilderQuery();
});
act(() => {
result.current.handleSetQueryData(1, {
...initialQueryBuilderFormValuesMap.traces,
queryName: 'B',
expression: 'B',
});
});
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData[2].dataSource).toBe(
DataSource.TRACES,
);
});
it('takes the source from the last query, not the first', () => {
const [metricsQuery] = initialQueriesMap.metrics.builder.queryData;
const { result } = renderQueryBuilder({
...initialQueriesMap.metrics,
builder: {
...initialQueriesMap.metrics.builder,
queryData: [
{ ...metricsQuery, source: '' },
{ ...metricsQuery, queryName: 'B', expression: 'B', source: 'meter' },
],
},
});
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData[2]).toMatchObject({
dataSource: DataSource.METRICS,
source: 'meter',
});
});
it('uses the host’s fixed data source over the previous query', () => {
const { result } = renderQueryBuilder(initialQueriesMap.metrics);
act(() => {
result.current.handleSetConfig(PANEL_TYPES.TIME_SERIES, DataSource.LOGS);
});
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData[1].dataSource).toBe(
DataSource.LOGS,
);
});
});

View File

@@ -1,7 +1,6 @@
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildExplorerNavigationUrl } from '../buildExplorerNavigationUrl';
@@ -20,14 +19,4 @@ describe('buildExplorerNavigationUrl', () => {
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
const url = buildExplorerNavigationUrl(
ROUTES.LOGS_EXPLORER,
initialQueriesMap[DataSource.LOGS],
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
);
expect(url).toContain(`${QueryParams.panelTypes}=%22table%22`);
});
});

View File

@@ -4,13 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
@@ -26,14 +22,12 @@ import (
const unmappedModelsLookback = time.Hour
type module struct {
store llmpricingruletypes.Store
querier querier.Querier
telemetryMetadataStore telemetrytypes.MetadataStore
logger *slog.Logger
store llmpricingruletypes.Store
querier querier.Querier
}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier, telemetryMetadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings) llmpricingrule.Module {
return &module{store: store, querier: querier, telemetryMetadataStore: telemetryMetadataStore, logger: providerSettings.Logger}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
return &module{store: store, querier: querier}
}
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
@@ -57,47 +51,13 @@ func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID)
return nil, err
}
return llmpricingruletypes.UnmappedModels(models, rules), nil
}
// Collect keeps the rule counts even when ClickHouse is unreachable: the
// counts that depend on it are skipped so the reporter retains their last value.
// Models come from the traces tag table rather than a span scan, so the
// unmapped count covers the metadata lookback instead of the last hour.
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
rules, err := module.listAllRules(ctx, orgID)
if err != nil {
return nil, err
}
stats := llmpricingruletypes.NewStatsFromRules(rules)
selectors := aiobservabilitytypes.StatsFieldValueSelectors(time.Now())
results := make([]*telemetrytypes.TelemetryFieldValues, len(selectors))
var lookups sync.WaitGroup
for i, selector := range selectors {
lookups.Go(func() {
fieldValues, _, err := module.telemetryMetadataStore.GetAllValues(ctx, orgID, selector)
if err != nil {
module.logger.DebugContext(ctx, "failed to collect gen_ai attribute stats", slog.String("key", selector.Name), errors.Attr(err))
return
}
results[i] = fieldValues
})
}
lookups.Wait()
values := make(map[string]*telemetrytypes.TelemetryFieldValues, len(selectors))
for i, selector := range selectors {
if results[i] != nil {
values[selector.Name] = results[i]
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
for _, m := range models {
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
unmapped = append(unmapped, m)
}
}
maps.Copy(stats, aiobservabilitytypes.NewStatsFromFieldValues(values))
if models := values[aiobservabilitytypes.GenAIRequestModel]; models != nil {
maps.Copy(stats, llmpricingruletypes.NewStatsFromModels(models.StringValues, rules))
}
return stats, nil
return unmapped, nil
}
// CreateOrUpdate saves a batch of pricing rules. isOverride decides how a rule

View File

@@ -5,7 +5,6 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -14,8 +13,6 @@ type Module interface {
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
agentConf.AgentFeature
statsreporter.StatsCollector
List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error)
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)

View File

@@ -164,7 +164,7 @@ func NewModules(
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, schemaMetadataStore, fl), providerSettings, config.TraceDetail),
SpanMapper: spanMapper,
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier, telemetryMetadataStore, providerSettings),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
Tag: tagModule,
}
}

View File

@@ -572,7 +572,6 @@ func New(
cloudIntegrationModule,
modules.LogsPipeline,
modules.InfraMonitoring,
modules.LLMPricingRule,
querier,
authz,
}

View File

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

View File

@@ -1,55 +0,0 @@
package aiobservabilitytypes
import (
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
// statsLookback reaches into the previous day; the metadata store rounds
// the start down to a day boundary.
statsLookback = 24 * time.Hour
// statsModelValuesLimit caps the model list, which also feeds the
// unmapped model count. One value is enough for the other keys.
statsModelValuesLimit = 100
)
// statsAttributeKeys are the gen_ai span attributes whose recent presence the
// stats reporter tracks under telemetry.traces.<key>.exists.
var statsAttributeKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
// StatsFieldValueSelectors look up each tracked attribute's string values
// since the start of the previous day.
func StatsFieldValueSelectors(now time.Time) []*telemetrytypes.FieldValueSelector {
selectors := make([]*telemetrytypes.FieldValueSelector, 0, len(statsAttributeKeys))
for _, key := range statsAttributeKeys {
limit := 1
if key == GenAIRequestModel {
limit = statsModelValuesLimit
}
selectors = append(selectors, &telemetrytypes.FieldValueSelector{
FieldKeySelector: &telemetrytypes.FieldKeySelector{
StartUnixMilli: now.Add(-statsLookback).UnixMilli(),
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
Name: key,
},
Limit: limit,
})
}
return selectors
}
// NewStatsFromFieldValues reports whether each looked-up key had a value. Keys
// absent from values are left out, so a failed lookup never reports false.
func NewStatsFromFieldValues(values map[string]*telemetrytypes.TelemetryFieldValues) map[string]any {
stats := make(map[string]any, len(values))
for _, key := range statsAttributeKeys {
if fieldValues := values[key]; fieldValues != nil {
stats["telemetry.traces."+key+".exists"] = len(fieldValues.StringValues) > 0
}
}
return stats
}

View File

@@ -1,73 +0,0 @@
package aiobservabilitytypes
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
func TestNewStatsFromFieldValues(t *testing.T) {
testCases := []struct {
name string
values map[string]*telemetrytypes.TelemetryFieldValues
want map[string]any
}{
{
name: "NoLookups_NothingReported",
want: map[string]any{},
},
{
name: "EveryKeySeen_AllTrue",
values: map[string]*telemetrytypes.TelemetryFieldValues{
GenAIRequestModel: {StringValues: []string{"gpt-5-nano"}},
GenAIToolName: {StringValues: []string{"search"}},
GenAIAgentName: {StringValues: []string{"planner"}},
},
want: map[string]any{
"telemetry.traces.gen_ai.request.model.exists": true,
"telemetry.traces.gen_ai.tool.name.exists": true,
"telemetry.traces.gen_ai.agent.name.exists": true,
},
},
{
name: "EmptyAndMissingLookups_OnlyLookedUpKeysReported",
values: map[string]*telemetrytypes.TelemetryFieldValues{
GenAIRequestModel: {StringValues: []string{"gpt-5-nano"}},
GenAIToolName: {},
GenAIOperationName: {StringValues: []string{"chat"}},
},
want: map[string]any{
"telemetry.traces.gen_ai.request.model.exists": true,
"telemetry.traces.gen_ai.tool.name.exists": false,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, NewStatsFromFieldValues(testCase.values))
})
}
}
func TestStatsFieldValueSelectors(t *testing.T) {
now := time.Date(2026, 11, 3, 10, 0, 0, 0, time.UTC)
selectors := StatsFieldValueSelectors(now)
require.Len(t, selectors, 3)
for _, selector := range selectors {
assert.Equal(t, now.Add(-24*time.Hour).UnixMilli(), selector.StartUnixMilli, selector.Name)
assert.Equal(t, telemetrytypes.SignalTraces, selector.Signal, selector.Name)
assert.Equal(t, telemetrytypes.FieldContextAttribute, selector.FieldContext, selector.Name)
assert.Equal(t, telemetrytypes.FieldDataTypeString, selector.FieldDataType, selector.Name)
}
assert.Equal(t, GenAIRequestModel, selectors[0].Name)
assert.Equal(t, 100, selectors[0].Limit)
assert.Equal(t, 1, selectors[1].Limit)
assert.Equal(t, 1, selectors[2].Limit)
}

View File

@@ -1,7 +1,6 @@
package dashboardtypes
import (
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -11,7 +10,6 @@ const (
statKeyPanelTracesCount = "dashboard.panels.traces.count"
statKeyPanelMetricsCount = "dashboard.panels.metrics.count"
statKeyPanelLogsCount = "dashboard.panels.logs.count"
statKeyPanelAICount = "dashboard.panels.ai.count"
)
// panelSignalStatKeys maps a builder query's signal to the stat it contributes
@@ -24,17 +22,14 @@ var panelSignalStatKeys = map[telemetrytypes.Signal]string{
}
// NewStatsFromStorableDashboards reports the stats of stored dashboards. Rows that
// do not decode as v2 contribute to dashboard.count only. System dashboards are
// provisioned into every org, so they are left out of every count.
// do not decode as v2 contribute to dashboard.count only.
func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]any {
stats := newStats()
stats := newPanelStats()
for _, dashboard := range dashboards {
if dashboard == nil || dashboard.Source == SourceSystem {
if dashboard == nil {
continue
}
incrementStat(stats, statKeyDashboardCount)
dashboardV2, err := dashboard.ToDashboardV2(nil)
if err != nil {
continue
@@ -42,6 +37,7 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
addPanelStats(&dashboardV2.Spec, stats)
}
stats[statKeyDashboardCount] = int64(len(dashboards))
return stats
}
@@ -49,26 +45,24 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
// created, straight off the postable spec — the create path has no reason to make
// a storable round-trip just to be counted.
func NewStatsFromPostableDashboardV2(postable PostableDashboardV2) map[string]any {
stats := newStats()
stats[statKeyDashboardCount] = int64(1)
stats := newPanelStats()
addPanelStats(&postable.Spec, stats)
stats[statKeyDashboardCount] = int64(1)
return stats
}
func newStats() map[string]any {
func newPanelStats() map[string]any {
return map[string]any{
statKeyDashboardCount: int64(0),
statKeyPanelCount: int64(0),
statKeyPanelTracesCount: int64(0),
statKeyPanelMetricsCount: int64(0),
statKeyPanelLogsCount: int64(0),
statKeyPanelAICount: int64(0),
}
}
// addPanelStats counts the panels of a v2 spec, and each panel's queries against
// the signal they read. AI queries count only towards the AI stat.
// the signal they read.
func addPanelStats(spec *DashboardSpec, stats map[string]any) {
for _, panel := range spec.Panels {
if panel == nil {
@@ -82,10 +76,6 @@ func addPanelStats(spec *DashboardSpec, stats map[string]any) {
continue
}
for _, envelope := range composite.Queries {
if envelope.Type == qb.QueryTypeBuilderAI {
incrementStat(stats, statKeyPanelAICount)
continue
}
if key, ok := panelSignalStatKeys[envelope.GetSignal()]; ok {
incrementStat(stats, key)
}

View File

@@ -117,60 +117,20 @@ func TestNewStatsFromStorableDashboardsCountsCompositeSubQueries(t *testing.T) {
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
func statsAIBuilderQuery() string {
return `{
// An AI builder query is always a traces query, so it counts towards traces.
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
aiBuilder := `{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}}}
}`
}
// An AI builder query reads traces but counts only towards the AI stat.
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(aiBuilder))
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
assert.Equal(t, int64(1), stats[statKeyPanelAICount])
}
func TestNewStatsFromStorableDashboardsCountsAICompositeSubQueries(t *testing.T) {
composite := `{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {"queries": [
{"type": "builder_ai_query", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}},
{"type": "builder_query", "spec": ` + statsBuilderQuerySpec("B", "logs") + `}
]}}}
}`
dashboard := newStatsStorableV2(t, `
"p1": `+statsPanel(composite)+`,
"p2": `+statsPanel(statsAIBuilderQuery())+`
`)
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
assert.Equal(t, int64(2), stats[statKeyPanelCount])
assert.Equal(t, int64(2), stats[statKeyPanelAICount])
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
func TestNewStatsFromStorableDashboardsSkipsSystemDashboards(t *testing.T) {
system := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
system.Source = SourceSystem
system.Name = SystemDashboardNamePrefix + "overview"
user := newStatsStorableV2(t, `"p1": `+statsPanel(statsBuilderQuery("logs")))
stats := NewStatsFromStorableDashboards([]*StorableDashboard{system, user})
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
// promql and clickhouse queries carry no signal, so they land in the panel total
@@ -255,17 +215,6 @@ func TestNewStatsFromPostableDashboardV2(t *testing.T) {
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
}
func TestNewStatsFromPostableDashboardV2CountsAIBuilderQueries(t *testing.T) {
postable := newStatsPostableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
stats := NewStatsFromPostableDashboardV2(postable)
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(1), stats[statKeyPanelAICount])
}
func TestNewStatsFromPostableDashboardV2WithNoPanels(t *testing.T) {
@@ -284,5 +233,4 @@ func TestNewStatsFromStorableDashboardsWithNoDashboards(t *testing.T) {
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
}

View File

@@ -277,13 +277,3 @@ func ModelMatchesAnyRule(model string, rules []*LLMPricingRule) bool {
}
return false
}
func UnmappedModels(models []*UnmappedModel, rules []*LLMPricingRule) []*UnmappedModel {
unmapped := make([]*UnmappedModel, 0, len(models))
for _, model := range models {
if !ModelMatchesAnyRule(model.ModelName, rules) {
unmapped = append(unmapped, model)
}
}
return unmapped
}

View File

@@ -1,31 +0,0 @@
package llmpricingruletypes
// NewStatsFromRules reports the rules users added themselves and the synced
// defaults users pinned.
func NewStatsFromRules(rules []*LLMPricingRule) map[string]any {
custom, overrides := int64(0), int64(0)
for _, rule := range rules {
switch {
case rule.SourceID == nil:
custom++
case rule.IsOverride:
overrides++
}
}
return map[string]any{
"llmpricingrule.custom.count": custom,
"llmpricingrule.override.count": overrides,
}
}
func NewStatsFromModels(models []string, rules []*LLMPricingRule) map[string]any {
unmapped := int64(0)
for _, model := range models {
if !ModelMatchesAnyRule(model, rules) {
unmapped++
}
}
return map[string]any{"llmpricingrule.unmapped_model.count": unmapped}
}

View File

@@ -1,65 +0,0 @@
package llmpricingruletypes
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/SigNoz/signoz/pkg/valuer"
)
func TestNewStatsFromRules(t *testing.T) {
sourceID := valuer.GenerateUUID()
testCases := []struct {
name string
rules []*LLMPricingRule
want map[string]any
}{
{
name: "NoRules",
want: map[string]any{
"llmpricingrule.custom.count": int64(0),
"llmpricingrule.override.count": int64(0),
},
},
{
name: "CustomPinnedAndSyncedRules_CountedSeparately",
rules: []*LLMPricingRule{
{IsOverride: true},
{IsOverride: false},
{SourceID: &sourceID, IsOverride: true},
{SourceID: &sourceID, IsOverride: false},
},
want: map[string]any{
"llmpricingrule.custom.count": int64(2),
"llmpricingrule.override.count": int64(1),
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, NewStatsFromRules(testCase.rules))
})
}
}
func TestNewStatsFromModels(t *testing.T) {
rules := []*LLMPricingRule{{ModelPattern: StringSlice{"gpt-*"}}}
testCases := []struct {
name string
models []string
want int64
}{
{name: "NoModels", want: 0},
{name: "PricedAndUnpricedModels_UnpricedCounted", models: []string{"gpt-5-nano", "claude-haiku-5-5", "gemini-3"}, want: 2},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, map[string]any{"llmpricingrule.unmapped_model.count": testCase.want}, NewStatsFromModels(testCase.models, rules))
})
}
}

View File

@@ -88,6 +88,12 @@ def pytest_addoption(parser: pytest.Parser):
default=False,
help="Build and run with web. Run pytest --basetemp=./tmp/ -vv --with-web src/bootstrap/setup::test_setup to setup your local dev environment with web.",
)
parser.addoption(
"--prebuilt-signoz",
action="store_true",
default=False,
help="Run the signoz:integration image already in the local image store instead of building it. The image must be built with ZEUSURL=http://zeus:8080.",
)
parser.addoption(
"--sqlstore-provider",
action="store",

View File

@@ -259,20 +259,3 @@ neither antd 5.11 nor `@rc-component/trigger` closes it on Escape, so
it, but when the popover covered the Advanced Options header, that click waited
on "intercepts pointer events" for the rest of the budget. The driver now closes
the popover through its trigger and waits for it to go.
## dnd-kit stops clicks right after a drop
`app-shell/sidenav` TC-03 needed a retry in half its CI jobs and failed all
three attempts in four of 22. dnd-kit 6.1 adds a capture-phase click handler on
`document` that stops propagation once a drag activates, and removes it 50 ms
after the drop. `saveShortcuts` clicked Save inside that window, so the modal
stayed open and no preference request went out. It now clicks again while the
dialog is still open.
## The Kafka configuration button drops clicks while loading
`messaging-queues/kafka` TC-01 failed its first attempt in four of 20 CI jobs.
The health check button passes `loading` to an antd 5.11 `Button` while the
three onboarding status queries fetch, and a loading antd button ignores clicks
without being disabled, so Playwright clicks it and nothing opens.
`openHealthCheck` clicks again until the dialog shows.

View File

@@ -258,15 +258,9 @@ export function messagingQueuesDriver(page: Page): MessagingQueues {
openHealthCheck: async () => {
// "Configuration", or "Missing Configuration (N)" when checks fail.
const button = page.getByRole('button', { name: /Configuration/ });
await page.getByRole('button', { name: /Configuration/ }).click();
const dialog = page.getByRole('dialog');
// The button drops clicks while its checks load, and is not disabled.
await expect(async () => {
if (!(await dialog.isVisible())) {
await button.click({ timeout: 5_000 });
}
await expect(dialog).toBeVisible({ timeout: 1_000 });
}).toPass();
await expect(dialog).toBeVisible();
return dialog;
},

View File

@@ -635,16 +635,7 @@ export function shellDriver(page: Page): Shell {
saveShortcuts: async () => {
const saved = page.waitForResponse(isPreferencePut('nav_shortcuts'));
const dialog = page
.getByRole('dialog')
.filter({ hasText: 'Manage Shortcuts' });
// dnd-kit stops every click for 50 ms after a drop.
await expect(async () => {
if (await dialog.isVisible()) {
await page.getByTestId('save-changes-btn').click({ timeout: 5_000 });
}
await expect(dialog).toBeHidden({ timeout: 1_000 });
}).toPass();
await page.getByTestId('save-changes-btn').click();
const response = await saved;
expect(response.ok()).toBe(true);
return (response.request().postDataJSON() as { value: string[] }).value;

View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
echo "e2e-signoz-$(git rev-parse HEAD:cmd HEAD:ee HEAD:pkg HEAD:go.mod HEAD:go.sum HEAD:.dockerignore HEAD:.github/workflows/cacheci.yml | sha256sum | cut -c1-32)"

View File

@@ -28,6 +28,8 @@ def zeus(
def create() -> types.TestContainerDocker:
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
container.with_network(network)
# The zeus address in images built for --prebuilt-signoz.
container.with_network_aliases("zeus")
container.start()
return types.TestContainerDocker(

View File

@@ -58,23 +58,24 @@ def create_signoz(
# The docker CLI is required: the Dockerfiles use BuildKit cache
# mounts, which docker-py does not support.
subprocess.run(
[
"docker",
"build",
"--file",
str(context / dockerfile_path),
"--tag",
"signoz:integration",
"--build-arg",
f"TARGETARCH={arch}",
"--build-arg",
f"ZEUSURL={zeus.container_configs['8080'].base()}",
str(context),
],
check=True,
env=os.environ | {"DOCKER_BUILDKIT": "1"},
)
if not pytestconfig.getoption("--prebuilt-signoz"):
subprocess.run(
[
"docker",
"build",
"--file",
str(context / dockerfile_path),
"--tag",
"signoz:integration",
"--build-arg",
f"TARGETARCH={arch}",
"--build-arg",
f"ZEUSURL={zeus.container_configs['8080'].base()}",
str(context),
],
check=True,
env=os.environ | {"DOCKER_BUILDKIT": "1"},
)
env = (
{