mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-05 16:00:41 +00:00
Compare commits
24 Commits
nv/offset-
...
test/qb-e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c92ef6079a | ||
|
|
436a13430a | ||
|
|
825991864d | ||
|
|
d19b9afff4 | ||
|
|
4cb022dd06 | ||
|
|
9b976c81b1 | ||
|
|
cb48993c8d | ||
|
|
07a59a2560 | ||
|
|
c3e0de9eb0 | ||
|
|
c31cb99273 | ||
|
|
5497146f98 | ||
|
|
13625708a0 | ||
|
|
a415902357 | ||
|
|
8d97b8ef74 | ||
|
|
9dda0825bd | ||
|
|
99f9e634eb | ||
|
|
7f1b6bd594 | ||
|
|
98e6975358 | ||
|
|
5b646396a9 | ||
|
|
4ced3b0c13 | ||
|
|
3e20fb09f7 | ||
|
|
dffb844621 | ||
|
|
117183b172 | ||
|
|
6c490be536 |
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -302,3 +302,8 @@ go.mod @therealpandey
|
||||
/frontend/.storybook/ @H4ad
|
||||
/frontend/src/storybook/ @H4ad
|
||||
/.claude/skills/signoz-page-story/ @H4ad
|
||||
|
||||
## AI Assistant
|
||||
/frontend/src/container/AIAssistant/ @SigNoz/nerve @SigNoz/frontend-maintainers
|
||||
/frontend/src/hooks/aiAssistant/ @SigNoz/nerve @SigNoz/frontend-maintainers
|
||||
/frontend/src/pages/AIAssistantPage/ @SigNoz/nerve @SigNoz/frontend-maintainers
|
||||
@@ -4027,9 +4027,7 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
to fetch the next page.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -4066,11 +4064,10 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -4082,10 +4079,8 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. On raw requests the skip counts from where a cursor resumes,
|
||||
and the skipped rows are still read, so prefer cursor for paging.
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10107,10 +10102,6 @@ components:
|
||||
description: The unique name of the formula, such as F1, used by selectedQueryName
|
||||
and by panels. Referencing a formula from another formula is not reliable.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of ordered series or rows to skip before the page
|
||||
starts.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the formula result.
|
||||
items:
|
||||
@@ -10136,9 +10127,7 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
to fetch the next page.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -10174,11 +10163,10 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -10190,10 +10178,8 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. On raw requests the skip counts from where a cursor resumes,
|
||||
and the skipped rows are still read, so prefer cursor for paging.
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10255,9 +10241,7 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
to fetch the next page.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -10294,11 +10278,10 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -10310,10 +10293,8 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. On raw requests the skip counts from where a cursor resumes,
|
||||
and the skipped rows are still read, so prefer cursor for paging.
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10375,9 +10356,7 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
to fetch the next page.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -10414,11 +10393,10 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -10430,10 +10408,8 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. On raw requests the skip counts from where a cursor resumes,
|
||||
and the skipped rows are still read, so prefer cursor for paging.
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10534,8 +10510,8 @@ components:
|
||||
description: The unique name of the trace operator query.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of raw or trace rows, or of ordered series or rows
|
||||
for time_series and scalar requests, to skip before the page starts.
|
||||
description: The number of raw results to skip before returning. Aggregation
|
||||
requests ignore it.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the matched traces; only span_count and trace_duration
|
||||
|
||||
@@ -143,7 +143,12 @@ const preview: Preview = {
|
||||
'Logs',
|
||||
['Explorer', 'Live Tail', 'Pipelines', 'Settings'],
|
||||
'Traces',
|
||||
['Explorer', 'Trace Details', 'Funnel Details'],
|
||||
[
|
||||
'Explorer',
|
||||
['Overview', 'Span Drawer'],
|
||||
'Trace Details',
|
||||
'Funnel Details',
|
||||
],
|
||||
'Metrics',
|
||||
['Explorer'],
|
||||
'Infrastructure',
|
||||
|
||||
@@ -5273,7 +5273,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5306,7 +5306,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5320,7 +5320,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5665,7 +5665,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5698,7 +5698,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5712,7 +5712,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5845,7 +5845,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5878,7 +5878,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5892,7 +5892,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5944,7 +5944,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5977,7 +5977,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5991,7 +5991,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -7064,11 +7064,6 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
* @description The unique name of the formula, such as F1, used by selectedQueryName and by panels. Referencing a formula from another formula is not reliable.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of ordered series or rows to skip before the page starts.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description The sort order of the formula result.
|
||||
@@ -7151,7 +7146,7 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of raw or trace rows, or of ordered series or rows for time_series and scalar requests, to skip before the page starts.
|
||||
* @description The number of raw results to skip before returning. Aggregation requests ignore it.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.drawer {
|
||||
overflow: hidden;
|
||||
border-color: var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: -4px 10px 16px 2px var(--l1-shadow);
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.details-panel-drawer {
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Drawer, DrawerContent } from '@signozhq/ui/drawer';
|
||||
import cx from 'classnames';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
|
||||
import './DetailsPanelDrawer.styles.scss';
|
||||
import styles from './DetailsPanelDrawer.module.scss';
|
||||
|
||||
interface DetailsPanelDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function DetailsPanelDrawer({
|
||||
@@ -14,22 +17,41 @@ function DetailsPanelDrawer({
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
testId,
|
||||
}: DetailsPanelDrawerProps): JSX.Element {
|
||||
return (
|
||||
<DrawerWrapper
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showOverlay={false}
|
||||
className={`details-panel-drawer ${className || ''}`}
|
||||
modal={false}
|
||||
>
|
||||
<div className="details-panel-drawer__body">{children}</div>
|
||||
</DrawerWrapper>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DrawerContent
|
||||
forceMount
|
||||
direction="right"
|
||||
width="extra-wide"
|
||||
showOverlay={false}
|
||||
// Outside presses are left to the page, which knows what counts as inside.
|
||||
onInteractOutside={(e): void => e.preventDefault()}
|
||||
className={cx(styles.drawer, className)}
|
||||
testId={testId}
|
||||
>
|
||||
<div className={styles.body}>{children}</div>
|
||||
</DrawerContent>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
DetailsPanelDrawer.defaultProps = {
|
||||
className: undefined,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default DetailsPanelDrawer;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export type UseRowNavigationParams<TData> = {
|
||||
rows: TData[];
|
||||
activeIndex: number;
|
||||
onNavigate: (row: TData, index: number) => void;
|
||||
};
|
||||
|
||||
export type UseRowNavigationResult = {
|
||||
goToPrev: () => void;
|
||||
goToNext: () => void;
|
||||
isPrevDisabled: boolean;
|
||||
isNextDisabled: boolean;
|
||||
};
|
||||
|
||||
/** Steps the active row through the loaded rows; stops at either end. */
|
||||
export function useRowNavigation<TData>({
|
||||
rows,
|
||||
activeIndex,
|
||||
onNavigate,
|
||||
}: UseRowNavigationParams<TData>): UseRowNavigationResult {
|
||||
const isPrevDisabled = activeIndex <= 0;
|
||||
const isNextDisabled = activeIndex < 0 || activeIndex >= rows.length - 1;
|
||||
|
||||
const goToPrev = useCallback((): void => {
|
||||
if (!isPrevDisabled) {
|
||||
onNavigate(rows[activeIndex - 1], activeIndex - 1);
|
||||
}
|
||||
}, [isPrevDisabled, onNavigate, rows, activeIndex]);
|
||||
|
||||
const goToNext = useCallback((): void => {
|
||||
if (!isNextDisabled) {
|
||||
onNavigate(rows[activeIndex + 1], activeIndex + 1);
|
||||
}
|
||||
}, [isNextDisabled, onNavigate, rows, activeIndex]);
|
||||
|
||||
return { goToPrev, goToNext, isPrevDisabled, isNextDisabled };
|
||||
}
|
||||
@@ -46,4 +46,5 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
|
||||
TRACES_SPAN_DRAWER_ENABLED = 'TRACES_SPAN_DRAWER_ENABLED',
|
||||
}
|
||||
|
||||
@@ -6,3 +6,7 @@
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.drawerScope {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { mockSuccess, mockTraceSummary, renderListView } from './testUtils';
|
||||
|
||||
// globalTime starts with loading:true, which gates the list query. Force just that
|
||||
// slice's loading to false so the query fires; every other selector is untouched.
|
||||
jest.mock('react-redux', () => {
|
||||
const actual = jest.requireActual('react-redux');
|
||||
return {
|
||||
...actual,
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown => {
|
||||
const result = actual.useSelector(selector);
|
||||
if (result && typeof result === 'object' && 'loading' in result) {
|
||||
return { ...result, loading: false };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// List columns come from the options menu (server-synced preferences). Pin them
|
||||
// so the expected columns render, independent of that API.
|
||||
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): unknown => ({
|
||||
options: {
|
||||
selectColumns: [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name', fieldContext: 'span' },
|
||||
{ name: 'duration_nano', fieldContext: 'span' },
|
||||
{ name: 'http_method', fieldContext: 'span' },
|
||||
{ name: 'response_status_code', fieldContext: 'span' },
|
||||
{ name: 'http.route', fieldContext: 'attribute' },
|
||||
{ name: 'cache.hit', fieldContext: 'attribute' },
|
||||
],
|
||||
},
|
||||
config: { addColumn: { onRemove: jest.fn() } },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Traces ListView - span drawer navigation', () => {
|
||||
beforeEach(() => {
|
||||
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
|
||||
mockSuccess();
|
||||
mockTraceSummary({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('opens the clicked span and steps through rows with the arrow keys', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderListView();
|
||||
|
||||
await user.click(await screen.findByText('HTTP GET'));
|
||||
|
||||
const drawer = await screen.findByTestId('span-details-drawer');
|
||||
expect(within(drawer).getByText('HTTP GET')).toBeInTheDocument();
|
||||
expect(within(drawer).getByTestId('span-details-trace-id')).toHaveTextContent(
|
||||
'0000000000000000344ded1387b08a7e',
|
||||
);
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await expect(
|
||||
within(drawer).findByText('authenticate_check_db'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.keyboard('{ArrowUp}');
|
||||
await expect(
|
||||
within(drawer).findByText('HTTP GET'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the drawer', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderListView();
|
||||
|
||||
await user.click(await screen.findByText('HTTP GET'));
|
||||
await screen.findByTestId('span-details-drawer');
|
||||
|
||||
await user.click(screen.getByTestId('span-drawer-close'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('stays open on a press in the drawer or the list, and closes on one outside', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderListView();
|
||||
|
||||
await user.click(await screen.findByText('HTTP GET'));
|
||||
const drawer = await screen.findByTestId('span-details-drawer');
|
||||
|
||||
await user.click(drawer);
|
||||
// Hovering re-renders the row, so the press needs a fresh query.
|
||||
await user.hover(screen.getByText('authenticate_check_db'));
|
||||
await user.click(screen.getByText('authenticate_check_db'));
|
||||
await expect(
|
||||
within(drawer).findByText('authenticate_check_db'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(document.body);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
import {
|
||||
FIVE_MINUTES_IN_MS,
|
||||
listResponse,
|
||||
listRows,
|
||||
mockSuccess,
|
||||
mockTraceSummary,
|
||||
QUERY_RANGE_URL,
|
||||
renderListView,
|
||||
TraceBounds,
|
||||
} from './testUtils';
|
||||
|
||||
// globalTime starts with loading:true, which gates the list query. Force just that
|
||||
// slice's loading to false so the query fires; every other selector is untouched.
|
||||
jest.mock('react-redux', () => {
|
||||
const actual = jest.requireActual('react-redux');
|
||||
return {
|
||||
...actual,
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown => {
|
||||
const result = actual.useSelector(selector);
|
||||
if (result && typeof result === 'object' && 'loading' in result) {
|
||||
return { ...result, loading: false };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// List columns come from the options menu (server-synced preferences). Pin them
|
||||
// so the expected columns render, independent of that API.
|
||||
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): unknown => ({
|
||||
options: {
|
||||
selectColumns: [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name', fieldContext: 'span' },
|
||||
{ name: 'duration_nano', fieldContext: 'span' },
|
||||
{ name: 'http_method', fieldContext: 'span' },
|
||||
{ name: 'response_status_code', fieldContext: 'span' },
|
||||
{ name: 'http.route', fieldContext: 'attribute' },
|
||||
{ name: 'cache.hit', fieldContext: 'attribute' },
|
||||
],
|
||||
},
|
||||
config: { addColumn: { onRemove: jest.fn() } },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Traces ListView - span drawer trace bounds', () => {
|
||||
beforeEach(() => {
|
||||
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
|
||||
mockSuccess();
|
||||
mockTraceSummary({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('requests span logs over whole milliseconds', async () => {
|
||||
const logsRequests: QueryRangeRequestV5[] = [];
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
|
||||
const body = await req.json<QueryRangeRequestV5>();
|
||||
const spec = body.compositeQuery.queries[0]?.spec;
|
||||
if (spec && 'signal' in spec && spec.signal === 'logs') {
|
||||
logsRequests.push(body);
|
||||
// No logs, so no context queries follow to outlive the test.
|
||||
return res(ctx.status(200), ctx.json(listResponse([])));
|
||||
}
|
||||
return res(ctx.status(200), ctx.json(listResponse(listRows)));
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderListView();
|
||||
|
||||
// Its 790.94939 ms duration makes the span window end mid-millisecond.
|
||||
await user.click(await screen.findByText('authenticate_check_db'));
|
||||
|
||||
await waitFor(() => expect(logsRequests.length).toBeGreaterThan(0));
|
||||
logsRequests.forEach(({ start, end }) => {
|
||||
expect(Number.isInteger(start)).toBe(true);
|
||||
expect(Number.isInteger(end)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the trace bounds from the trace summary', async () => {
|
||||
const bounds: TraceBounds = {
|
||||
'0000000000000000344ded1387b08a7e': {
|
||||
start: Date.parse('2024-07-19T08:39:58.700Z'),
|
||||
end: Date.parse('2024-07-19T08:39:58.900Z'),
|
||||
},
|
||||
a364a8e15af3e9a8c866e0528db8b637: {
|
||||
start: Date.parse('2024-07-19T08:39:59.900Z'),
|
||||
end: Date.parse('2024-07-19T08:40:01.000Z'),
|
||||
},
|
||||
};
|
||||
mockTraceSummary(bounds);
|
||||
const logsRequests: {
|
||||
traceId: string;
|
||||
isTraceWide: boolean;
|
||||
start: number;
|
||||
end: number;
|
||||
}[] = [];
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
|
||||
const body = await req.json<QueryRangeRequestV5>();
|
||||
const spec = body.compositeQuery.queries[0]?.spec;
|
||||
if (spec && 'signal' in spec && spec.signal === 'logs') {
|
||||
const traceId =
|
||||
Object.keys(bounds).find((id) => spec.filter?.expression?.includes(id)) ??
|
||||
'';
|
||||
// The payload carries the window in µs.
|
||||
logsRequests.push({
|
||||
traceId,
|
||||
isTraceWide: !spec.filter?.expression?.includes('span_id'),
|
||||
start: body.start / 1e3,
|
||||
end: body.end / 1e3,
|
||||
});
|
||||
return res(ctx.status(200), ctx.json(listResponse([])));
|
||||
}
|
||||
return res(ctx.status(200), ctx.json(listResponse(listRows)));
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderListView();
|
||||
|
||||
await user.click(await screen.findByText('HTTP GET'));
|
||||
const drawer = await screen.findByTestId('span-details-drawer');
|
||||
await expect(
|
||||
within(drawer).findByText(/of total exec time/),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// The next span is in another trace, so it needs that trace's bounds.
|
||||
// Both its span logs and its trace-wide check must land before the test ends.
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
logsRequests
|
||||
.filter(({ traceId }) => traceId === 'a364a8e15af3e9a8c866e0528db8b637')
|
||||
.map(({ isTraceWide }) => isTraceWide),
|
||||
).toStrictEqual(expect.arrayContaining([false, true])),
|
||||
);
|
||||
|
||||
// Logs wait for the bounds: no request ever goes out with another window.
|
||||
logsRequests.forEach(({ traceId, start, end }) => {
|
||||
expect(start).toBe(bounds[traceId].start - FIVE_MINUTES_IN_MS);
|
||||
expect(end).toBe(bounds[traceId].end + FIVE_MINUTES_IN_MS);
|
||||
});
|
||||
});
|
||||
|
||||
it('speaks only for the span when the trace has no summary', async () => {
|
||||
const logsFilters: string[] = [];
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
|
||||
const body = await req.json<QueryRangeRequestV5>();
|
||||
const spec = body.compositeQuery.queries[0]?.spec;
|
||||
if (spec && 'signal' in spec && spec.signal === 'logs') {
|
||||
logsFilters.push(spec.filter?.expression ?? '');
|
||||
return res(ctx.status(200), ctx.json(listResponse([])));
|
||||
}
|
||||
return res(ctx.status(200), ctx.json(listResponse(listRows)));
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderListView();
|
||||
|
||||
await user.click(await screen.findByText('HTTP GET'));
|
||||
const drawer = await screen.findByTestId('span-details-drawer');
|
||||
|
||||
await user.click(within(drawer).getByRole('tab', { name: /logs/i }));
|
||||
await expect(
|
||||
within(drawer).findByText('No logs found for this span.'),
|
||||
).resolves.toBeInTheDocument();
|
||||
// Checked once the 404 has settled; before it, exec time is absent anyway.
|
||||
expect(
|
||||
within(drawer).queryByText(/of total exec time/),
|
||||
).not.toBeInTheDocument();
|
||||
// No trace-wide check: every logs request is scoped to the span.
|
||||
expect(logsFilters.length).toBeGreaterThan(0);
|
||||
logsFilters.forEach((expression) => {
|
||||
expect(expression).toContain('span_id');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import ListView from '../index';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
export const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
|
||||
|
||||
export const listRows = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
name: 'HTTP GET',
|
||||
duration_nano: 55306000,
|
||||
http_method: 'GET',
|
||||
response_status_code: '200',
|
||||
span_id: '772c4d29dd9076ac',
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
|
||||
resource: { 'service.name': 'frontend' },
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:59.949129915Z',
|
||||
data: {
|
||||
name: 'authenticate_check_db',
|
||||
duration_nano: 790949390,
|
||||
// empty status fields to assert the "-" cell
|
||||
http_method: '',
|
||||
response_status_code: '',
|
||||
span_id: '5704353737b6778e',
|
||||
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
|
||||
attributes: {},
|
||||
resource: { 'service.name': 'demo-app' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const listResponse = (rows: unknown[]): Record<string, unknown> => ({
|
||||
data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } },
|
||||
});
|
||||
|
||||
export const TRACE_SUMMARY_URL = `${BASE_URL}/api/v1/traces/:traceId/summary`;
|
||||
export const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
|
||||
export type TraceBounds = Record<string, { start: number; end: number }>;
|
||||
|
||||
// Traces missing from `bounds` get a 404, like a trace with no summary row.
|
||||
export const mockTraceSummary = (bounds: TraceBounds): void => {
|
||||
server.use(
|
||||
rest.get(TRACE_SUMMARY_URL, (req, res, ctx) => {
|
||||
const traceBounds = bounds[req.params.traceId as string];
|
||||
if (!traceBounds) {
|
||||
return res(ctx.status(404), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
startTimestampMillis: traceBounds.start,
|
||||
endTimestampMillis: traceBounds.end,
|
||||
hasMissingSpans: false,
|
||||
rootServiceEntryPoint: '',
|
||||
rootServiceName: '',
|
||||
rootSpanStatusCode: '',
|
||||
totalErrorSpansCount: 0,
|
||||
totalSpansCount: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const mockSuccess = (rows: unknown[] = listRows): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listResponse(rows))),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const renderListView = (
|
||||
stagedQuery: Query = initialQueriesMap.traces,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<ListView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
/>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
stagedQuery,
|
||||
currentQuery: stagedQuery,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
@@ -1,17 +1,20 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormTraceOperatorValues,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { screen } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import ListView from './index';
|
||||
import {
|
||||
listResponse,
|
||||
listRows,
|
||||
mockSuccess,
|
||||
QUERY_RANGE_URL,
|
||||
renderListView,
|
||||
} from './__tests__/testUtils';
|
||||
|
||||
// globalTime starts with loading:true, which gates the list query. Force just that
|
||||
// slice's loading to false so the query fires; every other selector is untouched.
|
||||
@@ -49,74 +52,6 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
|
||||
|
||||
const listRows = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
name: 'HTTP GET',
|
||||
duration_nano: 55306000,
|
||||
http_method: 'GET',
|
||||
response_status_code: '200',
|
||||
span_id: '772c4d29dd9076ac',
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
|
||||
resource: { 'service.name': 'frontend' },
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:59.949129915Z',
|
||||
data: {
|
||||
name: 'authenticate_check_db',
|
||||
duration_nano: 790949390,
|
||||
// empty status fields to assert the "-" cell
|
||||
http_method: '',
|
||||
response_status_code: '',
|
||||
span_id: '5704353737b6778e',
|
||||
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
|
||||
attributes: {},
|
||||
resource: { 'service.name': 'demo-app' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const listResponse = (rows: unknown[]): Record<string, unknown> => ({
|
||||
data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } },
|
||||
});
|
||||
|
||||
const mockSuccess = (rows: unknown[] = listRows): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listResponse(rows))),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderListView = (
|
||||
stagedQuery: Query = initialQueriesMap.traces,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<ListView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
/>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
stagedQuery,
|
||||
currentQuery: stagedQuery,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
|
||||
describe('Traces ListView - Data Loaded', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
|
||||
@@ -29,6 +29,8 @@ import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import SpanDrawer from 'container/TracesExplorer/SpanDrawer/SpanDrawer';
|
||||
import { useSpanDrawer } from 'container/TracesExplorer/SpanDrawer/useSpanDrawer';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
@@ -202,6 +204,8 @@ function ListView({
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const { tableProps, drawerProps, onPressInside } = useSpanDrawer(rows);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(cols.map((c) => c.id));
|
||||
@@ -253,20 +257,26 @@ function ListView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
/>
|
||||
{/* A press in the table or the drawer (a portal) keeps the drawer open. */}
|
||||
<div className={styles.drawerScope} onPointerDownCapture={onPressInside}>
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
{...tableProps}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
/>
|
||||
|
||||
{drawerProps && <SpanDrawer {...drawerProps} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
112
frontend/src/container/TracesExplorer/SpanDrawer/SpanDrawer.tsx
Normal file
112
frontend/src/container/TracesExplorer/SpanDrawer/SpanDrawer.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ButtonGroup } from '@signozhq/ui/button-group';
|
||||
import { ChevronDown, ChevronUp, CornerUpRight } from '@signozhq/icons';
|
||||
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
|
||||
import { HeaderAction } from 'components/DetailsPanel';
|
||||
import type { UseRowNavigationResult } from 'components/TanStackTableView/useRowNavigation';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { SpanDetailVariant } from 'pages/TraceDetailsV3/SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from 'pages/TraceDetailsV3/SpanDetailsPanel/SpanDetailsPanel';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { useArrowKeyNavigation } from './useArrowKeyNavigation';
|
||||
import { toSpanV3 } from './utils';
|
||||
|
||||
export type SpanDrawerProps = {
|
||||
row: TracesTableRow | null;
|
||||
onClose: () => void;
|
||||
navigation: UseRowNavigationResult;
|
||||
};
|
||||
|
||||
function SpanDrawer({
|
||||
row,
|
||||
onClose,
|
||||
navigation,
|
||||
}: SpanDrawerProps): JSX.Element {
|
||||
const { goToPrev, goToNext, isPrevDisabled, isNextDisabled } = navigation;
|
||||
const isOpen = row !== null;
|
||||
|
||||
useArrowKeyNavigation({ enabled: isOpen, onPrev: goToPrev, onNext: goToNext });
|
||||
|
||||
const panelState = useMemo(
|
||||
() => ({ isOpen, open: (): void => {}, close: onClose }),
|
||||
[isOpen, onClose],
|
||||
);
|
||||
|
||||
const headerActions = useMemo((): HeaderAction[] => {
|
||||
if (!row) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: 'view-full-trace',
|
||||
component: (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<CornerUpRight />}
|
||||
onClick={(): void => openInNewTab(getTraceLink(row))}
|
||||
testId="span-drawer-view-full-trace"
|
||||
>
|
||||
View full trace
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'nav',
|
||||
component: (
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
testId="span-drawer-nav"
|
||||
items={[
|
||||
{
|
||||
value: 'prev',
|
||||
icon: <ChevronUp />,
|
||||
ariaLabel: 'Previous span',
|
||||
onClick: goToPrev,
|
||||
disabled: isPrevDisabled,
|
||||
disabledTooltip: 'No previous span on this page',
|
||||
},
|
||||
{
|
||||
value: 'next',
|
||||
icon: <ChevronDown />,
|
||||
ariaLabel: 'Next span',
|
||||
onClick: goToNext,
|
||||
disabled: isNextDisabled,
|
||||
disabledTooltip: 'No next span on this page',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [row, goToPrev, goToNext, isPrevDisabled, isNextDisabled]);
|
||||
|
||||
const span = useMemo(() => (row ? toSpanV3(row) : undefined), [row]);
|
||||
|
||||
const traceId = span?.trace_id ?? '';
|
||||
const { data: traceSummary, isLoading: isTraceBoundsLoading } =
|
||||
useGetTraceSummary(
|
||||
{ traceID: traceId },
|
||||
{ query: { enabled: !!traceId, retry: false } },
|
||||
);
|
||||
|
||||
return (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={span}
|
||||
variant={SpanDetailVariant.DRAWER}
|
||||
headerActions={headerActions}
|
||||
traceStartTime={traceSummary?.data?.startTimestampMillis}
|
||||
traceEndTime={traceSummary?.data?.endTimestampMillis}
|
||||
isTraceBoundsLoading={isTraceBoundsLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanDrawer;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { renderHook } from 'tests/test-utils';
|
||||
|
||||
import { useArrowKeyNavigation } from '../useArrowKeyNavigation';
|
||||
|
||||
const pressArrowDown = ({ handled }: { handled: boolean }): void => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'ArrowDown',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
}
|
||||
document.body.dispatchEvent(event);
|
||||
};
|
||||
|
||||
describe('useArrowKeyNavigation', () => {
|
||||
it('steps to the next span on ArrowDown', () => {
|
||||
const onNext = jest.fn() as jest.MockedFunction<() => void>;
|
||||
renderHook(() =>
|
||||
useArrowKeyNavigation({ enabled: true, onPrev: jest.fn(), onNext }),
|
||||
);
|
||||
|
||||
pressArrowDown({ handled: false });
|
||||
|
||||
expect(onNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores a key that a focused menu already handled', () => {
|
||||
const onNext = jest.fn() as jest.MockedFunction<() => void>;
|
||||
renderHook(() =>
|
||||
useArrowKeyNavigation({ enabled: true, onPrev: jest.fn(), onNext }),
|
||||
);
|
||||
|
||||
pressArrowDown({ handled: true });
|
||||
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
|
||||
import { useSpanDrawer } from '../useSpanDrawer';
|
||||
|
||||
const rows: TracesTableRow[] = [{ id: 'span-a' }, { id: 'span-b' }];
|
||||
|
||||
describe('useSpanDrawer', () => {
|
||||
beforeEach(() => {
|
||||
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
|
||||
});
|
||||
|
||||
it('does not reopen a span that a refetch dropped and a later one brings back', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ current }: { current: TracesTableRow[] }) => useSpanDrawer(current),
|
||||
{ initialProps: { current: rows } },
|
||||
);
|
||||
|
||||
act(() => result.current.tableProps.onRowClick?.(rows[1]));
|
||||
expect(result.current.drawerProps?.row).toBe(rows[1]);
|
||||
|
||||
rerender({ current: [rows[0]] });
|
||||
expect(result.current.drawerProps?.row).toBeNull();
|
||||
|
||||
rerender({ current: rows });
|
||||
expect(result.current.drawerProps?.row).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { toSpanV3 } from '../utils';
|
||||
|
||||
describe('toSpanV3', () => {
|
||||
const row = {
|
||||
id: '772c4d29dd9076ac',
|
||||
span_id: '772c4d29dd9076ac',
|
||||
timestamp: '2024-07-19T08:39:58.735Z',
|
||||
resource: { 'service.name': 'frontend' },
|
||||
events: [
|
||||
{
|
||||
name: 'exception',
|
||||
timeUnixNano: 1721378398735000000,
|
||||
attributes: { 'exception.type': 'Error' },
|
||||
},
|
||||
],
|
||||
links: [{ traceId: 'a364a8e15af3e9a8', spanId: '5704353737b6778e' }],
|
||||
};
|
||||
|
||||
it('reshapes a list row into the span the details panel reads', () => {
|
||||
const span = toSpanV3(row);
|
||||
|
||||
expect(span.timestamp).toBe(Date.UTC(2024, 6, 19, 8, 39, 58, 735));
|
||||
expect(span['service.name']).toBe('frontend');
|
||||
expect(span.events).toStrictEqual([
|
||||
{
|
||||
name: 'exception',
|
||||
timeUnixNano: 1721378398735000000,
|
||||
attributeMap: { 'exception.type': 'Error' },
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
expect((span as unknown as { references: unknown }).references).toStrictEqual(
|
||||
row.links,
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the parent from the linked spans', () => {
|
||||
const link = { traceId: 'a364a8e15af3e9a8', spanId: '5704353737b6778e' };
|
||||
const parent = { traceId: '1f2e3d4c5b6a7980', spanId: '0aa1bb2cc3dd4ee5' };
|
||||
const span = toSpanV3({
|
||||
...row,
|
||||
trace_id: parent.traceId,
|
||||
parent_span_id: parent.spanId,
|
||||
links: [parent, link, { ...parent, traceId: link.traceId }],
|
||||
});
|
||||
|
||||
expect((span as unknown as { references: unknown }).references).toStrictEqual(
|
||||
[link, { ...parent, traceId: link.traceId }],
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the empty parent ref of a root span', () => {
|
||||
const span = toSpanV3({
|
||||
...row,
|
||||
trace_id: 'a364a8e15af3e9a8',
|
||||
parent_span_id: '',
|
||||
links: [{ traceId: 'a364a8e15af3e9a8' }],
|
||||
});
|
||||
|
||||
expect((span as unknown as { references: unknown }).references).toStrictEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults missing events, links and service name', () => {
|
||||
const span = toSpanV3({ id: 'x', timestamp: row.timestamp });
|
||||
|
||||
expect(span.events).toStrictEqual([]);
|
||||
expect(span['service.name']).toBe('');
|
||||
expect((span as unknown as { references: unknown }).references).toStrictEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const EDITABLE_TARGET = 'input, textarea, [contenteditable="true"], .cm-editor';
|
||||
|
||||
type UseArrowKeyNavigationParams = {
|
||||
enabled: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
};
|
||||
|
||||
export function useArrowKeyNavigation({
|
||||
enabled,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: UseArrowKeyNavigationParams): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
// A focused menu or listbox already used the key.
|
||||
if (e.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
if (e.target instanceof Element && e.target.closest(EDITABLE_TARGET)) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
onPrev();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [enabled, onPrev, onNext]);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { PointerEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRowNavigation } from 'components/TanStackTableView/useRowNavigation';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import type { TracesTableProps } from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { useCloseOnOutsidePress } from 'hooks/useCloseOnOutsidePress';
|
||||
|
||||
import type { SpanDrawerProps } from './SpanDrawer';
|
||||
import { isSpanDrawerEnabled } from './utils';
|
||||
|
||||
type ActiveSpan = { id: string; index: number };
|
||||
|
||||
type UseSpanDrawerResult = {
|
||||
tableProps: Pick<
|
||||
TracesTableProps,
|
||||
'onRowClick' | 'isRowActive' | 'activeRowIndex'
|
||||
>;
|
||||
/** Null while the drawer flag is off. */
|
||||
drawerProps: SpanDrawerProps | null;
|
||||
/** `onPointerDownCapture` for the area a press keeps the drawer open in. */
|
||||
onPressInside: (event: PointerEvent) => void;
|
||||
};
|
||||
|
||||
export function useSpanDrawer(rows: TracesTableRow[]): UseSpanDrawerResult {
|
||||
const [isEnabled] = useState(isSpanDrawerEnabled);
|
||||
// The index lets navigation step past a duplicate span; the id survives a refetch.
|
||||
const [activeSpan, setActiveSpan] = useState<ActiveSpan | null>(null);
|
||||
|
||||
const activeRowIndex = useMemo(() => {
|
||||
if (!activeSpan) {
|
||||
return -1;
|
||||
}
|
||||
return rows[activeSpan.index]?.id === activeSpan.id
|
||||
? activeSpan.index
|
||||
: rows.findIndex((row) => row.id === activeSpan.id);
|
||||
}, [rows, activeSpan]);
|
||||
|
||||
const isRowActive = useCallback(
|
||||
(row: TracesTableRow): boolean => row === rows[activeRowIndex],
|
||||
[rows, activeRowIndex],
|
||||
);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(row: TracesTableRow, index: number): void => {
|
||||
setActiveSpan({ id: row.id, index });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectRow = useCallback(
|
||||
(row: TracesTableRow): void => handleNavigate(row, rows.indexOf(row)),
|
||||
[handleNavigate, rows],
|
||||
);
|
||||
|
||||
const clearActiveSpan = useCallback((): void => setActiveSpan(null), []);
|
||||
|
||||
// A refetch can drop the open span; forget it so a later page can't reopen it.
|
||||
useEffect(() => {
|
||||
if (activeSpan && activeRowIndex === -1) {
|
||||
setActiveSpan(null);
|
||||
}
|
||||
}, [activeSpan, activeRowIndex]);
|
||||
|
||||
const onPressInside = useCloseOnOutsidePress(
|
||||
isEnabled && activeRowIndex !== -1,
|
||||
clearActiveSpan,
|
||||
);
|
||||
|
||||
const navigation = useRowNavigation({
|
||||
rows,
|
||||
activeIndex: activeRowIndex,
|
||||
onNavigate: handleNavigate,
|
||||
});
|
||||
|
||||
if (!isEnabled) {
|
||||
return { tableProps: {}, drawerProps: null, onPressInside };
|
||||
}
|
||||
|
||||
return {
|
||||
tableProps: { onRowClick: selectRow, isRowActive, activeRowIndex },
|
||||
drawerProps: {
|
||||
row: rows[activeRowIndex] ?? null,
|
||||
onClose: clearActiveSpan,
|
||||
navigation,
|
||||
},
|
||||
onPressInside,
|
||||
};
|
||||
}
|
||||
46
frontend/src/container/TracesExplorer/SpanDrawer/utils.ts
Normal file
46
frontend/src/container/TracesExplorer/SpanDrawer/utils.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
/** Off by default; set `TRACES_SPAN_DRAWER_ENABLED` to `true` in localStorage to try it. */
|
||||
export const isSpanDrawerEnabled = (): boolean =>
|
||||
get(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED) === 'true';
|
||||
|
||||
type ListSpanEvent = {
|
||||
name: string;
|
||||
timeUnixNano: number;
|
||||
attributes?: Record<string, string>;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type ListSpanLink = { traceId: string; spanId: string };
|
||||
|
||||
/**
|
||||
* The list API returns a span in a different shape from the trace API the
|
||||
* details panel was built on: an ISO timestamp, `attributes` on events, and
|
||||
* `links` in place of `references`.
|
||||
*/
|
||||
export const toSpanV3 = (row: TracesTableRow): SpanV3 => {
|
||||
const resource = (row.resource ?? {}) as Record<string, string>;
|
||||
const events = (row.events ?? []) as ListSpanEvent[];
|
||||
const links = (row.links ?? []) as ListSpanLink[];
|
||||
|
||||
return {
|
||||
...row,
|
||||
timestamp: new Date(row.timestamp as string).getTime(),
|
||||
'service.name': resource['service.name'] ?? '',
|
||||
events: events.map(({ attributes, isError, ...event }) => ({
|
||||
...event,
|
||||
attributeMap: attributes ?? {},
|
||||
isError: isError ?? false,
|
||||
})),
|
||||
// The list API drops `refType`, so the parent ref is matched by id. A root
|
||||
// span's parent ref has no span id.
|
||||
references: links.filter(
|
||||
(link) =>
|
||||
!!link.spanId &&
|
||||
(link.spanId !== row.parent_span_id || link.traceId !== row.trace_id),
|
||||
),
|
||||
} as unknown as SpanV3;
|
||||
};
|
||||
@@ -24,6 +24,10 @@ export type TracesTableProps = {
|
||||
panelType: PanelTypeKeys;
|
||||
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
|
||||
getRowHref: (row: TracesTableRow) => string;
|
||||
/** Replaces the default row click, which opens the trace detail page. */
|
||||
onRowClick?: (row: TracesTableRow) => void;
|
||||
isRowActive?: (row: TracesTableRow) => boolean;
|
||||
activeRowIndex?: number;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
@@ -41,6 +45,9 @@ function TracesTable({
|
||||
respectColumnOrder = false,
|
||||
panelType,
|
||||
getRowHref,
|
||||
onRowClick,
|
||||
isRowActive,
|
||||
activeRowIndex,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
@@ -57,9 +64,13 @@ function TracesTable({
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
if (onRowClick) {
|
||||
onRowClick(row);
|
||||
return;
|
||||
}
|
||||
navigate(getRowHref(row));
|
||||
},
|
||||
[navigate, getRowHref],
|
||||
[onRowClick, navigate, getRowHref],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback(
|
||||
@@ -97,6 +108,8 @@ function TracesTable({
|
||||
onColumnRemove={onColumnRemove}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
isRowActive={isRowActive}
|
||||
activeRowIndex={activeRowIndex}
|
||||
getRowTestId={(row): string => `traces-table-row-${row.id}`}
|
||||
testId={`traces-table-${panelType.toLowerCase()}`}
|
||||
/>
|
||||
@@ -109,6 +122,9 @@ function TracesTable({
|
||||
TracesTable.defaultProps = {
|
||||
columnStorageKey: undefined,
|
||||
respectColumnOrder: false,
|
||||
onRowClick: undefined,
|
||||
isRowActive: undefined,
|
||||
activeRowIndex: undefined,
|
||||
onColumnOrderChange: undefined,
|
||||
onColumnRemove: undefined,
|
||||
cellTypographySize: 'medium',
|
||||
|
||||
107
frontend/src/hooks/__tests__/useCloseOnOutsidePress.test.tsx
Normal file
107
frontend/src/hooks/__tests__/useCloseOnOutsidePress.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { useCloseOnOutsidePress } from '../useCloseOnOutsidePress';
|
||||
|
||||
function Harness({
|
||||
enabled = true,
|
||||
onClose,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
onClose: () => void;
|
||||
}): JSX.Element {
|
||||
const onPressInside = useCloseOnOutsidePress(enabled, onClose);
|
||||
return (
|
||||
<>
|
||||
<div onPointerDownCapture={onPressInside}>
|
||||
<button type="button" data-testid="inside">
|
||||
inside
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="inside-stops"
|
||||
onPointerDown={(e): void => e.stopPropagation()}
|
||||
>
|
||||
inside, stops propagation
|
||||
</button>
|
||||
{createPortal(
|
||||
<button type="button" data-testid="inside-portal">
|
||||
portal
|
||||
</button>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
<button type="button" data-testid="outside">
|
||||
outside
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="outside-stops"
|
||||
onPointerDown={(e): void => e.stopPropagation()}
|
||||
>
|
||||
outside, stops propagation
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const flush = (): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve);
|
||||
});
|
||||
|
||||
describe('useCloseOnOutsidePress', () => {
|
||||
it('closes on a press outside', async () => {
|
||||
const onClose = jest.fn() as jest.MockedFunction<() => void>;
|
||||
render(<Harness onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('outside'));
|
||||
await flush();
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stays open on a press inside, portals included', async () => {
|
||||
const onClose = jest.fn() as jest.MockedFunction<() => void>;
|
||||
render(<Harness onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('inside'));
|
||||
await userEvent.click(screen.getByTestId('inside-portal'));
|
||||
await flush();
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes when the outside element stops propagation', async () => {
|
||||
const onClose = jest.fn() as jest.MockedFunction<() => void>;
|
||||
render(<Harness onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('outside-stops'));
|
||||
await flush();
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes on the first outside press after an inside press stops propagation', async () => {
|
||||
const onClose = jest.fn() as jest.MockedFunction<() => void>;
|
||||
render(<Harness onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('inside-stops'));
|
||||
await flush();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByTestId('outside'));
|
||||
await flush();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing while disabled', async () => {
|
||||
const onClose = jest.fn() as jest.MockedFunction<() => void>;
|
||||
render(<Harness enabled={false} onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('outside'));
|
||||
await flush();
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
51
frontend/src/hooks/useCloseOnOutsidePress.ts
Normal file
51
frontend/src/hooks/useCloseOnOutsidePress.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
/**
|
||||
* Calls `onClose` on any press outside the elements given the returned handler as
|
||||
* `onPointerDownCapture`. Menus and dialogs rendered in portals still count as
|
||||
* inside: React events follow the component tree.
|
||||
*
|
||||
* The document listener runs in the capture phase, so an outside element that
|
||||
* stops propagation still closes. It runs before React marks the press, so the
|
||||
* check waits for the dispatch to finish.
|
||||
*/
|
||||
export function useCloseOnOutsidePress(
|
||||
enabled: boolean,
|
||||
onClose: () => void,
|
||||
): (event: ReactPointerEvent) => void {
|
||||
const insidePress = useRef<Event | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
const timeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handlePointerDown = (event: PointerEvent): void => {
|
||||
const timeout = setTimeout(() => {
|
||||
timeouts.delete(timeout);
|
||||
if (insidePress.current !== event) {
|
||||
onCloseRef.current();
|
||||
}
|
||||
});
|
||||
timeouts.add(timeout);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return (): void => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
timeouts.forEach(clearTimeout);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return useCallback((event: ReactPointerEvent): void => {
|
||||
insidePress.current = event.nativeEvent;
|
||||
}, []);
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import styles from './Events.module.scss';
|
||||
|
||||
interface IEventsTableProps {
|
||||
span: SpanV3;
|
||||
startTime: number;
|
||||
/** Trace start; the "since trace start" offset is hidden without it. */
|
||||
startTime?: number;
|
||||
isSearchVisible: boolean;
|
||||
}
|
||||
|
||||
@@ -80,17 +81,19 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
|
||||
<Typography.Text className={styles.attributeKey}>
|
||||
Start Time
|
||||
</Typography.Text>
|
||||
<div className={styles.timestampContainer}>
|
||||
<Typography.Text className={styles.attributeValue}>
|
||||
{getYAxisFormattedValue(
|
||||
`${(timeUnixNano || 0) / 1e6 - startTime}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.timestampText}>
|
||||
since trace start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{startTime !== undefined && (
|
||||
<div className={styles.timestampContainer}>
|
||||
<Typography.Text className={styles.attributeValue}>
|
||||
{getYAxisFormattedValue(
|
||||
`${(timeUnixNano || 0) / 1e6 - startTime}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.timestampText}>
|
||||
since trace start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.timestampContainer}>
|
||||
<Typography.Text className={styles.attributeValue}>
|
||||
{getYAxisFormattedValue(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import Events from '../Events';
|
||||
|
||||
const SPAN_START_MS = 1_700_000_000_000;
|
||||
|
||||
const span = {
|
||||
span_id: 'span-1',
|
||||
trace_id: 'trace-1',
|
||||
name: 'checkout',
|
||||
timestamp: SPAN_START_MS,
|
||||
duration_nano: 50_000_000,
|
||||
events: [
|
||||
{
|
||||
name: 'cache miss',
|
||||
timeUnixNano: (SPAN_START_MS + 3) * 1e6,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
} as unknown as SpanV3;
|
||||
|
||||
describe('Events', () => {
|
||||
it('shows the offset from the trace start when it is known', () => {
|
||||
render(<Events span={span} startTime={SPAN_START_MS - 10} isSearchVisible />);
|
||||
|
||||
expect(screen.getByText('since trace start')).toBeInTheDocument();
|
||||
expect(screen.getByText('since span start')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only the offset from the span start without a trace start', () => {
|
||||
render(<Events span={span} isSearchVisible />);
|
||||
|
||||
expect(screen.queryByText('since trace start')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('since span start')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -110,3 +110,15 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.drawerPanel {
|
||||
.panelBody,
|
||||
.logsFooter,
|
||||
:global(.pretty-view__search-wrapper) {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
:global(.pretty-view__search-input) {
|
||||
background: var(--l2-background) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Tabs, type TabsItemProps } from '@signozhq/ui/tabs';
|
||||
import { Bookmark, ChartColumnBig, List, ScrollText } from '@signozhq/icons';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
|
||||
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
|
||||
import { DetailsPanelState } from 'components/DetailsPanel/types';
|
||||
@@ -49,6 +50,7 @@ import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
|
||||
import SpanDrawerHeader from './SpanDrawerHeader';
|
||||
import SpanSummary from './SpanSummary';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
@@ -58,8 +60,12 @@ interface SpanDetailsPanelProps {
|
||||
selectedSpan: SpanV3 | undefined;
|
||||
variant?: SpanDetailVariant;
|
||||
onVariantChange?: (variant: SpanDetailVariant) => void;
|
||||
/** Page-specific actions, shown before the dock mode switcher. */
|
||||
headerActions?: HeaderAction[];
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
/** The trace bounds are still being fetched; logs wait for them. */
|
||||
isTraceBoundsLoading?: boolean;
|
||||
}
|
||||
|
||||
// At/above this panel width the summary moves inside the Overview tab (bottom
|
||||
@@ -73,10 +79,12 @@ function SpanDetailsContent({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
isTraceBoundsLoading = false,
|
||||
}: {
|
||||
selectedSpan: SpanV3;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
isTraceBoundsLoading?: boolean;
|
||||
}): JSX.Element {
|
||||
const [bodyRef, { width: bodyWidth }] = useMeasure<HTMLDivElement>();
|
||||
const spanAttributeActions = useSpanAttributeActions();
|
||||
@@ -168,6 +176,31 @@ function SpanDetailsContent({
|
||||
// [spanAttributeActions, setCopy],
|
||||
// );
|
||||
|
||||
// Without trace bounds the panel speaks only for the span: its own logs
|
||||
// window, and no trace-wide logs check.
|
||||
const hasTraceBounds = !!traceStartTime && !!traceEndTime;
|
||||
const logsTimeRange = useMemo(
|
||||
() =>
|
||||
traceStartTime && traceEndTime
|
||||
? {
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
}
|
||||
: {
|
||||
startTime: selectedSpan.timestamp - FIVE_MINUTES_IN_MS,
|
||||
// query_range rejects a fractional end.
|
||||
endTime:
|
||||
Math.ceil(selectedSpan.timestamp + selectedSpan.duration_nano / 1e6) +
|
||||
FIVE_MINUTES_IN_MS,
|
||||
},
|
||||
[
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
selectedSpan.timestamp,
|
||||
selectedSpan.duration_nano,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
logs,
|
||||
isLoading: isLogsLoading,
|
||||
@@ -178,11 +211,9 @@ function SpanDetailsContent({
|
||||
} = useSpanContextLogs({
|
||||
traceId: selectedSpan.trace_id,
|
||||
spanId: selectedSpan.span_id,
|
||||
timeRange: {
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
},
|
||||
isDrawerOpen: true,
|
||||
timeRange: logsTimeRange,
|
||||
checkTraceLogs: hasTraceBounds,
|
||||
enabled: !isTraceBoundsLoading,
|
||||
});
|
||||
|
||||
const infraMetadata = useMemo(() => {
|
||||
@@ -201,8 +232,7 @@ function SpanDetailsContent({
|
||||
}, [selectedSpan]);
|
||||
|
||||
const handleExplorerPageRedirect = useCallback((): void => {
|
||||
const startTimeMs = (traceStartTime || 0) - FIVE_MINUTES_IN_MS;
|
||||
const endTimeMs = (traceEndTime || 0) + FIVE_MINUTES_IN_MS;
|
||||
const { startTime: startTimeMs, endTime: endTimeMs } = logsTimeRange;
|
||||
|
||||
const traceIdFilter = {
|
||||
op: 'AND',
|
||||
@@ -244,14 +274,15 @@ function SpanDetailsContent({
|
||||
searchParams.set(QueryParams.endTime, endTimeMs.toString());
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`);
|
||||
}, [selectedSpan.trace_id, traceStartTime, traceEndTime]);
|
||||
}, [selectedSpan.trace_id, logsTimeRange]);
|
||||
|
||||
const emptyLogsStateConfig = useMemo(
|
||||
() => ({
|
||||
...getEmptyLogsListConfig(() => {}),
|
||||
...(!hasTraceBounds && { title: 'No logs found for this span.' }),
|
||||
showClearFiltersButton: false,
|
||||
}),
|
||||
[],
|
||||
[hasTraceBounds],
|
||||
);
|
||||
|
||||
// const keyAttributes = useMemo(() => {
|
||||
@@ -321,11 +352,7 @@ function SpanDetailsContent({
|
||||
</Badge>
|
||||
) : undefined,
|
||||
children: (
|
||||
<Events
|
||||
span={selectedSpan}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
<Events span={selectedSpan} startTime={traceStartTime} isSearchVisible />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -336,10 +363,7 @@ function SpanDetailsContent({
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.trace_id}
|
||||
spanId={selectedSpan.span_id}
|
||||
timeRange={{
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
timeRange={logsTimeRange}
|
||||
logs={logs}
|
||||
isLoading={isLogsLoading}
|
||||
isError={isLogsError}
|
||||
@@ -394,7 +418,10 @@ function SpanDetailsContent({
|
||||
with the panel's bottom edge — always visible, never scrolls. */}
|
||||
{activeTab === 'logs' && (
|
||||
<div className={styles.logsFooter}>
|
||||
<OpenInLogsExplorer onClick={handleExplorerPageRedirect} />
|
||||
<OpenInLogsExplorer
|
||||
onClick={handleExplorerPageRedirect}
|
||||
disabled={isTraceBoundsLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -406,11 +433,14 @@ function SpanDetailsPanel({
|
||||
selectedSpan,
|
||||
variant = SpanDetailVariant.DIALOG,
|
||||
onVariantChange,
|
||||
headerActions: pageHeaderActions,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
isTraceBoundsLoading,
|
||||
}: SpanDetailsPanelProps): JSX.Element {
|
||||
const headerActions = useMemo((): HeaderAction[] => {
|
||||
const actions: HeaderAction[] = [
|
||||
...(pageHeaderActions ?? []),
|
||||
// TODO: Add back when driven through separate config for different pages
|
||||
// {
|
||||
// key: 'view-full-trace',
|
||||
@@ -440,7 +470,7 @@ function SpanDetailsPanel({
|
||||
}
|
||||
|
||||
return actions;
|
||||
}, [variant, onVariantChange]);
|
||||
}, [pageHeaderActions, variant, onVariantChange]);
|
||||
|
||||
const PANEL_WIDTH = SPAN_DETAILS_PANEL_MIN_WIDTH;
|
||||
const PANEL_MARGIN_RIGHT = 20;
|
||||
@@ -449,19 +479,28 @@ function SpanDetailsPanel({
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<DetailsHeader
|
||||
title="Span details"
|
||||
onClose={panelState.close}
|
||||
actions={headerActions}
|
||||
className={
|
||||
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
|
||||
}
|
||||
/>
|
||||
{variant === SpanDetailVariant.DRAWER ? (
|
||||
<SpanDrawerHeader
|
||||
title="Span details"
|
||||
onClose={panelState.close}
|
||||
actions={headerActions}
|
||||
/>
|
||||
) : (
|
||||
<DetailsHeader
|
||||
title="Span details"
|
||||
onClose={panelState.close}
|
||||
actions={headerActions}
|
||||
className={
|
||||
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{selectedSpan ? (
|
||||
<SpanDetailsContent
|
||||
selectedSpan={selectedSpan}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
isTraceBoundsLoading={isTraceBoundsLoading}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.panelBody}>
|
||||
@@ -483,7 +522,8 @@ function SpanDetailsPanel({
|
||||
<DetailsPanelDrawer
|
||||
isOpen={panelState.isOpen}
|
||||
onClose={panelState.close}
|
||||
className={styles.panel}
|
||||
className={cx(styles.panel, styles.drawerPanel)}
|
||||
testId="span-details-drawer"
|
||||
>
|
||||
{content}
|
||||
</DetailsPanelDrawer>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
.header {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
height: 48px;
|
||||
padding: 0 var(--spacing-8) 0 var(--spacing-4);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 16px;
|
||||
margin-inline: 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { X } from '@signozhq/icons';
|
||||
import type { HeaderAction } from 'components/DetailsPanel';
|
||||
|
||||
import styles from './SpanDrawerHeader.module.scss';
|
||||
|
||||
interface SpanDrawerHeaderProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
actions: HeaderAction[];
|
||||
}
|
||||
|
||||
function SpanDrawerHeader({
|
||||
title,
|
||||
onClose,
|
||||
actions,
|
||||
}: SpanDrawerHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
icon
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
testId="span-drawer-close"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
<Divider type="vertical" className={styles.divider} />
|
||||
<span className={styles.title}>{title}</span>
|
||||
<div className={styles.actions}>
|
||||
{actions.map((action) => (
|
||||
<div key={action.key}>{action.component}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanDrawerHeader;
|
||||
@@ -3,17 +3,23 @@ import { Compass } from '@signozhq/icons';
|
||||
|
||||
interface OpenInLogsExplorerProps {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Opens the full Logs Explorer (new tab) filtered to this span's trace.
|
||||
// Placement/alignment is the caller's responsibility.
|
||||
function OpenInLogsExplorer({ onClick }: OpenInLogsExplorerProps): JSX.Element {
|
||||
function OpenInLogsExplorer({
|
||||
onClick,
|
||||
disabled,
|
||||
}: OpenInLogsExplorerProps): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
disabledTooltip="Loading the trace's time range"
|
||||
prefix={<Compass size={16} />}
|
||||
testId="open-in-explorer-button"
|
||||
>
|
||||
|
||||
@@ -20,7 +20,8 @@ interface UseSpanContextLogsProps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isDrawerOpen?: boolean;
|
||||
checkTraceLogs?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseSpanContextLogsReturn {
|
||||
@@ -112,7 +113,8 @@ export const useSpanContextLogs = ({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
isDrawerOpen = true,
|
||||
checkTraceLogs = true,
|
||||
enabled = true,
|
||||
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
|
||||
const [allLogs, setAllLogs] = useState<ILog[]>([]);
|
||||
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
|
||||
@@ -142,7 +144,7 @@ export const useSpanContextLogs = ({
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () => GetMetricQueryRange(spanQueryPayload, ENTITY_VERSION_V5),
|
||||
enabled: !!traceId && !!spanId,
|
||||
enabled: enabled && !!traceId && !!spanId,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
@@ -311,7 +313,11 @@ export const useSpanContextLogs = ({
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(traceOnlyQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: isDrawerOpen && !!traceOnlyQueryPayload && spanLogs.length === 0,
|
||||
enabled:
|
||||
enabled &&
|
||||
checkTraceLogs &&
|
||||
!!traceOnlyQueryPayload &&
|
||||
spanLogs.length === 0,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
@@ -332,7 +338,7 @@ export const useSpanContextLogs = ({
|
||||
|
||||
return {
|
||||
logs: allLogs,
|
||||
isLoading: isSpanLoading && spanLogs.length === 0,
|
||||
isLoading: (!enabled || isSpanLoading) && spanLogs.length === 0,
|
||||
isError: isSpanError,
|
||||
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
|
||||
spanLogIds,
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
.traceId {
|
||||
color: var(--accent-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
min-width: 0;
|
||||
|
||||
// Sized to the icon, so the copy button sits flush against the id.
|
||||
--button-padding: 2px;
|
||||
--button-height: auto;
|
||||
--button-width: auto;
|
||||
}
|
||||
|
||||
// A block, since overflow and text-overflow do not apply to the inline span
|
||||
// Typography.Text renders.
|
||||
.value {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import styles from './TraceIdField.module.scss';
|
||||
@@ -14,51 +8,20 @@ interface TraceIdFieldProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a span's trace id. When the user is already on the trace detail
|
||||
* page for this trace, clicking the id copies it to the clipboard (the
|
||||
* "navigate" affordance would be a no-op). Otherwise, falls back to the
|
||||
* existing link to the trace detail page.
|
||||
*/
|
||||
export function TraceIdField({ span }: TraceIdFieldProps): JSX.Element {
|
||||
const { pathname } = useAppLocation();
|
||||
const match = matchRoute<'id'>(pathname, ROUTES.TRACE_DETAIL, { exact: true });
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
|
||||
const isCurrentTrace = match?.params.id === span.trace_id;
|
||||
|
||||
if (isCurrentTrace) {
|
||||
const handleCopy = (): void => {
|
||||
setCopy(span.trace_id);
|
||||
toast.success('Trace ID copied to clipboard', {
|
||||
position: 'top-right',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title="Click to copy trace ID">
|
||||
<Button
|
||||
size="md"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
maxWidth="100%"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{span.trace_id}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
to={{
|
||||
pathname: `/trace/${span.trace_id}`,
|
||||
search: window.location.search,
|
||||
}}
|
||||
className={styles.traceId}
|
||||
>
|
||||
{span.trace_id}
|
||||
</AppLink>
|
||||
<div className={styles.traceId}>
|
||||
<span className={styles.value} title={span.trace_id}>
|
||||
<Typography.Text testId="span-details-trace-id">
|
||||
{span.trace_id}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<CopyButton
|
||||
value={span.trace_id}
|
||||
size={12}
|
||||
ariaLabel="Copy trace ID"
|
||||
testId="span-details-copy-trace-id"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.aiDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -137,7 +137,7 @@ function TraceDetailsHeader({
|
||||
badgeValue={traceID}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
<TraceDetailsTabs />
|
||||
<TraceDetailsTabs hasAiSummary={!!traceSummary?.ai} />
|
||||
</div>
|
||||
)}
|
||||
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
|
||||
|
||||
@@ -8,20 +8,13 @@ import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
|
||||
const TAB_ITEMS = [
|
||||
{
|
||||
value: TraceDetailsTab.Overview,
|
||||
label: 'Overview',
|
||||
testId: 'trace-details-tab-overview',
|
||||
},
|
||||
{
|
||||
value: TraceDetailsTab.Thread,
|
||||
label: 'Thread',
|
||||
testId: 'trace-details-tab-thread',
|
||||
},
|
||||
];
|
||||
interface TraceDetailsTabsProps {
|
||||
hasAiSummary: boolean;
|
||||
}
|
||||
|
||||
function TraceDetailsTabs(): JSX.Element {
|
||||
function TraceDetailsTabs({
|
||||
hasAiSummary,
|
||||
}: TraceDetailsTabsProps): JSX.Element {
|
||||
const { id: traceId = '' } = useAppParams<'id'>();
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId);
|
||||
@@ -35,6 +28,26 @@ function TraceDetailsTabs(): JSX.Element {
|
||||
void setTab(value as TraceDetailsTab, { history: 'push' });
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
value: TraceDetailsTab.Overview,
|
||||
label: 'Overview',
|
||||
testId: 'trace-details-tab-overview',
|
||||
},
|
||||
{
|
||||
value: TraceDetailsTab.Thread,
|
||||
label: 'Thread',
|
||||
testId: 'trace-details-tab-thread',
|
||||
suffix: hasAiSummary ? (
|
||||
<span
|
||||
className={styles.aiDot}
|
||||
aria-hidden
|
||||
data-testid="trace-details-tab-thread-ai-dot"
|
||||
/>
|
||||
) : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.tabs}>
|
||||
<ToggleGroup
|
||||
@@ -44,7 +57,7 @@ function TraceDetailsTabs(): JSX.Element {
|
||||
value={tab}
|
||||
onChange={handleChange}
|
||||
size="sm"
|
||||
items={TAB_ITEMS}
|
||||
items={items}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -394,6 +394,27 @@ describe('TraceDetailsHeader – tabs', () => {
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the AI dot on the Thread tab only when the summary has an AI block', () => {
|
||||
renderHeader();
|
||||
expect(
|
||||
screen.queryByTestId('trace-details-tab-thread-ai-dot'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
jest.mocked(useTraceSummary).mockReturnValue({
|
||||
data: {
|
||||
...SUMMARY,
|
||||
ai: {
|
||||
tokens: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
renderHeader();
|
||||
expect(
|
||||
screen.getByTestId('trace-details-tab-thread-ai-dot'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the filter result when switching to the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
@@ -34,7 +34,8 @@ function ThreadSpanDetails({
|
||||
const [width, setWidth] = useState(RIGHT_DOCK_MIN);
|
||||
const [height, setHeight] = useState(BOTTOM_DOCK_DEFAULT);
|
||||
const { id: traceId = '' } = useAppParams<'id'>();
|
||||
const { data: traceSummary } = useTraceSummary(traceId);
|
||||
const { data: traceSummary, isLoading: isTraceSummaryLoading } =
|
||||
useTraceSummary(traceId);
|
||||
|
||||
const panel = (
|
||||
<SpanDetailsPanel
|
||||
@@ -44,6 +45,7 @@ function ThreadSpanDetails({
|
||||
onVariantChange={onVariantChange}
|
||||
traceStartTime={traceSummary?.startTimestampMillis}
|
||||
traceEndTime={traceSummary?.endTimestampMillis}
|
||||
isTraceBoundsLoading={isTraceSummaryLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import { useThreadDataLoadedEvent } from './hooks/useThreadDataLoadedEvent';
|
||||
import { useThreadLoadedEvents } from './hooks/useThreadLoadedEvents';
|
||||
import { useThreadSpanSelection } from './hooks/useThreadSpanSelection';
|
||||
import { useThreadView } from './hooks/useThreadView';
|
||||
import { useTraceThread } from './hooks/useTraceThread';
|
||||
@@ -30,10 +30,12 @@ const INITIAL_LOADER_COUNT = 4;
|
||||
interface TraceDetailsThreadProps {
|
||||
// Owned by the page so the load event fires once per trace, not per tab visit.
|
||||
dataLoadedFiredForRef: MutableRefObject<string>;
|
||||
threadEmptyFiredForRef: MutableRefObject<string>;
|
||||
}
|
||||
|
||||
function TraceDetailsThread({
|
||||
dataLoadedFiredForRef,
|
||||
threadEmptyFiredForRef,
|
||||
}: TraceDetailsThreadProps): JSX.Element {
|
||||
const { id: traceId } = useAppParams<'id'>();
|
||||
const [, setTab] = useTraceDetailsTab();
|
||||
@@ -68,17 +70,24 @@ function TraceDetailsThread({
|
||||
const interestedSpanIndex = spans.findIndex(
|
||||
(span) => span.span_id === interestedSpanId,
|
||||
);
|
||||
const hasNoThread =
|
||||
!isLoading &&
|
||||
interestedSpanStatus !== InterestedSpanStatus.NotFound &&
|
||||
(isError || spans.length === 0);
|
||||
|
||||
const selectedSpan = spans.find((span) => span.span_id === selectedSpanId);
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useSpanDetailVariant();
|
||||
const isBottomDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
useThreadDataLoadedEvent({
|
||||
useThreadLoadedEvents({
|
||||
traceId: traceId || '',
|
||||
interestedSpanId,
|
||||
hasSpans: spans.length > 0,
|
||||
hasNoThread,
|
||||
spanPanelVariant: spanDetailVariant,
|
||||
firedForRef: dataLoadedFiredForRef,
|
||||
dataLoadedFiredForRef,
|
||||
threadEmptyFiredForRef,
|
||||
logTraceEvent,
|
||||
});
|
||||
useSpanDetailsOpenedEvent({
|
||||
@@ -96,10 +105,6 @@ function TraceDetailsThread({
|
||||
onVariantChange={setSpanDetailVariant}
|
||||
/>
|
||||
);
|
||||
const hasNoThread =
|
||||
!isLoading &&
|
||||
interestedSpanStatus !== InterestedSpanStatus.NotFound &&
|
||||
(isError || spans.length === 0);
|
||||
|
||||
const renderBody = (): JSX.Element => {
|
||||
if (isLoading) {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { MutableRefObject, ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import {
|
||||
GetTraceSummary200,
|
||||
SpantypesGettableTraceSummaryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getGetTraceSummaryQueryKey } from 'api/generated/services/tracedetail';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { TraceDetailLogEvent } from '../../../hooks/useTraceDetailLogEvent';
|
||||
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
|
||||
import { useThreadLoadedEvents } from '../useThreadLoadedEvents';
|
||||
|
||||
const BASE_SUMMARY: SpantypesGettableTraceSummaryDTO = {
|
||||
startTimestampMillis: 1_000,
|
||||
endTimestampMillis: 4_000,
|
||||
totalSpansCount: 12,
|
||||
totalErrorSpansCount: 2,
|
||||
hasMissingSpans: true,
|
||||
rootServiceEntryPoint: 'POST /chat',
|
||||
rootServiceName: 'agent',
|
||||
rootSpanStatusCode: 'STATUS_CODE_ERROR',
|
||||
};
|
||||
|
||||
const SUMMARIES: Record<string, SpantypesGettableTraceSummaryDTO> = {
|
||||
'trace-ai': {
|
||||
...BASE_SUMMARY,
|
||||
ai: {
|
||||
tokens: {
|
||||
input: 1_200,
|
||||
output: 300,
|
||||
cacheRead: 50,
|
||||
cacheWrite: 10,
|
||||
reasoning: 40,
|
||||
},
|
||||
totalCost: 0.0123,
|
||||
},
|
||||
},
|
||||
'trace-ai-next': {
|
||||
...BASE_SUMMARY,
|
||||
endTimestampMillis: 1_500,
|
||||
totalSpansCount: 4,
|
||||
ai: {
|
||||
tokens: { input: 80, output: 20, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
|
||||
totalCost: 0.0004,
|
||||
},
|
||||
},
|
||||
'trace-no-ai': BASE_SUMMARY,
|
||||
};
|
||||
|
||||
export interface ThreadLoadedEventsProps {
|
||||
traceId: string;
|
||||
hasSpans?: boolean;
|
||||
hasNoThread?: boolean;
|
||||
interestedSpanId?: string;
|
||||
spanPanelVariant?: SpanDetailVariant;
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
dataLoadedFiredForRef?: MutableRefObject<string>;
|
||||
threadEmptyFiredForRef?: MutableRefObject<string>;
|
||||
queryClient?: QueryClient;
|
||||
}
|
||||
|
||||
interface RenderResult {
|
||||
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>;
|
||||
rerender: (props: ThreadLoadedEventsProps) => void;
|
||||
unmount: () => void;
|
||||
queryClient: QueryClient;
|
||||
}
|
||||
|
||||
/** Serves the fixture summaries and returns the trace ids requested, in order. */
|
||||
export function mockTraceSummaries(): string[] {
|
||||
const requests: string[] = [];
|
||||
server.use(
|
||||
rest.get('*/api/v1/traces/:traceId/summary', (req, res, ctx) => {
|
||||
requests.push(req.params.traceId as string);
|
||||
const body: GetTraceSummary200 = {
|
||||
status: 'success',
|
||||
data: SUMMARIES[req.params.traceId as string],
|
||||
};
|
||||
return res(ctx.status(200), ctx.json(body));
|
||||
}),
|
||||
);
|
||||
return requests;
|
||||
}
|
||||
|
||||
export function renderThreadLoadedEvents(
|
||||
initialProps: ThreadLoadedEventsProps,
|
||||
{
|
||||
dataLoadedFiredForRef = { current: '' },
|
||||
threadEmptyFiredForRef = { current: '' },
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
}),
|
||||
}: RenderOptions = {},
|
||||
): RenderResult {
|
||||
const logTraceEvent = jest.fn() as jest.MockedFunction<TraceDetailLogEvent>;
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { rerender, unmount } = renderHook(
|
||||
({
|
||||
traceId,
|
||||
hasSpans = false,
|
||||
hasNoThread = false,
|
||||
interestedSpanId,
|
||||
spanPanelVariant = SpanDetailVariant.DOCKED_RIGHT,
|
||||
}: ThreadLoadedEventsProps) =>
|
||||
useThreadLoadedEvents({
|
||||
traceId,
|
||||
interestedSpanId,
|
||||
hasSpans,
|
||||
hasNoThread,
|
||||
spanPanelVariant,
|
||||
dataLoadedFiredForRef,
|
||||
threadEmptyFiredForRef,
|
||||
logTraceEvent,
|
||||
}),
|
||||
{ initialProps, wrapper },
|
||||
);
|
||||
return { logTraceEvent, rerender, unmount, queryClient };
|
||||
}
|
||||
|
||||
export async function waitForSummary(
|
||||
queryClient: QueryClient,
|
||||
traceId: string,
|
||||
): Promise<void> {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryClient.getQueryData(getGetTraceSummaryQueryKey({ traceID: traceId })),
|
||||
).toBeDefined(),
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { MutableRefObject, ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import {
|
||||
GetTraceSummary200,
|
||||
SpantypesGettableTraceSummaryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getGetTraceSummaryQueryKey } from 'api/generated/services/tracedetail';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { TraceDetailsTab } from '../../../constants';
|
||||
import { TraceDetailEvents } from '../../../events';
|
||||
import { TraceDetailLogEvent } from '../../../hooks/useTraceDetailLogEvent';
|
||||
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
|
||||
import { useThreadDataLoadedEvent } from '../useThreadDataLoadedEvent';
|
||||
|
||||
const SUMMARIES: Record<string, SpantypesGettableTraceSummaryDTO> = {
|
||||
'trace-a': {
|
||||
startTimestampMillis: 1_000,
|
||||
endTimestampMillis: 4_000,
|
||||
totalSpansCount: 12,
|
||||
totalErrorSpansCount: 2,
|
||||
hasMissingSpans: false,
|
||||
rootServiceEntryPoint: 'POST /chat',
|
||||
rootServiceName: 'agent',
|
||||
rootSpanStatusCode: 'STATUS_CODE_ERROR',
|
||||
},
|
||||
'trace-b': {
|
||||
startTimestampMillis: 5_000,
|
||||
endTimestampMillis: 5_500,
|
||||
totalSpansCount: 3,
|
||||
totalErrorSpansCount: 0,
|
||||
hasMissingSpans: false,
|
||||
rootServiceEntryPoint: 'POST /chat',
|
||||
rootServiceName: 'agent',
|
||||
rootSpanStatusCode: 'STATUS_CODE_OK',
|
||||
},
|
||||
};
|
||||
|
||||
interface HookProps {
|
||||
traceId: string;
|
||||
hasSpans: boolean;
|
||||
spanPanelVariant?: SpanDetailVariant;
|
||||
}
|
||||
|
||||
function renderDataLoadedEvent(
|
||||
initialProps: HookProps,
|
||||
firedForRef: MutableRefObject<string> = { current: '' },
|
||||
): {
|
||||
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>;
|
||||
rerender: (props: HookProps) => void;
|
||||
queryClient: QueryClient;
|
||||
} {
|
||||
const logTraceEvent = jest.fn() as jest.MockedFunction<TraceDetailLogEvent>;
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = renderHook(
|
||||
({ traceId, hasSpans, spanPanelVariant }: HookProps) =>
|
||||
useThreadDataLoadedEvent({
|
||||
traceId,
|
||||
hasSpans,
|
||||
spanPanelVariant: spanPanelVariant ?? SpanDetailVariant.DOCKED_RIGHT,
|
||||
firedForRef,
|
||||
logTraceEvent,
|
||||
}),
|
||||
{ initialProps, wrapper },
|
||||
);
|
||||
return { logTraceEvent, rerender, queryClient };
|
||||
}
|
||||
|
||||
describe('useThreadDataLoadedEvent', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get('*/api/v1/traces/:traceId/summary', (req, res, ctx) => {
|
||||
const body: GetTraceSummary200 = {
|
||||
status: 'success',
|
||||
data: SUMMARIES[req.params.traceId as string],
|
||||
};
|
||||
return res(ctx.status(200), ctx.json(body));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the trace summary, then reports it as a thread load', async () => {
|
||||
const { logTraceEvent } = renderDataLoadedEvent({
|
||||
traceId: 'trace-a',
|
||||
hasSpans: true,
|
||||
});
|
||||
|
||||
expect(logTraceEvent).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
|
||||
expect(logTraceEvent).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
|
||||
detailsTab: TraceDetailsTab.Thread,
|
||||
totalSpansCount: 12,
|
||||
traceDurationMs: 3_000,
|
||||
hadErrors: true,
|
||||
spanPanelVariant: SpanDetailVariant.DOCKED_RIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for the thread spans even when the summary is already loaded', async () => {
|
||||
const { logTraceEvent, rerender, queryClient } = renderDataLoadedEvent({
|
||||
traceId: 'trace-a',
|
||||
hasSpans: false,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryClient.getQueryData(
|
||||
getGetTraceSummaryQueryKey({ traceID: 'trace-a' }),
|
||||
),
|
||||
).toBeDefined(),
|
||||
);
|
||||
rerender({ traceId: 'trace-a', hasSpans: false });
|
||||
|
||||
expect(logTraceEvent).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ traceId: 'trace-a', hasSpans: true });
|
||||
|
||||
expect(logTraceEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a trace once, including when the tab is visited again', async () => {
|
||||
const firedForRef = { current: '' };
|
||||
const firstVisit = renderDataLoadedEvent(
|
||||
{ traceId: 'trace-a', hasSpans: true },
|
||||
firedForRef,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
firstVisit.rerender({
|
||||
traceId: 'trace-a',
|
||||
hasSpans: true,
|
||||
spanPanelVariant: SpanDetailVariant.DOCKED,
|
||||
});
|
||||
|
||||
const secondVisit = renderDataLoadedEvent(
|
||||
{ traceId: 'trace-a', hasSpans: true },
|
||||
firedForRef,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
secondVisit.queryClient.getQueryData(
|
||||
getGetTraceSummaryQueryKey({ traceID: 'trace-a' }),
|
||||
),
|
||||
).toBeDefined(),
|
||||
);
|
||||
secondVisit.rerender({ traceId: 'trace-a', hasSpans: true });
|
||||
|
||||
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1);
|
||||
expect(secondVisit.logTraceEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the next trace with its own summary', async () => {
|
||||
const { logTraceEvent, rerender } = renderDataLoadedEvent({
|
||||
traceId: 'trace-a',
|
||||
hasSpans: true,
|
||||
});
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender({ traceId: 'trace-b', hasSpans: true });
|
||||
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
|
||||
expect(logTraceEvent).toHaveBeenLastCalledWith(
|
||||
TraceDetailEvents.DataLoaded,
|
||||
expect.objectContaining({ totalSpansCount: 3, traceDurationMs: 500 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { waitFor } from 'tests/test-utils';
|
||||
|
||||
import { TraceDetailsTab } from '../../../constants';
|
||||
import { TraceDetailEvents } from '../../../events';
|
||||
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
|
||||
import {
|
||||
mockTraceSummaries,
|
||||
renderThreadLoadedEvents,
|
||||
ThreadLoadedEventsProps,
|
||||
waitForSummary,
|
||||
} from './threadLoadedEventsTestUtils';
|
||||
|
||||
describe('useThreadLoadedEvents: data loaded', () => {
|
||||
beforeEach(() => {
|
||||
mockTraceSummaries();
|
||||
});
|
||||
|
||||
it('waits for the trace summary, then reports it as a thread load', async () => {
|
||||
const { logTraceEvent } = renderThreadLoadedEvents({
|
||||
traceId: 'trace-ai',
|
||||
hasSpans: true,
|
||||
});
|
||||
|
||||
expect(logTraceEvent).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
|
||||
expect(logTraceEvent).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
|
||||
detailsTab: TraceDetailsTab.Thread,
|
||||
totalSpansCount: 12,
|
||||
traceDurationMs: 3_000,
|
||||
hadErrors: true,
|
||||
spanPanelVariant: SpanDetailVariant.DOCKED_RIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for the thread spans even when the summary is already loaded', async () => {
|
||||
const { logTraceEvent, rerender, queryClient } = renderThreadLoadedEvents({
|
||||
traceId: 'trace-ai',
|
||||
});
|
||||
await waitForSummary(queryClient, 'trace-ai');
|
||||
rerender({ traceId: 'trace-ai' });
|
||||
|
||||
expect(logTraceEvent).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ traceId: 'trace-ai', hasSpans: true });
|
||||
|
||||
expect(logTraceEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a trace once, including when the tab is visited again', async () => {
|
||||
const dataLoadedFiredForRef = { current: '' };
|
||||
const props: ThreadLoadedEventsProps = {
|
||||
traceId: 'trace-ai',
|
||||
hasSpans: true,
|
||||
};
|
||||
const firstVisit = renderThreadLoadedEvents(props, { dataLoadedFiredForRef });
|
||||
await waitFor(() =>
|
||||
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
firstVisit.rerender({
|
||||
...props,
|
||||
spanPanelVariant: SpanDetailVariant.DOCKED,
|
||||
});
|
||||
|
||||
const secondVisit = renderThreadLoadedEvents(props, {
|
||||
dataLoadedFiredForRef,
|
||||
});
|
||||
await waitForSummary(secondVisit.queryClient, 'trace-ai');
|
||||
secondVisit.rerender(props);
|
||||
|
||||
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1);
|
||||
expect(secondVisit.logTraceEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the next trace with its own summary', async () => {
|
||||
const { logTraceEvent, rerender } = renderThreadLoadedEvents({
|
||||
traceId: 'trace-ai',
|
||||
hasSpans: true,
|
||||
});
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender({ traceId: 'trace-ai-next', hasSpans: true });
|
||||
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
|
||||
expect(logTraceEvent).toHaveBeenLastCalledWith(
|
||||
TraceDetailEvents.DataLoaded,
|
||||
expect.objectContaining({ totalSpansCount: 4, traceDurationMs: 500 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('also reports an empty thread, once each, when an errored thread still holds spans', async () => {
|
||||
const props: ThreadLoadedEventsProps = {
|
||||
traceId: 'trace-ai',
|
||||
hasSpans: true,
|
||||
hasNoThread: true,
|
||||
};
|
||||
const { logTraceEvent, rerender } = renderThreadLoadedEvents(props);
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
|
||||
rerender(props);
|
||||
|
||||
expect(logTraceEvent).toHaveBeenCalledTimes(2);
|
||||
expect(logTraceEvent).toHaveBeenCalledWith(
|
||||
TraceDetailEvents.DataLoaded,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(logTraceEvent).toHaveBeenCalledWith(
|
||||
TraceDetailEvents.ThreadEmptyWithAiSummary,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { waitFor } from 'tests/test-utils';
|
||||
|
||||
import { TraceDetailsTab } from '../../../constants';
|
||||
import { TraceDetailEvents } from '../../../events';
|
||||
import {
|
||||
mockTraceSummaries,
|
||||
renderThreadLoadedEvents,
|
||||
ThreadLoadedEventsProps,
|
||||
waitForSummary,
|
||||
} from './threadLoadedEventsTestUtils';
|
||||
|
||||
describe('useThreadLoadedEvents: thread empty with AI summary', () => {
|
||||
let summaryRequests: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
summaryRequests = mockTraceSummaries();
|
||||
});
|
||||
|
||||
it('reports the trace summary when the thread is empty but the summary has AI usage', async () => {
|
||||
const { logTraceEvent } = renderThreadLoadedEvents({
|
||||
traceId: 'trace-ai',
|
||||
hasNoThread: true,
|
||||
interestedSpanId: 'span-1',
|
||||
});
|
||||
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
|
||||
expect(logTraceEvent).toHaveBeenCalledWith(
|
||||
TraceDetailEvents.ThreadEmptyWithAiSummary,
|
||||
{
|
||||
detailsTab: TraceDetailsTab.Thread,
|
||||
spanId: 'span-1',
|
||||
rootServiceName: 'agent',
|
||||
rootServiceEntryPoint: 'POST /chat',
|
||||
totalSpansCount: 12,
|
||||
traceDurationMs: 3_000,
|
||||
hadErrors: true,
|
||||
hasMissingSpans: true,
|
||||
inputTokens: 1_200,
|
||||
outputTokens: 300,
|
||||
cacheReadTokens: 50,
|
||||
cacheWriteTokens: 10,
|
||||
reasoningTokens: 40,
|
||||
totalCost: 0.0123,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('does not report when the summary has no AI usage', async () => {
|
||||
const props: ThreadLoadedEventsProps = {
|
||||
traceId: 'trace-no-ai',
|
||||
hasNoThread: true,
|
||||
};
|
||||
const { logTraceEvent, rerender, queryClient } =
|
||||
renderThreadLoadedEvents(props);
|
||||
await waitForSummary(queryClient, 'trace-no-ai');
|
||||
rerender(props);
|
||||
|
||||
expect(logTraceEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits until the thread is known to be empty', async () => {
|
||||
const { logTraceEvent, rerender, queryClient } = renderThreadLoadedEvents({
|
||||
traceId: 'trace-ai',
|
||||
});
|
||||
await waitForSummary(queryClient, 'trace-ai');
|
||||
rerender({ traceId: 'trace-ai' });
|
||||
|
||||
expect(logTraceEvent).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ traceId: 'trace-ai', hasNoThread: true });
|
||||
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('reports once per trace and reuses the loaded summary when the tab is visited again', async () => {
|
||||
const threadEmptyFiredForRef = { current: '' };
|
||||
const props: ThreadLoadedEventsProps = {
|
||||
traceId: 'trace-ai',
|
||||
hasNoThread: true,
|
||||
};
|
||||
const first = renderThreadLoadedEvents(props, { threadEmptyFiredForRef });
|
||||
await waitFor(() => expect(first.logTraceEvent).toHaveBeenCalledTimes(1));
|
||||
first.unmount();
|
||||
|
||||
const second = renderThreadLoadedEvents(props, {
|
||||
threadEmptyFiredForRef,
|
||||
queryClient: first.queryClient,
|
||||
});
|
||||
|
||||
expect(second.queryClient.isFetching()).toBe(0);
|
||||
expect(summaryRequests).toStrictEqual(['trace-ai']);
|
||||
expect(second.logTraceEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the next trace with its own summary', async () => {
|
||||
const { logTraceEvent, rerender } = renderThreadLoadedEvents({
|
||||
traceId: 'trace-ai',
|
||||
hasNoThread: true,
|
||||
});
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender({ traceId: 'trace-ai-next', hasNoThread: true });
|
||||
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
|
||||
expect(logTraceEvent).toHaveBeenLastCalledWith(
|
||||
TraceDetailEvents.ThreadEmptyWithAiSummary,
|
||||
expect.objectContaining({
|
||||
totalSpansCount: 4,
|
||||
inputTokens: 80,
|
||||
totalCost: 0.0004,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
|
||||
|
||||
import { TraceDetailsTab } from '../../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../../events';
|
||||
import { TraceDetailLogEvent } from '../../hooks/useTraceDetailLogEvent';
|
||||
import { SpanDetailVariant } from '../../SpanDetailsPanel/constants';
|
||||
|
||||
interface ThreadDataLoadedEventArgs {
|
||||
traceId: string;
|
||||
hasSpans: boolean;
|
||||
spanPanelVariant: SpanDetailVariant;
|
||||
firedForRef: MutableRefObject<string>;
|
||||
logTraceEvent: TraceDetailLogEvent;
|
||||
}
|
||||
|
||||
export function useThreadDataLoadedEvent({
|
||||
traceId,
|
||||
hasSpans,
|
||||
spanPanelVariant,
|
||||
firedForRef,
|
||||
logTraceEvent,
|
||||
}: ThreadDataLoadedEventArgs): void {
|
||||
// No keepPreviousData: a previous trace's summary must not be reported for this one.
|
||||
const { data } = useGetTraceSummary(
|
||||
{ traceID: traceId },
|
||||
{ query: { enabled: !!traceId, refetchOnMount: false } },
|
||||
);
|
||||
const summary = data?.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!traceId || firedForRef.current === traceId || !hasSpans || !summary) {
|
||||
return;
|
||||
}
|
||||
firedForRef.current = traceId;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.DetailsTab]: TraceDetailsTab.Thread,
|
||||
[TraceDetailEventKeys.TotalSpansCount]: summary.totalSpansCount,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
summary.endTimestampMillis - summary.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: summary.totalErrorSpansCount > 0,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]: spanPanelVariant,
|
||||
});
|
||||
}, [traceId, hasSpans, summary, spanPanelVariant, firedForRef, logTraceEvent]);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
|
||||
|
||||
import { TraceDetailEvents } from '../../events';
|
||||
import { TraceDetailLogEvent } from '../../hooks/useTraceDetailLogEvent';
|
||||
import { SpanDetailVariant } from '../../SpanDetailsPanel/constants';
|
||||
import {
|
||||
getThreadDataLoadedEventAttributes,
|
||||
getThreadEmptyWithAiEventAttributes,
|
||||
} from '../utils';
|
||||
|
||||
interface ThreadLoadedEventsArgs {
|
||||
traceId: string;
|
||||
interestedSpanId?: string;
|
||||
hasSpans: boolean;
|
||||
hasNoThread: boolean;
|
||||
spanPanelVariant: SpanDetailVariant;
|
||||
dataLoadedFiredForRef: MutableRefObject<string>;
|
||||
threadEmptyFiredForRef: MutableRefObject<string>;
|
||||
logTraceEvent: TraceDetailLogEvent;
|
||||
}
|
||||
|
||||
export function useThreadLoadedEvents({
|
||||
traceId,
|
||||
interestedSpanId,
|
||||
hasSpans,
|
||||
hasNoThread,
|
||||
spanPanelVariant,
|
||||
dataLoadedFiredForRef,
|
||||
threadEmptyFiredForRef,
|
||||
logTraceEvent,
|
||||
}: ThreadLoadedEventsArgs): void {
|
||||
// No keepPreviousData: a previous trace's summary must not be reported for this one.
|
||||
const { data } = useGetTraceSummary(
|
||||
{ traceID: traceId },
|
||||
{ query: { enabled: !!traceId, refetchOnMount: false } },
|
||||
);
|
||||
const summary = data?.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!traceId || !summary) {
|
||||
return;
|
||||
}
|
||||
// Not exclusive: an errored thread can still hold spans, so each event keeps its own ref.
|
||||
if (hasSpans && dataLoadedFiredForRef.current !== traceId) {
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
logTraceEvent(
|
||||
TraceDetailEvents.DataLoaded,
|
||||
getThreadDataLoadedEventAttributes(summary, spanPanelVariant),
|
||||
);
|
||||
}
|
||||
if (hasNoThread && summary.ai && threadEmptyFiredForRef.current !== traceId) {
|
||||
threadEmptyFiredForRef.current = traceId;
|
||||
logTraceEvent(
|
||||
TraceDetailEvents.ThreadEmptyWithAiSummary,
|
||||
getThreadEmptyWithAiEventAttributes(summary, interestedSpanId),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
traceId,
|
||||
interestedSpanId,
|
||||
hasSpans,
|
||||
hasNoThread,
|
||||
summary,
|
||||
spanPanelVariant,
|
||||
dataLoadedFiredForRef,
|
||||
threadEmptyFiredForRef,
|
||||
logTraceEvent,
|
||||
]);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { SpantypesThreadSpanDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SpantypesGettableTraceSummaryDTO,
|
||||
SpantypesThreadSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { isAxiosError } from 'axios';
|
||||
|
||||
import { collectToolCallKeys } from '../AIThreadMessage/utils';
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys } from '../events';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import { InterestedSpanStatus, ThreadSpan } from './types';
|
||||
|
||||
/** Derives SpanV3's `timestamp` and `service.name`, as the waterfall does. */
|
||||
@@ -47,3 +53,44 @@ export function getInterestedSpanStatus(
|
||||
? InterestedSpanStatus.Found
|
||||
: InterestedSpanStatus.NoIOMessages;
|
||||
}
|
||||
|
||||
function getThreadSummaryAttributes(
|
||||
summary: SpantypesGettableTraceSummaryDTO,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
[TraceDetailEventKeys.DetailsTab]: TraceDetailsTab.Thread,
|
||||
[TraceDetailEventKeys.TotalSpansCount]: summary.totalSpansCount,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
summary.endTimestampMillis - summary.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: summary.totalErrorSpansCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getThreadDataLoadedEventAttributes(
|
||||
summary: SpantypesGettableTraceSummaryDTO,
|
||||
spanPanelVariant: SpanDetailVariant,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...getThreadSummaryAttributes(summary),
|
||||
[TraceDetailEventKeys.SpanPanelVariant]: spanPanelVariant,
|
||||
};
|
||||
}
|
||||
|
||||
export function getThreadEmptyWithAiEventAttributes(
|
||||
summary: SpantypesGettableTraceSummaryDTO,
|
||||
interestedSpanId: string | undefined,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...getThreadSummaryAttributes(summary),
|
||||
[TraceDetailEventKeys.SpanId]: interestedSpanId,
|
||||
[TraceDetailEventKeys.RootServiceName]: summary.rootServiceName,
|
||||
[TraceDetailEventKeys.RootServiceEntryPoint]: summary.rootServiceEntryPoint,
|
||||
[TraceDetailEventKeys.HasMissingSpans]: summary.hasMissingSpans,
|
||||
[TraceDetailEventKeys.InputTokens]: summary.ai?.tokens.input,
|
||||
[TraceDetailEventKeys.OutputTokens]: summary.ai?.tokens.output,
|
||||
[TraceDetailEventKeys.CacheReadTokens]: summary.ai?.tokens.cacheRead,
|
||||
[TraceDetailEventKeys.CacheWriteTokens]: summary.ai?.tokens.cacheWrite,
|
||||
[TraceDetailEventKeys.ReasoningTokens]: summary.ai?.tokens.reasoning,
|
||||
[TraceDetailEventKeys.TotalCost]: summary.ai?.totalCost,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export enum TraceDetailEvents {
|
||||
SpanPercentileAttributeChanged = 'Trace Detail: Span percentile attribute changed',
|
||||
SpanDetailsOpened = 'Trace Detail: Span details opened',
|
||||
DetailsTabChanged = 'Trace Detail: Details tab changed',
|
||||
ThreadEmptyWithAiSummary = 'Trace Detail: Thread empty with AI summary',
|
||||
}
|
||||
|
||||
export enum TraceDetailEventKeys {
|
||||
@@ -48,6 +49,16 @@ export enum TraceDetailEventKeys {
|
||||
Selected = 'selected',
|
||||
// Data loaded / span details opened: which tab the user is on
|
||||
DetailsTab = 'detailsTab',
|
||||
// Thread empty with AI summary (reuses SpanId, DetailsTab, TotalSpansCount, TraceDurationMs, HadErrors)
|
||||
RootServiceName = 'rootServiceName',
|
||||
RootServiceEntryPoint = 'rootServiceEntryPoint',
|
||||
HasMissingSpans = 'hasMissingSpans',
|
||||
InputTokens = 'inputTokens',
|
||||
OutputTokens = 'outputTokens',
|
||||
CacheReadTokens = 'cacheReadTokens',
|
||||
CacheWriteTokens = 'cacheWriteTokens',
|
||||
ReasoningTokens = 'reasoningTokens',
|
||||
TotalCost = 'totalCost',
|
||||
}
|
||||
|
||||
export type TraceDetailView = 'v2' | 'v3';
|
||||
|
||||
@@ -17,6 +17,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef<string>('');
|
||||
const threadDataLoadedFiredForRef = useRef<string>('');
|
||||
const threadEmptyFiredForRef = useRef<string>('');
|
||||
|
||||
const handleFilteredSpansChange = useCallback(
|
||||
(spanIds: string[], isActive: boolean): void => {
|
||||
@@ -40,7 +41,10 @@ function TraceDetailsV3(): JSX.Element {
|
||||
dataLoadedFiredForRef={dataLoadedFiredForRef}
|
||||
/>
|
||||
) : (
|
||||
<TraceDetailsThread dataLoadedFiredForRef={threadDataLoadedFiredForRef} />
|
||||
<TraceDetailsThread
|
||||
dataLoadedFiredForRef={threadDataLoadedFiredForRef}
|
||||
threadEmptyFiredForRef={threadEmptyFiredForRef}
|
||||
/>
|
||||
)}
|
||||
</TraceDetailsWrapper>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { SpantypesPostableTraceAggregationsDTO } from 'api/generated/servic
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
import type { QueryRangeRequestV5, RawRow } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -150,6 +150,12 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
value: 8,
|
||||
max: 20,
|
||||
}),
|
||||
traceOnlyLogs: toggleControl('Logs only on the trace', {
|
||||
group: PANEL,
|
||||
description:
|
||||
'The span has no logs of its own but the trace does, which is the Logs tab\'s "view logs for the current trace" state.',
|
||||
value: false,
|
||||
}),
|
||||
exportState: choiceControl<ExportState>('Trace export', {
|
||||
group: EXPORT,
|
||||
description:
|
||||
@@ -165,6 +171,12 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
errors: values.errors,
|
||||
traceStart: traceStartOf(),
|
||||
};
|
||||
const spanLogRowsFor = (body: QueryRangeRequestV5): RawRow[] => {
|
||||
const scope = spanLogScopeOf(body);
|
||||
const count =
|
||||
values.traceOnlyLogs && scope !== 'trace' ? 0 : values.spanLogs;
|
||||
return spanLogRows(count, trace, scope);
|
||||
};
|
||||
|
||||
return [
|
||||
rest.post('http://localhost/api/v1/export_raw_data', (_req, res, ctx) => {
|
||||
@@ -266,9 +278,7 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
|
||||
if (requestType === 'raw') {
|
||||
return queryRangeV5RawResponse(
|
||||
signalOf(body) === 'logs'
|
||||
? spanLogRows(values.spanLogs, trace, spanLogScopeOf(body))
|
||||
: spanMatchRows(trace),
|
||||
signalOf(body) === 'logs' ? spanLogRowsFor(body) : spanMatchRows(trace),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
focusSpanIndex,
|
||||
scenarioSpanCount,
|
||||
spanIdAt,
|
||||
STORY_TRACE_ID,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
import {
|
||||
openRowMenu,
|
||||
@@ -249,18 +248,35 @@ export const SpanPercentileOpen: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The span's trace id clicked in the panel, which copies it. */
|
||||
export const TraceIdCopiedToast: Story = {
|
||||
/** The span has no logs of its own but its trace does, so the Logs tab points to the trace's. */
|
||||
export const SpanPanelTraceOnlyLogs: Story = {
|
||||
args: { traceOnlyLogs: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole('tab', { name: /Logs/ }, untilLoaded),
|
||||
);
|
||||
await expect(
|
||||
await within(canvasElement).findByText(
|
||||
'No logs found for selected span. View logs for the current trace.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** The span panel's trace id copied with its copy button, which turns into a check. */
|
||||
export const TraceIdCopied: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: STORY_TRACE_ID },
|
||||
{ name: 'Copy trace ID' },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
await screen.findByText('Trace ID copied to clipboard'),
|
||||
await within(canvasElement).findByRole('button', { name: 'Copied' }),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { spanPercentilesResponse } from 'pages/TraceDetailsV3/stories/__story_mockdata__/traceDetails';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
import { explorerViewToPanelType } from 'utils/explorerUtils';
|
||||
|
||||
@@ -27,6 +32,10 @@ import {
|
||||
import {
|
||||
exportDashboardsResponse,
|
||||
savedTraceViewsResponse,
|
||||
SPAN_LOGS_STATES,
|
||||
spanLogRows,
|
||||
spanLogScopeOf,
|
||||
type SpanLogsState,
|
||||
traceAttributeValuesResponse,
|
||||
traceFieldKeysResponse,
|
||||
traceFieldValues,
|
||||
@@ -34,8 +43,11 @@ import {
|
||||
traceQuickFiltersResponse,
|
||||
traceRootSpanRows,
|
||||
traceSpanRows,
|
||||
TRACE_SUMMARY_STATES,
|
||||
traceSummaryResponse,
|
||||
TRACES_EXPLORER_VIEWS,
|
||||
TRACES_TABS,
|
||||
type TraceSummaryState,
|
||||
type TracesTab,
|
||||
} from './__story_mockdata__/traces';
|
||||
|
||||
@@ -65,6 +77,8 @@ const tracesRoute = ({ tab, view }: RouteValues): string => {
|
||||
interface RawQuerySpec {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
signal?: string;
|
||||
filter?: { expression?: string };
|
||||
}
|
||||
|
||||
const rawSpecOf = (body: QueryRangeRequestV5): RawQuerySpec => {
|
||||
@@ -73,6 +87,9 @@ const rawSpecOf = (body: QueryRangeRequestV5): RawQuerySpec => {
|
||||
return spec as RawQuerySpec;
|
||||
};
|
||||
|
||||
// The summary has no window of its own; its bounds wrap the span the list showed.
|
||||
const listWindow = { start: 0, end: 0 };
|
||||
|
||||
export const tracesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
tab: choiceControl<TracesTab>('Tab', {
|
||||
@@ -102,6 +119,26 @@ export const tracesMocks = defineStoryMocks({
|
||||
'Answers 503 on the spans of the services that fail, which is what the status column and the error colouring read.',
|
||||
value: true,
|
||||
}),
|
||||
spanDrawer: toggleControl('Span drawer', {
|
||||
group: LIST,
|
||||
description:
|
||||
'The `TRACES_SPAN_DRAWER_ENABLED` localStorage flag the span drawer ships behind. Off, a row click opens the trace page.',
|
||||
value: false,
|
||||
}),
|
||||
traceSummary: choiceControl<TraceSummaryState>('Trace summary', {
|
||||
group: LIST,
|
||||
description:
|
||||
'What the span drawer gets for the trace start and end. Not found falls back to span-only logs; loading holds the logs tab.',
|
||||
options: TRACE_SUMMARY_STATES,
|
||||
value: 'found',
|
||||
}),
|
||||
spanLogs: choiceControl<SpanLogsState>('Span logs', {
|
||||
group: LIST,
|
||||
description:
|
||||
"What the span drawer's Logs tab gets. `span`: the span's logs with context either side; `trace only`: none for the span but some for the trace; `none`; `loading` and `error` hold or fail only the log queries.",
|
||||
options: SPAN_LOGS_STATES,
|
||||
value: 'span',
|
||||
}),
|
||||
warning: toggleControl('Query warning', {
|
||||
group: LIST,
|
||||
description: 'Attaches a warning to the response, next to the toolbar.',
|
||||
@@ -169,6 +206,96 @@ export const tracesMocks = defineStoryMocks({
|
||||
response.json(exportDashboardsResponse),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/span_percentile',
|
||||
response.json(spanPercentilesResponse),
|
||||
),
|
||||
|
||||
// Answers on its own control rather than the Data one, so the drawer's
|
||||
// fallback and waiting states are reachable with the list loaded.
|
||||
rest.get(
|
||||
'http://localhost/api/v1/traces/:traceId/summary',
|
||||
(req, res, ctx) => {
|
||||
if (values.traceSummary === 'loading') {
|
||||
return res(ctx.delay('infinite'));
|
||||
}
|
||||
const summary =
|
||||
values.traceSummary === 'found'
|
||||
? traceSummaryResponse({
|
||||
traceId: String(req.params.traceId),
|
||||
count: values.spans,
|
||||
...listWindow,
|
||||
})
|
||||
: null;
|
||||
|
||||
return summary
|
||||
? res(ctx.status(200), ctx.json(summary))
|
||||
: res(ctx.status(404), ctx.json({ status: 'error' }));
|
||||
},
|
||||
),
|
||||
|
||||
// The drawer's percentile panel dereferences this preference without a guard.
|
||||
rest.get(
|
||||
'http://localhost/api/v1/user/preferences/:name',
|
||||
response.json((req) => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
name: String(req.params.name),
|
||||
description: 'storybook',
|
||||
valueType: 'array',
|
||||
defaultValue: [],
|
||||
allowedValues: [],
|
||||
allowedScopes: ['user'],
|
||||
value:
|
||||
String(req.params.name) ===
|
||||
USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES
|
||||
? ['service.name']
|
||||
: [],
|
||||
},
|
||||
})),
|
||||
),
|
||||
|
||||
// The drawer's log queries answer on their own control, so they can hang or
|
||||
// fail with the list loaded. Anything else falls through to the next handler.
|
||||
rest.post('http://localhost/api/v5/query_range', async (req, res, ctx) => {
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const { signal, filter } = rawSpecOf(body);
|
||||
if (signal !== 'logs') {
|
||||
return undefined;
|
||||
}
|
||||
if (values.spanLogs === 'loading') {
|
||||
return res(ctx.delay('infinite'));
|
||||
}
|
||||
if (values.spanLogs === 'error') {
|
||||
return res(
|
||||
ctx.status(500),
|
||||
ctx.json({ status: 'error', error: 'storybook logs failure' }),
|
||||
);
|
||||
}
|
||||
|
||||
const expression = filter?.expression ?? '';
|
||||
const scope = spanLogScopeOf(expression);
|
||||
const answers =
|
||||
values.spanLogs === 'span' ||
|
||||
(values.spanLogs === 'trace only' && scope === 'trace');
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
queryRangeV5RawResponse(
|
||||
answers
|
||||
? spanLogRows({
|
||||
scope,
|
||||
expression,
|
||||
startMicros: body.start,
|
||||
endMicros: body.end,
|
||||
})
|
||||
: [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
@@ -195,6 +322,9 @@ export const tracesMocks = defineStoryMocks({
|
||||
: undefined;
|
||||
|
||||
if (requestType === 'raw' || requestType === 'trace') {
|
||||
listWindow.start = start;
|
||||
listWindow.end = end;
|
||||
|
||||
return queryRangeV5RawResponse(
|
||||
requestType === 'trace' ? traceRootSpanRows(rows) : traceSpanRows(rows),
|
||||
{
|
||||
@@ -232,4 +362,11 @@ export const tracesMocks = defineStoryMocks({
|
||||
),
|
||||
],
|
||||
config: (values) => ({ route: tracesRoute(values) }),
|
||||
effect: (values) => {
|
||||
if (values.spanDrawer) {
|
||||
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
|
||||
} else {
|
||||
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ const pageStory = storyMocks(tracesMocks, { layout: 'app' });
|
||||
* Route: `/traces-explorer`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Traces/Explorer',
|
||||
title: 'Pages/Traces/Explorer/Overview',
|
||||
tags: ['play'],
|
||||
component: TracesModulePage,
|
||||
...pageStory,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
GetQuickFilters200,
|
||||
GetTraceSummary200,
|
||||
ListSavedViews200,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -173,6 +174,22 @@ const flatSpanRows = ({
|
||||
};
|
||||
});
|
||||
|
||||
/** The list API's event shape: `attributes`, where the trace API says `attributeMap`. */
|
||||
const exceptionEvent = (
|
||||
timestamp: string,
|
||||
): {
|
||||
name: string;
|
||||
timeUnixNano: number;
|
||||
attributes: Record<string, string>;
|
||||
} => ({
|
||||
name: 'exception',
|
||||
timeUnixNano: new Date(timestamp).getTime() * 1_000_000,
|
||||
attributes: {
|
||||
'exception.type': 'UpstreamTimeoutError',
|
||||
'exception.message': 'upstream did not answer within 1s',
|
||||
},
|
||||
});
|
||||
|
||||
/** The List view returns every span field, with resource and attribute fields nested in maps. */
|
||||
export const traceSpanRows = (options: SpanRowsOptions): RawRow[] =>
|
||||
flatSpanRows(options).map(
|
||||
@@ -182,6 +199,10 @@ export const traceSpanRows = (options: SpanRowsOptions): RawRow[] =>
|
||||
...data,
|
||||
attributes: {},
|
||||
resource: { 'service.name': serviceName },
|
||||
events: data.has_error ? [exceptionEvent(timestamp)] : [],
|
||||
links: data.has_error
|
||||
? [{ traceId: traceIdAt(0), spanId: spanIdAt(0) }]
|
||||
: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -196,6 +217,122 @@ export const traceRootSpanRows = (options: SpanRowsOptions): RawRow[] =>
|
||||
},
|
||||
}));
|
||||
|
||||
export const TRACE_SUMMARY_STATES = ['found', 'not found', 'loading'] as const;
|
||||
|
||||
export type TraceSummaryState = (typeof TRACE_SUMMARY_STATES)[number];
|
||||
|
||||
const TRACE_PADDING_MS = 250;
|
||||
|
||||
/** Null for a trace the list never showed. */
|
||||
export const traceSummaryResponse = ({
|
||||
traceId,
|
||||
count,
|
||||
start,
|
||||
end,
|
||||
}: Pick<SpanRowsOptions, 'count' | 'start' | 'end'> & {
|
||||
traceId: string;
|
||||
}): GetTraceSummary200 | null => {
|
||||
const index = rowWindow({ count, offset: 0, limit: count }).find(
|
||||
(i) => traceIdAt(i) === traceId,
|
||||
);
|
||||
if (index === undefined) {
|
||||
return null;
|
||||
}
|
||||
const spanStart = new Date(timestampAt(index, start, end)).getTime();
|
||||
const span = SPANS[index % SPANS.length];
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
startTimestampMillis: spanStart - TRACE_PADDING_MS,
|
||||
endTimestampMillis: spanStart + span.durationMs + TRACE_PADDING_MS,
|
||||
rootServiceName: SPANS[0].service,
|
||||
rootServiceEntryPoint: SPANS[0].operation,
|
||||
rootSpanStatusCode: '200',
|
||||
totalSpansCount: SPANS.length,
|
||||
totalErrorSpansCount: SPANS.filter(({ failing }) => failing).length,
|
||||
hasMissingSpans: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const SPAN_LOGS_STATES = [
|
||||
'span',
|
||||
'trace only',
|
||||
'none',
|
||||
'loading',
|
||||
'error',
|
||||
] as const;
|
||||
|
||||
export type SpanLogsState = (typeof SPAN_LOGS_STATES)[number];
|
||||
|
||||
/**
|
||||
* Which of the drawer's log queries is asking: the span's own logs, the context
|
||||
* either side of them, or the whole trace when the span has none.
|
||||
*/
|
||||
export type SpanLogScope = 'span' | 'before' | 'after' | 'trace';
|
||||
|
||||
export const spanLogScopeOf = (expression: string): SpanLogScope => {
|
||||
if (expression.includes('span_id')) {
|
||||
return 'span';
|
||||
}
|
||||
if (expression.includes('id <')) {
|
||||
return 'before';
|
||||
}
|
||||
return expression.includes('id >') ? 'after' : 'trace';
|
||||
};
|
||||
|
||||
const SPAN_LOG_BODIES = [
|
||||
'request received',
|
||||
'cache miss, querying orders',
|
||||
'payment provider timed out',
|
||||
'response sent',
|
||||
];
|
||||
|
||||
const CONTEXT_OFFSET_MS: Record<SpanLogScope, number> = {
|
||||
before: -60_000,
|
||||
span: 0,
|
||||
after: 60_000,
|
||||
trace: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Log rows for one of the drawer's queries, placed in the middle of the window
|
||||
* it asked for. The span-log queries send that window in microseconds.
|
||||
*/
|
||||
export const spanLogRows = ({
|
||||
scope,
|
||||
expression,
|
||||
startMicros,
|
||||
endMicros,
|
||||
}: {
|
||||
scope: SpanLogScope;
|
||||
expression: string;
|
||||
startMicros: number;
|
||||
endMicros: number;
|
||||
}): RawRow[] => {
|
||||
const traceId = expression.match(/trace_id = '([^']+)'/)?.[1] ?? '';
|
||||
const spanId = expression.match(/span_id = '([^']+)'/)?.[1] ?? '';
|
||||
const middle = (startMicros + endMicros) / 2 / 1e3;
|
||||
|
||||
return SPAN_LOG_BODIES.map((body, index) => ({
|
||||
timestamp: new Date(
|
||||
middle + CONTEXT_OFFSET_MS[scope] + index * 100,
|
||||
).toISOString(),
|
||||
data: {
|
||||
body,
|
||||
id: `storybook-${scope}-log-${index + 1}`,
|
||||
trace_id: traceId,
|
||||
span_id: scope === 'span' ? spanId : '',
|
||||
severity_text: index === 2 ? 'ERROR' : 'INFO',
|
||||
severity_number: index === 2 ? 17 : 9,
|
||||
attributes_string: {},
|
||||
resources_string: { 'service.name': SPANS[0].service },
|
||||
scope_name: '',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const { resource, attribute } = TelemetrytypesFieldContextDTO;
|
||||
const { string: stringType, bool } = TelemetrytypesFieldDataTypeDTO;
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { tracesMocks } from '../TracesModulePage.stories.mocks';
|
||||
|
||||
import TracesModulePage from '../../TracesModulePage';
|
||||
|
||||
type TracesArgs = PageStoryArgs<typeof tracesMocks>;
|
||||
|
||||
const pageStory = storyMocks(tracesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The span details drawer the explorer list opens on a row, one story per state
|
||||
* of its trace summary and its Logs tab. Every story opens the same failing
|
||||
* span; the Trace summary and Span logs controls pick what the drawer gets.
|
||||
*
|
||||
* Route: `/traces-explorer`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Traces/Explorer/Span Drawer',
|
||||
tags: ['play'],
|
||||
component: TracesModulePage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
args: { spanDrawer: true },
|
||||
} satisfies Meta<TracesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<TracesArgs>;
|
||||
|
||||
/** The page fetches before it renders its rows, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
// The quick filters list the same span name, so the row is found by its test
|
||||
// id; the table renders its rows a few at a time, so it waits for this one.
|
||||
const openDrawer = async (): Promise<HTMLElement> => {
|
||||
const row = await waitFor((): HTMLElement => {
|
||||
const match = screen
|
||||
.getAllByTestId(/^traces-table-row-/)
|
||||
.find((candidate) => candidate.textContent?.includes('POST /api/orders'));
|
||||
|
||||
expect(match).toBeDefined();
|
||||
|
||||
return match as HTMLElement;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(within(row).getByText('POST /api/orders'));
|
||||
|
||||
return screen.findByTestId('span-details-drawer');
|
||||
};
|
||||
|
||||
const openLogsTab = async (): Promise<HTMLElement> => {
|
||||
const drawer = await openDrawer();
|
||||
|
||||
await userEvent.click(within(drawer).getByRole('tab', { name: /Logs/ }));
|
||||
|
||||
return drawer;
|
||||
};
|
||||
|
||||
/**
|
||||
* The drawer on a failing span: its summary with % exec time, attributes, the
|
||||
* exception event and a linked span, with up and down stepping through the page.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openDrawer();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
/of total exec time/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** The Logs tab with the span's logs, between the logs just before and after them. */
|
||||
export const Logs: Story = {
|
||||
args: { spanLogs: 'span' },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
(
|
||||
await within(drawer).findAllByText(
|
||||
/payment provider timed out/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)[0],
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** The span has no logs of its own but its trace does, so the tab points to the trace's. */
|
||||
export const TraceOnlyLogs: Story = {
|
||||
args: { spanLogs: 'trace only' },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for selected span. View logs for the current trace.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** Neither the span nor its trace has logs. */
|
||||
export const NoLogs: Story = {
|
||||
args: { spanLogs: 'none' },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for this trace.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The trace summary 404s, so the drawer speaks only for the span: no % exec
|
||||
* time, logs over the span's own window and no trace-wide logs check.
|
||||
*/
|
||||
export const WithoutTraceSummary: Story = {
|
||||
args: { traceSummary: 'not found', spanLogs: 'none' },
|
||||
// The deliberate 404 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for this span.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
within(drawer).queryByText(/of total exec time/),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** The trace summary never answers: the Logs tab waits and its explorer link is off. */
|
||||
export const TraceSummaryLoading: Story = {
|
||||
args: { traceSummary: 'loading' },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await screen.findByTestId('open-in-explorer-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
},
|
||||
};
|
||||
|
||||
/** The log queries fail while the rest of the drawer renders. */
|
||||
export const LogsError: Story = {
|
||||
args: { spanLogs: 'error' },
|
||||
// The deliberate 500 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
/Something went wrong/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
@@ -182,7 +182,7 @@ func postProcessBuilderQuery[T any](
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
) *qbtypes.Result {
|
||||
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Offset, query.Order)
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Order)
|
||||
|
||||
// Apply functions
|
||||
if len(query.Functions) > 0 {
|
||||
@@ -234,7 +234,7 @@ func postProcessMetricQuery(
|
||||
reducesToScalar := req.RequestType == qbtypes.RequestTypeScalar && config.ReduceTo != qbtypes.ReduceToUnknown
|
||||
|
||||
if !reducesToScalar {
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Offset, query.Order)
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Order)
|
||||
}
|
||||
|
||||
if len(query.Functions) > 0 {
|
||||
@@ -246,7 +246,7 @@ func postProcessMetricQuery(
|
||||
// Apply reduce to for scalar request type
|
||||
if reducesToScalar {
|
||||
result = q.applyMetricReduceTo(result, config.ReduceTo)
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Offset, query.Order)
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Order)
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
result.Value = convertTimeSeriesDataToScalar(tsData, tsData.QueryName)
|
||||
}
|
||||
@@ -263,7 +263,7 @@ func postProcessTraceOperator(
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
) *qbtypes.Result {
|
||||
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Offset, query.Order)
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Order)
|
||||
|
||||
// Apply functions if any
|
||||
if len(query.Functions) > 0 {
|
||||
@@ -296,7 +296,7 @@ func (q *querier) applyMetricReduceTo(result *qbtypes.Result, reduceOp qbtypes.R
|
||||
}
|
||||
|
||||
// applySeriesLimit limits the number of series in the result.
|
||||
func (q *querier) applySeriesLimit(result *qbtypes.Result, limit, offset int, orderBy []qbtypes.OrderBy) *qbtypes.Result {
|
||||
func (q *querier) applySeriesLimit(result *qbtypes.Result, limit int, orderBy []qbtypes.OrderBy) *qbtypes.Result {
|
||||
tsData, ok := result.Value.(*qbtypes.TimeSeriesData)
|
||||
if !ok {
|
||||
return result
|
||||
@@ -305,7 +305,7 @@ func (q *querier) applySeriesLimit(result *qbtypes.Result, limit, offset int, or
|
||||
if tsData != nil {
|
||||
for _, agg := range tsData.Aggregations {
|
||||
// Use the ApplySeriesLimit function from querybuildertypes
|
||||
agg.Series = qbtypes.ApplySeriesLimit(agg.Series, orderBy, limit, offset)
|
||||
agg.Series = qbtypes.ApplySeriesLimit(agg.Series, orderBy, limit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
result := q.processTimeSeriesFormula(ctx, results, formula, req)
|
||||
if result != nil {
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Offset, formula.Order)
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
case qbtypes.RequestTypeHeatmap:
|
||||
@@ -370,7 +370,7 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
|
||||
bucketFormulaOutputAsHeatmap(tsData, bucketing)
|
||||
resolveHeatmapBucketAxis(tsData, bucketing)
|
||||
}
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Offset, formula.Order)
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
@@ -651,7 +651,7 @@ func (q *querier) processScalarFormula(
|
||||
}
|
||||
|
||||
// Apply ordering (and limit) before converting to scalar format.
|
||||
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit, formula.Offset)
|
||||
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit)
|
||||
|
||||
// Convert back to scalar format
|
||||
scalarResult := &qbtypes.ScalarData{
|
||||
|
||||
@@ -186,7 +186,6 @@ func TestPostProcessMetricQuery_LimitRanksByReducedValue(t *testing.T) {
|
||||
name string
|
||||
reduceTo qbtypes.ReduceTo
|
||||
order []qbtypes.OrderBy
|
||||
offset int
|
||||
expected [][]any
|
||||
}{
|
||||
{
|
||||
@@ -212,13 +211,6 @@ func TestPostProcessMetricQuery_LimitRanksByReducedValue(t *testing.T) {
|
||||
reduceTo: qbtypes.ReduceToMax,
|
||||
expected: [][]any{{"payment", 40.0}, {"auth", 30.0}},
|
||||
},
|
||||
{
|
||||
name: "Max_Desc_Offset1_SkipsHighestPeak",
|
||||
reduceTo: qbtypes.ReduceToMax,
|
||||
order: orderByValue(qbtypes.OrderDirectionDesc),
|
||||
offset: 1,
|
||||
expected: [][]any{{"auth", 30.0}, {"checkout", 10.0}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -228,7 +220,6 @@ func TestPostProcessMetricQuery_LimitRanksByReducedValue(t *testing.T) {
|
||||
Aggregations: []qbtypes.MetricAggregation{{MetricName: "http_requests_total", ReduceTo: testCase.reduceTo}},
|
||||
Order: testCase.order,
|
||||
Limit: 2,
|
||||
Offset: testCase.offset,
|
||||
}
|
||||
req := &qbtypes.QueryRangeRequest{RequestType: qbtypes.RequestTypeScalar}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package querybuilder
|
||||
|
||||
import "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
// AddOffset skips offset rows. Call it after the ORDER BY and LIMIT clauses:
|
||||
// the ClickHouse flavor renders OFFSET only inside a LIMIT, so an unlimited
|
||||
// page writes the clause itself at the current position.
|
||||
func AddOffset(sb *sqlbuilder.SelectBuilder, limit, offset int) {
|
||||
if offset <= 0 {
|
||||
return
|
||||
}
|
||||
if limit > 0 {
|
||||
sb.Offset(offset)
|
||||
return
|
||||
}
|
||||
sb.SQL("OFFSET " + sb.Var(offset))
|
||||
}
|
||||
@@ -374,11 +374,7 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
|
||||
if query.Limit > 0 && len(query.GroupBy) > 0 {
|
||||
cteSB := sqlbuilder.NewSelectBuilder()
|
||||
// the series skip happens after the query, so the top-N must still include the skipped groups
|
||||
cteQuery := query
|
||||
cteQuery.Limit += query.Offset
|
||||
cteQuery.Offset = 0
|
||||
cteStmt, err := b.buildScalarQuery(ctx, orgID, cteSB, cteQuery, start, end, keys, true, variables)
|
||||
cteStmt, err := b.buildScalarQuery(ctx, orgID, cteSB, query, start, end, keys, true, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -540,8 +536,6 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
|
||||
sb.Limit(query.Limit)
|
||||
}
|
||||
|
||||
querybuilder.AddOffset(sb, query.Limit, query.Offset)
|
||||
|
||||
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||
|
||||
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||
|
||||
@@ -506,11 +506,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
if query.Limit > 0 && len(query.GroupBy) > 0 {
|
||||
// build the scalar “top/bottom-N” query in its own builder.
|
||||
cteSB := sqlbuilder.NewSelectBuilder()
|
||||
// the series skip happens after the query, so the top-N must still include the skipped groups
|
||||
cteQuery := query
|
||||
cteQuery.Limit += query.Offset
|
||||
cteQuery.Offset = 0
|
||||
cteStmt, err := b.buildScalarQuery(ctx, orgID, cteSB, cteQuery, start, end, keys, true, variables)
|
||||
cteStmt, err := b.buildScalarQuery(ctx, orgID, cteSB, query, start, end, keys, true, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -711,8 +707,6 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
sb.Limit(query.Limit)
|
||||
}
|
||||
|
||||
querybuilder.AddOffset(sb, query.Limit, query.Offset)
|
||||
|
||||
combinedArgs := allAggChArgs
|
||||
|
||||
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||
|
||||
@@ -654,10 +654,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
|
||||
cteFragments = append(cteFragments, fmt.Sprintf("__scoped_traces_total AS (%s)", totalSQL))
|
||||
cteArgs = append(cteArgs, totalArgs)
|
||||
|
||||
// the series skip happens after the query, so the top-N must still include the skipped groups
|
||||
cteQuery := query
|
||||
cteQuery.Limit += query.Offset
|
||||
limitSQL, limitArgs := outerLimitSQL(cteQuery, traceAggs, groupNames, windowSeconds)
|
||||
limitSQL, limitArgs := outerLimitSQL(query, traceAggs, groupNames, windowSeconds)
|
||||
cteFragments = append(cteFragments, fmt.Sprintf("__limit_cte AS (%s)", limitSQL))
|
||||
cteArgs = append(cteArgs, limitArgs)
|
||||
|
||||
@@ -725,7 +722,6 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
|
||||
if query.Limit > 0 {
|
||||
sb.Limit(query.Limit)
|
||||
}
|
||||
querybuilder.AddOffset(sb, query.Limit, query.Offset)
|
||||
}
|
||||
|
||||
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
@@ -632,11 +632,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
if query.Limit > 0 && len(query.GroupBy) > 0 {
|
||||
// build the scalar “top/bottom-N” query in its own builder.
|
||||
cteSB := sqlbuilder.NewSelectBuilder()
|
||||
// the series skip happens after the query, so the top-N must still include the skipped groups
|
||||
cteQuery := query
|
||||
cteQuery.Limit += query.Offset
|
||||
cteQuery.Offset = 0
|
||||
cteStmt, err := b.buildScalarQuery(ctx, orgID, cteSB, cteQuery, start, end, keys, variables, true, true)
|
||||
cteStmt, err := b.buildScalarQuery(ctx, orgID, cteSB, query, start, end, keys, variables, true, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -839,8 +835,6 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
sb.Limit(query.Limit)
|
||||
}
|
||||
|
||||
querybuilder.AddOffset(sb, query.Limit, query.Offset)
|
||||
|
||||
combinedArgs := allAggChArgs
|
||||
|
||||
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||
|
||||
@@ -841,8 +841,6 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
sb.Limit(b.operator.Limit)
|
||||
}
|
||||
|
||||
querybuilder.AddOffset(sb, b.operator.Limit, b.operator.Offset)
|
||||
|
||||
combinedArgs := allAggChArgs
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||
@@ -924,8 +922,6 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
|
||||
sb.Limit(b.operator.Limit)
|
||||
}
|
||||
|
||||
querybuilder.AddOffset(sb, b.operator.Limit, b.operator.Offset)
|
||||
|
||||
combinedArgs := allAggChArgs
|
||||
|
||||
// Add HAVING clause if specified
|
||||
|
||||
@@ -20,6 +20,9 @@ var (
|
||||
ErrCodeUnknownIdentifier = errors.MustNewCode("unknown_identifier")
|
||||
ErrCodeIllegalArgument = errors.MustNewCode("illegal_argument")
|
||||
|
||||
ErrCodeDistributedSubqueryDenied = errors.MustNewCode("distributed_subquery_denied")
|
||||
ErrCodeDecimalOverflow = errors.MustNewCode("decimal_overflow")
|
||||
|
||||
ErrCodeQueryCanceled = errors.MustNewCode("query_canceled")
|
||||
ErrCodeQueryTimeout = errors.MustNewCode("query_timeout")
|
||||
ErrCodeExecutionFailed = errors.MustNewCode("execution_failed")
|
||||
@@ -72,6 +75,18 @@ var clickHouseExceptionWrappers = map[chproto.Error]func(cause error, ex *clickh
|
||||
chproto.ErrTooLessArgumentsForFunction: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "too few arguments to function: %s", ex.Message)
|
||||
},
|
||||
chproto.ErrBadGet: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "parameter of the wrong type: %s", ex.Message).
|
||||
WithSuggestions("write numeric function parameters in the type the function expects, for example 1.0 rather than 1 where it takes a Float64")
|
||||
},
|
||||
chproto.ErrDistributedInJoinSubqueryDenied: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeDistributedSubqueryDenied, "IN or JOIN subquery over a distributed table is not allowed: %s", ex.Message).
|
||||
WithSuggestions("use GLOBAL IN or GLOBAL JOIN for the subquery")
|
||||
},
|
||||
chproto.ErrDecimalOverflow: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeDecimalOverflow, "decimal overflow: %s", ex.Message).
|
||||
WithSuggestions("cast the Decimal values to Float64, or to a wider Decimal such as Decimal128, before the arithmetic")
|
||||
},
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
|
||||
@@ -34,13 +34,13 @@ type QueryBuilderQuery[T any] struct {
|
||||
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" description:"The fields to return for each row in raw requests, in addition to the columns always present: timestamp and id for logs, timestamp, trace_id and span_id for traces. Ignored by aggregating requests."`
|
||||
|
||||
Limit int `json:"limit,omitempty" description:"The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows."`
|
||||
Limit int `json:"limit,omitempty" description:"The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest."`
|
||||
|
||||
LimitBy *LimitBy `json:"limitBy,omitempty" description:"Reserved for applying limit within each combination of the given group-by keys. Accepted but currently ignored."`
|
||||
|
||||
Offset int `json:"offset,omitempty" description:"The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging."`
|
||||
Offset int `json:"offset,omitempty" description:"The number of rows to skip before returning results. Prefer cursor for raw requests."`
|
||||
|
||||
Cursor string `json:"cursor,omitempty" description:"The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it."`
|
||||
Cursor string `json:"cursor,omitempty" description:"The opaque nextCursor value from a previous raw result, used to fetch the next page."`
|
||||
|
||||
Having *Having `json:"having,omitempty" description:"A filter applied to aggregation results, referencing aliases or expressions, for example count() > 100."`
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ type QueryBuilderFormula struct {
|
||||
|
||||
Limit int `json:"limit,omitempty" description:"The maximum number of series or rows to return. Unlimited when 0."`
|
||||
|
||||
Offset int `json:"offset,omitempty" description:"The number of ordered series or rows to skip before the page starts."`
|
||||
|
||||
Having *Having `json:"having,omitempty" description:"Accepted but currently ignored; the computed values are returned unfiltered."`
|
||||
|
||||
Functions []Function `json:"functions,omitzero" description:"Post-processing functions applied in order to each result series."`
|
||||
@@ -96,14 +94,6 @@ func (f QueryBuilderFormula) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
if f.Offset < 0 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"offset must be non-negative, got %d",
|
||||
f.Offset,
|
||||
)
|
||||
}
|
||||
|
||||
// Validate expression is parseable
|
||||
if _, err := govaluate.NewEvaluableExpressionWithFunctions(f.Expression, EvalFuncs()); err != nil {
|
||||
return errors.NewInvalidInputf(
|
||||
|
||||
@@ -180,8 +180,6 @@ func (q *QueryEnvelope) GetOffset() int {
|
||||
return spec.Offset
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
return spec.Offset
|
||||
case QueryBuilderFormula:
|
||||
return spec.Offset
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -209,9 +209,6 @@ func (q *QueryEnvelope) SetOffset(offset int) {
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
spec.Offset = offset
|
||||
q.Spec = spec
|
||||
case QueryBuilderFormula:
|
||||
spec.Offset = offset
|
||||
q.Spec = spec
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@ const (
|
||||
DefaultOrderByKey = "__result"
|
||||
)
|
||||
|
||||
// ApplySeriesLimit sorts the series by the order criteria, skips the first offset of them and keeps at most limit.
|
||||
func ApplySeriesLimit(series []*TimeSeries, orderBy []OrderBy, limit, offset int) []*TimeSeries {
|
||||
// ApplyLimit applies limit and ordering to a list of time series
|
||||
// This function sorts the series based on the provided order criteria and applies the limit.
|
||||
func ApplySeriesLimit(series []*TimeSeries, orderBy []OrderBy, limit int) []*TimeSeries {
|
||||
if len(series) == 0 {
|
||||
return series
|
||||
}
|
||||
@@ -64,10 +65,6 @@ func ApplySeriesLimit(series []*TimeSeries, orderBy []OrderBy, limit, offset int
|
||||
return 0
|
||||
})
|
||||
|
||||
if offset > 0 {
|
||||
series = series[min(offset, len(series)):]
|
||||
}
|
||||
|
||||
// Apply limit if specified
|
||||
if limit > 0 && len(series) > limit {
|
||||
return series[:limit]
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestApplySeriesLimit(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result := ApplySeriesLimit(series, orderBy, 2, 0)
|
||||
result := ApplySeriesLimit(series, orderBy, 2)
|
||||
|
||||
// Should return top 2 series by value: service-b (50.0), service-c (30.0)
|
||||
assert.Len(t, result, 2)
|
||||
@@ -209,7 +209,7 @@ func TestApplySeriesLimit(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result := ApplySeriesLimit(series, orderBy, 3, 0)
|
||||
result := ApplySeriesLimit(series, orderBy, 3)
|
||||
|
||||
// Should return 3 series sorted by service (asc), then environment (desc)
|
||||
// Expected order:
|
||||
@@ -233,25 +233,6 @@ func TestApplySeriesLimit(t *testing.T) {
|
||||
assert.Equal(t, "prod", result[2].Labels[1].Value)
|
||||
assert.Equal(t, 40.0, result[2].Values[0].Value)
|
||||
})
|
||||
|
||||
t.Run("Offset skips the top series before the limit applies", func(t *testing.T) {
|
||||
hostKey := telemetrytypes.TelemetryFieldKey{Name: "host", FieldDataType: telemetrytypes.FieldDataTypeString}
|
||||
series := []*TimeSeries{
|
||||
{Labels: []*Label{{Key: hostKey, Value: "h1"}}, Values: []*TimeSeriesValue{{Timestamp: 1000, Value: 10.0}}},
|
||||
{Labels: []*Label{{Key: hostKey, Value: "h2"}}, Values: []*TimeSeriesValue{{Timestamp: 1000, Value: 50.0}}},
|
||||
{Labels: []*Label{{Key: hostKey, Value: "h3"}}, Values: []*TimeSeriesValue{{Timestamp: 1000, Value: 30.0}}},
|
||||
{Labels: []*Label{{Key: hostKey, Value: "h4"}}, Values: []*TimeSeriesValue{{Timestamp: 1000, Value: 20.0}}},
|
||||
}
|
||||
|
||||
// Default order is value descending: h2, h3, h4, h1. Skipping one leaves h3, h4.
|
||||
result := ApplySeriesLimit(series, nil, 2, 1)
|
||||
|
||||
require.Len(t, result, 2)
|
||||
assert.Equal(t, "h3", result[0].Labels[0].Value)
|
||||
assert.Equal(t, "h4", result[1].Labels[0].Value)
|
||||
|
||||
assert.Empty(t, ApplySeriesLimit(series, nil, 2, 4))
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplySeriesLimitRanksHeatmapSeriesByBucketTotals(t *testing.T) {
|
||||
@@ -290,7 +271,7 @@ func TestApplySeriesLimitRanksHeatmapSeriesByBucketTotals(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result := ApplySeriesLimit(series, nil, 2, 0)
|
||||
result := ApplySeriesLimit(series, nil, 2)
|
||||
|
||||
require.Len(t, result, 2)
|
||||
assert.Equal(t, "busy", result[0].Labels[0].Value)
|
||||
|
||||
@@ -55,7 +55,7 @@ type QueryBuilderTraceOperator struct {
|
||||
Having *Having `json:"having,omitempty" description:"A condition on the aggregation results, for example count() > 10."`
|
||||
|
||||
Limit int `json:"limit,omitempty" description:"The maximum number of traces or groups to return. For raw requests 0 means 100 rows; for aggregations 0 means unlimited."`
|
||||
Offset int `json:"offset,omitempty" description:"The number of raw or trace rows, or of ordered series or rows for time_series and scalar requests, to skip before the page starts."`
|
||||
Offset int `json:"offset,omitempty" description:"The number of raw results to skip before returning. Aggregation requests ignore it."`
|
||||
Cursor string `json:"cursor,omitempty" description:"Accepted but currently ignored by the trace operator; page raw results with offset."`
|
||||
|
||||
Legend string `json:"legend" description:"The display name template for each series; {{field}} placeholders are replaced by group-by label values."`
|
||||
|
||||
13
tests/e2e/drivers/antd.ts
generated
13
tests/e2e/drivers/antd.ts
generated
@@ -64,8 +64,17 @@ export function antdDriver(page: Page): Antd {
|
||||
*/
|
||||
pickByName: async (select, name) => {
|
||||
const before = await antd.selectedTags(select).count();
|
||||
await select.click();
|
||||
await expect(select).toHaveClass(/ant-select-open/);
|
||||
// A re-render of the surrounding form (e.g. after a query edit) can close
|
||||
// the select as the click opens it; re-click only while it is still closed.
|
||||
await expect(async () => {
|
||||
const isOpen = await select.evaluate((node) =>
|
||||
node.classList.contains('ant-select-open'),
|
||||
);
|
||||
if (!isOpen) {
|
||||
await select.click();
|
||||
}
|
||||
await expect(select).toHaveClass(/ant-select-open/, { timeout: 2_000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
|
||||
// `fill` rather than `keyboard.type`: every keystroke re-runs antd's
|
||||
// filter, so typing a ~30 character name costs seconds per pick, while
|
||||
|
||||
261
tests/e2e/drivers/query-builder.ts
generated
Normal file
261
tests/e2e/drivers/query-builder.ts
generated
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { watchConsole } from './console';
|
||||
import { isQueryRange } from './query-range-mock';
|
||||
import { dismissQuickFiltersAnnouncement } from './logs/explorer';
|
||||
|
||||
import { driver, kit } from './kit';
|
||||
import { EXPLORER, RELATIVE_TIME } from './query-builder/constants';
|
||||
import {
|
||||
addOnsAt,
|
||||
exactly,
|
||||
metricAt,
|
||||
openSelect,
|
||||
} from './query-builder/controls';
|
||||
import { afterRender, editorAt } from './query-builder/editor';
|
||||
import type { QueryBuilderDriver, V5Body } from './query-builder/types';
|
||||
|
||||
function queryBuilderDriver(page: Page): QueryBuilderDriver {
|
||||
const bodies: V5Body[] = [];
|
||||
page.on('request', (request: Request) => {
|
||||
if (isQueryRange(request.url()) && request.method() === 'POST') {
|
||||
bodies.push(request.postDataJSON() as V5Body);
|
||||
}
|
||||
});
|
||||
const consoleWatch = watchConsole(page, {
|
||||
// ANTLR reports every partial expression on console.error, prefixed
|
||||
// with its position (`line 1:14 ...`).
|
||||
ignore: ['line 1:', 'line 2:', 'line 3:'],
|
||||
});
|
||||
|
||||
// Waits on a request the action started: a response to one already in
|
||||
// flight (e.g. after a structural edit) would end the wait too early.
|
||||
const firedBy = async (action: () => Promise<void>): Promise<V5Body[]> => {
|
||||
const before = bodies.length;
|
||||
const answered = page
|
||||
.waitForRequest((r) => isQueryRange(r.url()) && r.method() === 'POST')
|
||||
.then((request) => request.response());
|
||||
await action();
|
||||
await answered;
|
||||
return bodies.slice(before);
|
||||
};
|
||||
|
||||
const qb: QueryBuilderDriver = {
|
||||
async openExplorer(path, { query, raw, view } = {}) {
|
||||
await dismissQuickFiltersAnnouncement(page);
|
||||
const params = [`relativeTime=${RELATIVE_TIME}`];
|
||||
if (view) {
|
||||
params.push(`panelTypes=${encodeURIComponent(JSON.stringify(view))}`);
|
||||
}
|
||||
const composite =
|
||||
raw ??
|
||||
(query === undefined
|
||||
? undefined
|
||||
: encodeURIComponent(JSON.stringify(query)));
|
||||
if (composite !== undefined) {
|
||||
params.push(`compositeQuery=${composite}`);
|
||||
}
|
||||
const url = `${path}?${params.join('&')}`;
|
||||
// The metrics explorer runs nothing until a metric is chosen; its
|
||||
// settle point is the metric list instead.
|
||||
if (path === EXPLORER.metrics) {
|
||||
await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes('/api/v2/metrics')),
|
||||
page.goto(url),
|
||||
]);
|
||||
await expect(qb.query('A').filter.content()).toBeVisible();
|
||||
return [];
|
||||
}
|
||||
const fired = await firedBy(async () => {
|
||||
await page.goto(url);
|
||||
});
|
||||
await expect(qb.query('A').filter.content()).toBeVisible();
|
||||
return fired;
|
||||
},
|
||||
query(name) {
|
||||
const root = (): Locator => page.getByTestId(`query-${name}`);
|
||||
// Index-suffixed testids follow screen position, not the query's
|
||||
// name (A, C, B after a delete and re-add): match them in the block.
|
||||
const signalSelect = (): Locator =>
|
||||
root().locator('[data-testid^="query-data-source-selector-"]');
|
||||
return {
|
||||
root,
|
||||
filter: editorAt(page, () =>
|
||||
root().getByTestId('query-where-clause-editor'),
|
||||
),
|
||||
filterStatus: () => root().getByTestId('query-validation-status'),
|
||||
aggregation: editorAt(page, () =>
|
||||
root().getByTestId('query-aggregation-editor'),
|
||||
),
|
||||
step: () => root().getByTestId('input-Seconds'),
|
||||
metric: metricAt(page, root),
|
||||
addOns: addOnsAt(page, root),
|
||||
actions: () => root().getByTestId('query-actions'),
|
||||
menuItem: (action) =>
|
||||
page.getByTestId(`query-actions-item-${action}-query`),
|
||||
async clone() {
|
||||
await root().getByTestId('query-actions').click();
|
||||
await page.getByTestId('query-actions-item-clone-query').click();
|
||||
await afterRender(page);
|
||||
},
|
||||
async delete() {
|
||||
await root().getByTestId('query-actions').click();
|
||||
await page.getByTestId('query-actions-item-delete-query').click();
|
||||
await expect(root()).toHaveCount(0);
|
||||
await afterRender(page);
|
||||
},
|
||||
async toggleVisibility() {
|
||||
await root().getByTestId('qb-entity-visibility').click();
|
||||
// The icon is the only visible change and carries no state.
|
||||
await afterRender(page);
|
||||
},
|
||||
collapse: () => root().getByTestId('qb-entity-collapse').click(),
|
||||
signal: () => signalSelect().locator('.ant-select-selection-item'),
|
||||
async setSignal(label) {
|
||||
const options = await openSelect(page, signalSelect());
|
||||
await options.filter({ hasText: exactly(label) }).click();
|
||||
await expect(this.signal()).toHaveText(label);
|
||||
},
|
||||
};
|
||||
},
|
||||
tab: (queryType) => page.getByTestId(`query-type-tab-${queryType}`),
|
||||
promql: () => page.getByTestId('promql-query-input').first(),
|
||||
async addQuery(name) {
|
||||
await page.getByTestId('add-new-query-button').click();
|
||||
const block = qb.query(name);
|
||||
await expect(block.filter.content()).toBeVisible();
|
||||
return block;
|
||||
},
|
||||
queryNames: () =>
|
||||
page.locator('[data-testid^="query-"]').evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => node.getAttribute('data-testid') ?? '')
|
||||
.filter((id) => /^query-[A-Z]$/.test(id))
|
||||
.map((id) => id.slice('query-'.length)),
|
||||
),
|
||||
formula(name) {
|
||||
const root = (): Locator => page.getByTestId(`formula-${name}`);
|
||||
return {
|
||||
root,
|
||||
expression: () => root().getByTestId('formula-expression'),
|
||||
legend: () => root().getByTestId('formula-legend'),
|
||||
async delete() {
|
||||
await root().getByTestId('qb-entity-delete').click();
|
||||
await expect(root()).toHaveCount(0);
|
||||
// The block leaves the DOM before the editor's draft follows.
|
||||
await afterRender(page);
|
||||
},
|
||||
};
|
||||
},
|
||||
async addFormula(name) {
|
||||
await page.getByTestId('add-formula-button').click();
|
||||
const formula = qb.formula(name);
|
||||
await expect(formula.expression()).toBeVisible();
|
||||
return formula;
|
||||
},
|
||||
traceOperator: {
|
||||
root: () => page.getByTestId('trace-operator'),
|
||||
// No testid; the label is followed by a "Beta" tag inside the button.
|
||||
addButton: () =>
|
||||
page.getByRole('button').filter({ hasText: /^Add Trace Matching/ }),
|
||||
async add() {
|
||||
await qb.traceOperator.addButton().click();
|
||||
await expect(qb.traceOperator.editor.content()).toBeVisible();
|
||||
},
|
||||
async remove() {
|
||||
// The only direct button child: the trash icon, no testid.
|
||||
await qb.traceOperator.root().locator(':scope > button').click();
|
||||
await expect(qb.traceOperator.root()).toHaveCount(0);
|
||||
},
|
||||
editor: editorAt(page, () => page.getByTestId('trace-operator-editor')),
|
||||
},
|
||||
async addToDashboard(title) {
|
||||
await page.getByTestId('explorer-add-to-dashboard').click();
|
||||
const dialog = page.getByTestId('export-panel-dialog');
|
||||
await dialog
|
||||
.getByTestId('export-dashboard-select')
|
||||
.locator('input')
|
||||
.fill(title);
|
||||
await dialog.locator(`.ant-select-item-option[title="${title}"]`).click();
|
||||
await dialog.getByTestId('export-panel-export').click();
|
||||
await page.waitForURL(/\/panel\/new\?/);
|
||||
await expect(qb.query('A').filter.content()).toBeVisible();
|
||||
},
|
||||
runButton: () => page.locator('[data-run-query-btn]').first(),
|
||||
// No testid on the container.
|
||||
logsResults: () => page.locator('.logs-explorer-views'),
|
||||
async run() {
|
||||
await afterRender(page);
|
||||
return firedBy(() => qb.runButton().click());
|
||||
},
|
||||
async rerunUntil(read, expected) {
|
||||
await expect(async () => {
|
||||
await expect(qb.runButton()).toHaveText(/Run Query/);
|
||||
await afterRender(page);
|
||||
await qb.runButton().click();
|
||||
await expect.poll(read, { timeout: 3_000 }).toEqual(expected);
|
||||
}).toPass({ timeout: 25_000 });
|
||||
},
|
||||
async runShortcut() {
|
||||
await afterRender(page);
|
||||
return firedBy(() => page.keyboard.press('ControlOrMeta+Enter'));
|
||||
},
|
||||
quickFilter(title, value) {
|
||||
const checkbox = (): Locator =>
|
||||
page.getByTestId(`checkbox-${title}-${value}`);
|
||||
return {
|
||||
checkbox,
|
||||
toggle: () => firedBy(() => checkbox().click()),
|
||||
};
|
||||
},
|
||||
spec(fired, name) {
|
||||
for (const body of fired) {
|
||||
const match = body.compositeQuery?.queries?.find(
|
||||
(q) => q.spec?.name === name,
|
||||
);
|
||||
if (match) {
|
||||
return match.spec;
|
||||
}
|
||||
}
|
||||
throw new Error(`no query ${name} in ${fired.length} query_range bodies`);
|
||||
},
|
||||
sent: () => bodies,
|
||||
latest: (name) =>
|
||||
[...bodies]
|
||||
.reverse()
|
||||
.flatMap((body) => body.compositeQuery?.queries ?? [])
|
||||
.find((query) => query.spec?.name === name)?.spec,
|
||||
urlQuery() {
|
||||
const raw = new URL(page.url()).searchParams.get('compositeQuery');
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
// The app encodes before URLSearchParams encodes again.
|
||||
return JSON.parse(decodeURIComponent(raw)) as Record<string, unknown>;
|
||||
},
|
||||
console: consoleWatch,
|
||||
};
|
||||
return qb;
|
||||
}
|
||||
|
||||
/** Layers `qb` onto another host's test, e.g. the alert-rule fixtures. */
|
||||
export const withQueryBuilder = driver('qb', ({ page }) =>
|
||||
queryBuilderDriver(page),
|
||||
);
|
||||
|
||||
export const test = withQueryBuilder(kit);
|
||||
|
||||
export { expect } from './kit';
|
||||
export * from './query-builder/constants';
|
||||
export * from './query-builder/types';
|
||||
export { reduceToLabel } from './query-builder/controls';
|
||||
export { withMetricTypes } from './query-builder/seed';
|
||||
17
tests/e2e/drivers/query-builder/constants.ts
generated
Normal file
17
tests/e2e/drivers/query-builder/constants.ts
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
export const EXPLORER = {
|
||||
logs: '/logs/logs-explorer',
|
||||
traces: '/traces-explorer',
|
||||
metrics: '/metrics-explorer/explorer',
|
||||
} as const;
|
||||
|
||||
/** A monotonic Sum in the golden dataset. */
|
||||
export const SUM_METRIC = 'signoz_calls_total';
|
||||
/** Another golden Sum, for switching between metrics of one type. */
|
||||
export const OTHER_SUM_METRIC = 'signoz_latency_count';
|
||||
|
||||
/** Inside the golden dataset's window. */
|
||||
export const RELATIVE_TIME = '6h';
|
||||
136
tests/e2e/drivers/query-builder/controls.ts
generated
Normal file
136
tests/e2e/drivers/query-builder/controls.ts
generated
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { antdDriver } from '../antd';
|
||||
|
||||
import { editorAt } from './editor';
|
||||
import type { AddOnField, AddOns, MetricControls } from './types';
|
||||
|
||||
/**
|
||||
* Opens the antd select inside `select` and returns its options. Scoped to the
|
||||
* list the select owns: a previous select's list can still be mid-close.
|
||||
*/
|
||||
export async function openSelect(
|
||||
page: Page,
|
||||
select: Locator,
|
||||
): Promise<Locator> {
|
||||
await select.locator('.ant-select-selector').click();
|
||||
const list = await antdDriver(page).ownDropdown(select);
|
||||
await expect(list).toBeVisible();
|
||||
return list.locator('.ant-select-item-option');
|
||||
}
|
||||
|
||||
/** antd renders select options with trailing whitespace ("Sum "). */
|
||||
export const exactly = (label: string): RegExp =>
|
||||
new RegExp(`^\\s*${label}\\s*$`);
|
||||
|
||||
/** A reduce-to option or value by its operator: "Sum of values in timeframe". */
|
||||
export const reduceToLabel = (operator: string): RegExp =>
|
||||
new RegExp(`^\\s*${operator} of values`);
|
||||
|
||||
const ADD_ON_CONTENT: Record<AddOnField, string> = {
|
||||
group_by: 'group-by-content',
|
||||
having: 'having-content',
|
||||
order_by: 'order-by-content',
|
||||
limit: 'limit-content',
|
||||
legend_format: 'legend-format-content',
|
||||
reduce_to: 'reduce-to-content',
|
||||
};
|
||||
|
||||
export function addOnsAt(page: Page, root: () => Locator): AddOns {
|
||||
const content = (field: AddOnField): Locator =>
|
||||
root().getByTestId(ADD_ON_CONTENT[field]);
|
||||
const groupBy = (): Locator => root().getByTestId('group-by');
|
||||
const addOns: AddOns = {
|
||||
toggles: () => root().locator('[data-testid^="query-add-on-"]'),
|
||||
async open(field) {
|
||||
if (await content(field).isVisible()) {
|
||||
return;
|
||||
}
|
||||
await root().getByTestId(`query-add-on-${field}`).click();
|
||||
await expect(content(field)).toBeVisible();
|
||||
},
|
||||
content,
|
||||
groupBy: {
|
||||
async add(key) {
|
||||
const options = await openSelect(page, groupBy());
|
||||
await groupBy().locator('input').fill(key);
|
||||
await options
|
||||
.filter({ hasText: exactly(key) })
|
||||
.first()
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(
|
||||
groupBy().locator('.ant-select-selection-item', { hasText: key }),
|
||||
).toBeVisible();
|
||||
},
|
||||
async remove(key) {
|
||||
await groupBy()
|
||||
.locator('.ant-select-selection-item', { hasText: key })
|
||||
.locator('.ant-select-selection-item-remove')
|
||||
.click();
|
||||
},
|
||||
async values() {
|
||||
const texts = await groupBy()
|
||||
.locator('.ant-select-selection-item-content')
|
||||
.allTextContents();
|
||||
return texts.map((text) => text.trim());
|
||||
},
|
||||
},
|
||||
having: editorAt(page, () => root().getByTestId('having-editor')),
|
||||
limit: () => content('limit').getByTestId('input-Limit'),
|
||||
legend: () => content('legend_format').getByTestId('input-Legend format'),
|
||||
reduceTo: {
|
||||
value: () =>
|
||||
root().getByTestId('reduce-to').locator('.ant-select-selection-item'),
|
||||
// Labels read "<Op> of values in timeframe"; matched on the operator.
|
||||
async set(label) {
|
||||
const options = await openSelect(page, root().getByTestId('reduce-to'));
|
||||
await options.filter({ hasText: reduceToLabel(label) }).click();
|
||||
await expect(addOns.reduceTo.value()).toHaveText(reduceToLabel(label));
|
||||
},
|
||||
},
|
||||
};
|
||||
return addOns;
|
||||
}
|
||||
|
||||
export function metricAt(page: Page, root: () => Locator): MetricControls {
|
||||
const picker = (): Locator =>
|
||||
root().locator('[data-testid^="metric-name-selector-"]');
|
||||
const time = (): Locator => root().getByTestId('metrics-time-aggregation');
|
||||
const space = (): Locator => root().getByTestId('metrics-space-aggregation');
|
||||
return {
|
||||
async select(name) {
|
||||
// An antd AutoComplete: the testid is on a wrapper.
|
||||
const field = picker();
|
||||
const options = await openSelect(page, field);
|
||||
await field.locator('input').fill(name);
|
||||
// Options carry the metric's type and unit next to its name.
|
||||
await options.filter({ hasText: name }).first().click();
|
||||
},
|
||||
// The selection item, not the root: these selects render their list
|
||||
// inside the root, so its text includes every option.
|
||||
input: () => picker().locator('input'),
|
||||
timeAggregation: () => time().locator('.ant-select-selection-item'),
|
||||
spaceAggregation: () => space().locator('.ant-select-selection-item'),
|
||||
async setTimeAggregation(label) {
|
||||
const options = await openSelect(page, time());
|
||||
await options.filter({ hasText: exactly(label) }).click();
|
||||
await expect(this.timeAggregation()).toHaveText(label);
|
||||
},
|
||||
async setSpaceAggregation(label) {
|
||||
const options = await openSelect(page, space());
|
||||
await options.filter({ hasText: exactly(label) }).click();
|
||||
await expect(this.spaceAggregation()).toHaveText(label);
|
||||
},
|
||||
async spaceOptions() {
|
||||
const options = await openSelect(page, space());
|
||||
const labels = await options.allTextContents();
|
||||
await page.keyboard.press('Escape');
|
||||
return labels.map((label) => label.trim());
|
||||
},
|
||||
};
|
||||
}
|
||||
77
tests/e2e/drivers/query-builder/editor.ts
generated
Normal file
77
tests/e2e/drivers/query-builder/editor.ts
generated
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import type { FilterEditor } from './types';
|
||||
|
||||
/** CodeMirror renders its placeholder inside the empty line. */
|
||||
const EDITOR_DOC = async (root: Locator): Promise<string> => {
|
||||
const lines = root.locator('.cm-content .cm-line');
|
||||
const texts = await lines.allTextContents();
|
||||
const placeholders = await lines.evaluateAll((nodes) =>
|
||||
nodes.map((node) => node.querySelector('.cm-placeholder')?.textContent ?? ''),
|
||||
);
|
||||
return texts
|
||||
.map((text, i) =>
|
||||
placeholders[i] ? text.replace(placeholders[i], '') : text,
|
||||
)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
export function editorAt(page: Page, root: () => Locator): FilterEditor {
|
||||
const content = (): Locator => root().locator('.cm-content');
|
||||
const editor: FilterEditor = {
|
||||
root,
|
||||
content,
|
||||
async type(text) {
|
||||
await editor.clear();
|
||||
await page.keyboard.type(text);
|
||||
},
|
||||
async append(text) {
|
||||
await content().click();
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.type(text);
|
||||
},
|
||||
async clear() {
|
||||
await content().click();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await page.keyboard.press('Backspace');
|
||||
await expect.poll(() => EDITOR_DOC(root())).toBe('');
|
||||
},
|
||||
press: (key) => page.keyboard.press(key),
|
||||
expression: () => EDITOR_DOC(root()),
|
||||
lineCount: () => root().locator('.cm-line'),
|
||||
suggestions: () => page.locator('.cm-tooltip-autocomplete'),
|
||||
async openSuggestions() {
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('Control+Space');
|
||||
await expect(editor.suggestions()).toBeVisible({ timeout: 2_000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
},
|
||||
suggestion: (label) =>
|
||||
page
|
||||
.locator('.cm-tooltip-autocomplete [role="option"]')
|
||||
.filter({ hasText: label }),
|
||||
};
|
||||
return editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two frames: an edit reaches React state on its next scheduler task, and an
|
||||
* action in the same frame reads the previous render. A long render delays
|
||||
* the frame itself, so this cannot pass before the commit.
|
||||
*/
|
||||
export async function afterRender(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
// Runs in the browser; the e2e tsconfig has no DOM lib.
|
||||
const { requestAnimationFrame: frame } = globalThis as unknown as {
|
||||
requestAnimationFrame(this: void, callback: () => void): number;
|
||||
};
|
||||
frame(() => frame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
}
|
||||
50
tests/e2e/drivers/query-builder/seed.ts
generated
Normal file
50
tests/e2e/drivers/query-builder/seed.ts
generated
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { postToSeeder } from '../telemetry';
|
||||
|
||||
import { seeded } from '../kit';
|
||||
|
||||
import type { MetricTypes } from './types';
|
||||
|
||||
const HISTOGRAM_BOUNDS = ['1', '2', '4', '+Inf'];
|
||||
|
||||
/** Ten minutes of a gauge and a cumulative histogram, names unique per worker. */
|
||||
export const withMetricTypes = seeded(
|
||||
'metricTypes',
|
||||
async ({ page, workerIndex }) => {
|
||||
const suffix = `${workerIndex}_${Date.now()}`;
|
||||
const types: MetricTypes = {
|
||||
gauge: `e2e_qb_gauge_${suffix}`,
|
||||
histogram: `e2e_qb_histogram_${suffix}`,
|
||||
};
|
||||
const now = Date.now();
|
||||
const points: Record<string, unknown>[] = [];
|
||||
for (let minute = 0; minute < 10; minute += 1) {
|
||||
const timestamp = new Date(now - (10 - minute) * 60_000).toISOString();
|
||||
points.push({
|
||||
metric_name: types.gauge,
|
||||
labels: { host: 'qb-host' },
|
||||
timestamp,
|
||||
value: 10 + minute,
|
||||
type_: 'Gauge',
|
||||
temporality: 'Unspecified',
|
||||
is_monotonic: false,
|
||||
});
|
||||
HISTOGRAM_BOUNDS.forEach((le, bucket) => {
|
||||
points.push({
|
||||
metric_name: types.histogram,
|
||||
labels: { le },
|
||||
timestamp,
|
||||
value: (minute + 1) * (bucket + 1),
|
||||
type_: 'Histogram',
|
||||
temporality: 'Cumulative',
|
||||
is_monotonic: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
await postToSeeder(page.request, '/telemetry/metrics', points);
|
||||
return types;
|
||||
},
|
||||
);
|
||||
225
tests/e2e/drivers/query-builder/types.ts
generated
Normal file
225
tests/e2e/drivers/query-builder/types.ts
generated
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import type { Locator } from '@playwright/test';
|
||||
|
||||
import type { ConsoleWatch } from '../console';
|
||||
|
||||
/** The golden dataset has only Sums; these are seeded per worker. */
|
||||
export interface MetricTypes {
|
||||
gauge: string;
|
||||
histogram: string;
|
||||
}
|
||||
|
||||
/** One entry of a v5 `query_range` body's `compositeQuery.queries`. */
|
||||
export interface V5Query {
|
||||
type: string;
|
||||
spec: {
|
||||
name?: string;
|
||||
signal?: string;
|
||||
disabled?: boolean;
|
||||
filter?: { expression?: string };
|
||||
aggregations?: {
|
||||
expression?: string;
|
||||
metricName?: string;
|
||||
timeAggregation?: string;
|
||||
spaceAggregation?: string;
|
||||
reduceTo?: string;
|
||||
}[];
|
||||
groupBy?: { name: string }[];
|
||||
having?: { expression?: string };
|
||||
order?: { key: { name: string }; direction: string }[];
|
||||
limit?: number;
|
||||
stepInterval?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface V5Body {
|
||||
requestType: string;
|
||||
compositeQuery: { queries: V5Query[] };
|
||||
}
|
||||
|
||||
export interface FilterEditor {
|
||||
root(): Locator;
|
||||
/** The contenteditable CodeMirror surface. */
|
||||
content(): Locator;
|
||||
/** Replaces the expression by typing it key by key. */
|
||||
type(text: string): Promise<void>;
|
||||
/** Appends at the caret without clearing. */
|
||||
append(text: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
press(key: string): Promise<void>;
|
||||
/** The document text, placeholder excluded, lines joined with `\n`. */
|
||||
expression(): Promise<string>;
|
||||
lineCount(): Locator;
|
||||
/** The open autocomplete popup. */
|
||||
suggestions(): Locator;
|
||||
/**
|
||||
* Ctrl+Space: opens the popup at the caret. Typing opens it only once the
|
||||
* debounced key fetch answers, which a loaded stack can delay past a test's
|
||||
* next step.
|
||||
*/
|
||||
openSuggestions(): Promise<void>;
|
||||
suggestion(label: string | RegExp): Locator;
|
||||
}
|
||||
|
||||
export interface QueryBlock {
|
||||
root(): Locator;
|
||||
filter: FilterEditor;
|
||||
/** The filter's status icon: shown only while unfocused and non-empty. */
|
||||
filterStatus(): Locator;
|
||||
aggregation: FilterEditor;
|
||||
/** The aggregation's "every … Seconds" input. */
|
||||
step(): Locator;
|
||||
metric: MetricControls;
|
||||
addOns: AddOns;
|
||||
/** The "…" menu: offered only where several queries are allowed. */
|
||||
actions(): Locator;
|
||||
/** A row of the open "…" menu. */
|
||||
menuItem(action: 'clone' | 'delete'): Locator;
|
||||
clone(): Promise<void>;
|
||||
delete(): Promise<void>;
|
||||
toggleVisibility(): Promise<void>;
|
||||
collapse(): Promise<void>;
|
||||
/** The data-source dropdown's value; only on hosts that let it change. */
|
||||
signal(): Locator;
|
||||
setSignal(label: 'Logs' | 'Traces' | 'Metrics'): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FormulaBlock {
|
||||
root(): Locator;
|
||||
expression(): Locator;
|
||||
legend(): Locator;
|
||||
delete(): Promise<void>;
|
||||
}
|
||||
|
||||
/** `query-add-on-<field>` toggle values: a documented DOM contract. */
|
||||
export type AddOnField =
|
||||
| 'group_by'
|
||||
| 'having'
|
||||
| 'order_by'
|
||||
| 'limit'
|
||||
| 'legend_format'
|
||||
| 'reduce_to';
|
||||
|
||||
export interface AddOns {
|
||||
/** The toggle row; its children name the add-ons on offer. */
|
||||
toggles(): Locator;
|
||||
/** Idempotent: leaves an already-open add-on open. */
|
||||
open(field: AddOnField): Promise<void>;
|
||||
content(field: AddOnField): Locator;
|
||||
groupBy: {
|
||||
/** Types `key` and picks the suggestion with that exact name. */
|
||||
add(key: string): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
/** The selected keys, in order. */
|
||||
values(): Promise<string[]>;
|
||||
};
|
||||
having: FilterEditor;
|
||||
limit(): Locator;
|
||||
legend(): Locator;
|
||||
reduceTo: {
|
||||
/** The selected value. */
|
||||
value(): Locator;
|
||||
set(label: string): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/** The metrics aggregation section: metric picker plus time/space selects. */
|
||||
export interface MetricControls {
|
||||
select(name: string): Promise<void>;
|
||||
/** The metric name input. */
|
||||
input(): Locator;
|
||||
/** The selected value. Absent for histograms. */
|
||||
timeAggregation(): Locator;
|
||||
/** The selected value. */
|
||||
spaceAggregation(): Locator;
|
||||
setTimeAggregation(label: string): Promise<void>;
|
||||
setSpaceAggregation(label: string): Promise<void>;
|
||||
/** Labels offered by the space-aggregation select, as shown. */
|
||||
spaceOptions(): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface QuickFilterValue {
|
||||
checkbox(): Locator;
|
||||
/** Toggles the value; returns the query_range bodies the toggle fired. */
|
||||
toggle(): Promise<V5Body[]>;
|
||||
}
|
||||
|
||||
/** The explorer's tab, carried in the `panelTypes` param. */
|
||||
export type ExplorerView = 'list' | 'graph' | 'table';
|
||||
|
||||
export interface OpenOptions {
|
||||
/** Serialised the way the app writes it. */
|
||||
query?: unknown;
|
||||
/** A `compositeQuery` value sent as given, for malformed input. */
|
||||
raw?: string;
|
||||
view?: ExplorerView;
|
||||
}
|
||||
|
||||
export interface QueryBuilderDriver {
|
||||
/**
|
||||
* Resolves once the page's own first query_range has answered: the explorer
|
||||
* rewrites its URL and re-mounts the builder until then, which drops
|
||||
* keystrokes. Returns the bodies that load fired.
|
||||
*/
|
||||
openExplorer(path: string, options?: OpenOptions): Promise<V5Body[]>;
|
||||
query(name: string): QueryBlock;
|
||||
/** Adds the next query (B, C, …) and waits for its block. */
|
||||
addQuery(name: string): Promise<QueryBlock>;
|
||||
/** Builder query names, top to bottom. */
|
||||
queryNames(): Promise<string[]>;
|
||||
/** Panel-editor query-type tab, by its queryType (`builder`, `promql`, …). */
|
||||
tab(queryType: string): Locator;
|
||||
promql(): Locator;
|
||||
formula(name: string): FormulaBlock;
|
||||
addFormula(name: string): Promise<FormulaBlock>;
|
||||
traceOperator: {
|
||||
root(): Locator;
|
||||
/** "Add Trace Matching": offered on traces hosts that allow it. */
|
||||
addButton(): Locator;
|
||||
add(): Promise<void>;
|
||||
remove(): Promise<void>;
|
||||
editor: FilterEditor;
|
||||
};
|
||||
/**
|
||||
* Explorer "Add to dashboard": picks the dashboard titled `title` and waits
|
||||
* for the new-panel editor the export navigates to.
|
||||
*/
|
||||
addToDashboard(title: string): Promise<void>;
|
||||
runButton(): Locator;
|
||||
/** The logs explorer's results area, below the builder. */
|
||||
logsResults(): Locator;
|
||||
/** Clicks Run; returns every query_range body the click fired. */
|
||||
run(): Promise<V5Body[]>;
|
||||
/**
|
||||
* Clicks Run until `read()` equals `expected`. For panel-editor structural
|
||||
* edits, whose draft can lag the DOM: an early Run is a cache hit that
|
||||
* sends nothing. Never clicks while the button is Cancel.
|
||||
*/
|
||||
rerunUntil(read: () => unknown, expected: unknown): Promise<void>;
|
||||
/**
|
||||
* Ctrl/Cmd+Enter from the focused field; returns the fired bodies. Waits two
|
||||
* frames first: editor changes reach React state on its next scheduler task,
|
||||
* and a keypress inside the same frame as the last keystroke reads the
|
||||
* previous render — a gap no person can produce.
|
||||
*/
|
||||
runShortcut(): Promise<V5Body[]>;
|
||||
/** A value in an explorer's checkbox quick filter, by its header title. */
|
||||
quickFilter(title: string, value: string): QuickFilterValue;
|
||||
/** The spec of query `name` in the first body that has it. */
|
||||
spec(bodies: V5Body[], name: string): V5Query['spec'];
|
||||
/** Every query_range body sent since the test started. */
|
||||
sent(): V5Body[];
|
||||
/**
|
||||
* Query `name`'s spec in the newest body that has it, whatever sent it. The
|
||||
* panel editor fetches on structural edits, so Run after one can be a cache
|
||||
* hit that sends nothing: poll this rather than waiting on Run.
|
||||
*/
|
||||
latest(name: string): V5Query['spec'] | undefined;
|
||||
/** The URL's `compositeQuery`, parsed. */
|
||||
urlQuery(): Record<string, unknown> | null;
|
||||
console: ConsoleWatch;
|
||||
}
|
||||
4
tests/e2e/drivers/query-range-mock.ts
generated
4
tests/e2e/drivers/query-range-mock.ts
generated
@@ -272,7 +272,9 @@ export async function holdQueryRange(
|
||||
});
|
||||
await page.route(QUERY_RANGE_GLOB, async (route) => {
|
||||
await gate;
|
||||
await fulfill(route, body);
|
||||
// A cancelled run aborts its request while it is held; there is
|
||||
// nothing left to answer.
|
||||
await fulfill(route, body).catch(() => undefined);
|
||||
});
|
||||
return { release: () => release() };
|
||||
}
|
||||
|
||||
154
tests/e2e/tests/query-builder/addons.spec.ts
Normal file
154
tests/e2e/tests/query-builder/addons.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
expect,
|
||||
EXPLORER,
|
||||
SUM_METRIC,
|
||||
test,
|
||||
} from '../../drivers/query-builder';
|
||||
|
||||
const HAVING = ['count() > 10', 'count() IN [1, 2]', 'count() NOT IN [1, 2]'];
|
||||
|
||||
test.describe('Query builder — add-ons', () => {
|
||||
for (const explorer of ['logs', 'traces'] as const) {
|
||||
test(`TC-21 ${explorer} group by keeps order through removal and reload`, async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER[explorer], { view: 'graph' });
|
||||
const addOns = qb.query('A').addOns;
|
||||
await addOns.open('group_by');
|
||||
|
||||
await addOns.groupBy.add('service.name');
|
||||
await addOns.groupBy.add('deployment.environment');
|
||||
await addOns.groupBy.add('k8s.namespace.name');
|
||||
await addOns.groupBy.remove('service.name');
|
||||
const fired = await qb.run();
|
||||
|
||||
const expected = ['deployment.environment', 'k8s.namespace.name'];
|
||||
expect(qb.spec(fired, 'A').groupBy?.map((key) => key.name)).toEqual(
|
||||
expected,
|
||||
);
|
||||
const reloaded = await qb.openExplorer(EXPLORER[explorer], {
|
||||
view: 'graph',
|
||||
query: qb.urlQuery(),
|
||||
});
|
||||
expect(qb.spec(reloaded, 'A').groupBy?.map((key) => key.name)).toEqual(
|
||||
expected,
|
||||
);
|
||||
expect(await qb.query('A').addOns.groupBy.values()).toEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-21 metrics group by offers the metric’s own attributes', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.metrics);
|
||||
const query = qb.query('A');
|
||||
await query.metric.select(SUM_METRIC);
|
||||
|
||||
await query.addOns.groupBy.add('operation');
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').groupBy?.map((key) => key.name)).toEqual([
|
||||
'operation',
|
||||
]);
|
||||
});
|
||||
|
||||
for (const having of HAVING) {
|
||||
test(`TC-22 having "${having}" is sent verbatim and accepted`, async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
const addOns = qb.query('A').addOns;
|
||||
await addOns.open('group_by');
|
||||
await addOns.groupBy.add('service.name');
|
||||
await addOns.open('having');
|
||||
|
||||
await addOns.having.type(having);
|
||||
await addOns.having.press('Escape');
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').having?.expression).toBe(having);
|
||||
expect(qb.console.failedResponses).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-23 the logs list orders by timestamp then id, traces by timestamp only', async ({
|
||||
qb,
|
||||
}) => {
|
||||
const logs = await qb.openExplorer(EXPLORER.logs, { view: 'list' });
|
||||
expect(qb.spec(logs, 'A').order?.map((o) => o.key.name)).toEqual([
|
||||
'timestamp',
|
||||
'id',
|
||||
]);
|
||||
|
||||
const traces = await qb.openExplorer(EXPLORER.traces, { view: 'list' });
|
||||
expect(qb.spec(traces, 'A').order?.map((o) => o.key.name)).toEqual([
|
||||
'timestamp',
|
||||
]);
|
||||
});
|
||||
|
||||
test('TC-24 a positive limit is sent; empty and zero send none', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
const addOns = qb.query('A').addOns;
|
||||
await addOns.open('group_by');
|
||||
await addOns.groupBy.add('service.name');
|
||||
await addOns.open('limit');
|
||||
|
||||
await addOns.limit().fill('10');
|
||||
expect(qb.spec(await qb.run(), 'A').limit).toBe(10);
|
||||
|
||||
await addOns.limit().fill('');
|
||||
expect(qb.spec(await qb.run(), 'A')).not.toHaveProperty('limit');
|
||||
|
||||
await addOns.limit().fill('3');
|
||||
await qb.run();
|
||||
await addOns.limit().fill('0');
|
||||
expect(qb.spec(await qb.run(), 'A')).not.toHaveProperty('limit');
|
||||
});
|
||||
|
||||
test('TC-24 a limit input drops the sign of a negative number', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
const addOns = qb.query('A').addOns;
|
||||
await addOns.open('group_by');
|
||||
await addOns.groupBy.add('service.name');
|
||||
await addOns.open('limit');
|
||||
await addOns.limit().fill('10');
|
||||
expect(qb.spec(await qb.run(), 'A').limit).toBe(10);
|
||||
|
||||
await addOns.limit().fill('-5');
|
||||
await expect(addOns.limit()).toHaveValue('5');
|
||||
expect(qb.spec(await qb.run(), 'A').limit).toBe(5);
|
||||
expect(qb.console.failedResponses).toEqual([]);
|
||||
});
|
||||
|
||||
test('TC-25 the legend prefills from group by and a custom one is kept', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
const addOns = qb.query('A').addOns;
|
||||
await addOns.open('group_by');
|
||||
await addOns.groupBy.add('service.name');
|
||||
await addOns.groupBy.add('deployment.environment');
|
||||
|
||||
await addOns.open('legend_format');
|
||||
await expect(addOns.legend()).toHaveValue(
|
||||
'service.name = {{service.name}}, deployment.environment = {{deployment.environment}}',
|
||||
);
|
||||
|
||||
await addOns.legend().fill('{{service.name}} logs');
|
||||
await addOns.groupBy.add('k8s.namespace.name');
|
||||
await expect(addOns.legend()).toHaveValue('{{service.name}} logs');
|
||||
expect(qb.spec(await qb.run(), 'A').legend).toBe('{{service.name}} logs');
|
||||
});
|
||||
|
||||
test('TC-25 the legend opens empty without a group by', async ({ qb }) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
|
||||
await qb.query('A').addOns.open('legend_format');
|
||||
|
||||
await expect(qb.query('A').addOns.legend()).toHaveValue('');
|
||||
});
|
||||
});
|
||||
80
tests/e2e/tests/query-builder/aggregation.spec.ts
Normal file
80
tests/e2e/tests/query-builder/aggregation.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { expect, EXPLORER, test } from '../../drivers/query-builder';
|
||||
|
||||
// One comma-separated term per entry in the request's `aggregations`.
|
||||
const EXPRESSIONS: Record<string, string[]> = {
|
||||
'a count': ['count()'],
|
||||
'a distinct count': ['count_distinct(service.name)'],
|
||||
'a percentile of a span field': ['p99(duration_nano)'],
|
||||
'two aggregations': ['count()', 'p99(duration_nano)'],
|
||||
};
|
||||
|
||||
test.describe('Query builder — logs and traces aggregations', () => {
|
||||
for (const [label, terms] of Object.entries(EXPRESSIONS)) {
|
||||
test(`TC-17 ${label} reaches the request`, async ({ qb }) => {
|
||||
await qb.openExplorer(EXPLORER.traces, { view: 'graph' });
|
||||
const aggregation = qb.query('A').aggregation;
|
||||
|
||||
await aggregation.type(terms.join(', '));
|
||||
await aggregation.press('Escape');
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').aggregations?.map((a) => a.expression)).toEqual(
|
||||
terms,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-18 the step interval reaches the request', async ({
|
||||
authedPage: page,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.traces, { view: 'graph' });
|
||||
|
||||
await qb.query('A').step().fill('120');
|
||||
await page.keyboard.press('Tab');
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').stepInterval).toBe(120);
|
||||
});
|
||||
|
||||
test('TC-30 a trace operator replaces per-query aggregation until removed', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.traces, { view: 'graph' });
|
||||
await (await qb.addQuery('B')).filter.type("http.status_code = '500'");
|
||||
await qb.query('B').filter.press('Escape');
|
||||
|
||||
await qb.traceOperator.add();
|
||||
await qb.traceOperator.editor.type('A => B');
|
||||
await qb.traceOperator.editor.press('Escape');
|
||||
for (const name of ['A', 'B']) {
|
||||
await expect(qb.query(name).aggregation.root()).toHaveCount(0);
|
||||
await expect(qb.query(name).addOns.toggles()).toHaveCount(0);
|
||||
}
|
||||
const fired = await qb.run();
|
||||
const operator = fired
|
||||
.flatMap((body) => body.compositeQuery.queries)
|
||||
.find((query) => query.type === 'builder_trace_operator');
|
||||
expect(operator?.spec).toMatchObject({ expression: 'A => B' });
|
||||
|
||||
await qb.traceOperator.remove();
|
||||
await expect(qb.query('A').aggregation.root()).toBeVisible();
|
||||
await qb.rerunUntil(
|
||||
() =>
|
||||
qb
|
||||
.sent()
|
||||
.at(-1)
|
||||
?.compositeQuery.queries.some(
|
||||
(query) => query.type === 'builder_trace_operator',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-30 the logs explorer offers no trace matching', async ({ qb }) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
|
||||
await expect(qb.query('A').root()).toBeVisible();
|
||||
await expect(qb.traceOperator.addButton()).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
80
tests/e2e/tests/query-builder/alerts.spec.ts
Normal file
80
tests/e2e/tests/query-builder/alerts.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
AlertType,
|
||||
expect,
|
||||
test as alertsTest,
|
||||
} from '../../drivers/alerts';
|
||||
import { withQueryBuilder } from '../../drivers/query-builder';
|
||||
|
||||
const test = withQueryBuilder(alertsTest);
|
||||
|
||||
const FILTER_A = "http.method = 'GET'";
|
||||
const FILTER_B = "http.status_code = '500'";
|
||||
const TRACE_MATCH = 'A => B';
|
||||
|
||||
interface SavedRule {
|
||||
condition?: {
|
||||
compositeQuery?: {
|
||||
queries?: {
|
||||
type: string;
|
||||
spec: {
|
||||
name?: string;
|
||||
expression?: string;
|
||||
filter?: { expression?: string };
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('Query builder — alerts', () => {
|
||||
test('TC-06 a trace-matching traces alert saves and reopens intact', async ({
|
||||
authedPage: page,
|
||||
alerts,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
qb,
|
||||
}) => {
|
||||
await alerts.form.gotoV2({ alertType: AlertType.TRACES });
|
||||
await alerts.form.name().fill(`e2e-qb-tc06-${Date.now()}`);
|
||||
|
||||
await qb.query('A').filter.type(FILTER_A);
|
||||
await qb.query('A').filter.press('Escape');
|
||||
const b = await qb.addQuery('B');
|
||||
await b.filter.type(FILTER_B);
|
||||
await b.filter.press('Escape');
|
||||
await qb.traceOperator.add();
|
||||
await qb.traceOperator.editor.type(TRACE_MATCH);
|
||||
await qb.traceOperator.editor.press('Escape');
|
||||
// Blur first: the editor's blur re-renders the condition section, which
|
||||
// otherwise closes the channel select as its click opens it.
|
||||
await qb.traceOperator.editor.content().blur();
|
||||
await alerts.form.selectThresholdChannel(0, alertChannel.name);
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
|
||||
),
|
||||
alerts.form.saveButton().click(),
|
||||
]);
|
||||
await ownedRules.register(response);
|
||||
const saved = response.request().postDataJSON() as SavedRule;
|
||||
const queries = saved.condition?.compositeQuery?.queries ?? [];
|
||||
expect(queries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'builder_trace_operator',
|
||||
spec: expect.objectContaining({ expression: TRACE_MATCH }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const { data } = (await response.json()) as { data: { id: string } };
|
||||
await alerts.details.gotoOverview(String(data.id));
|
||||
|
||||
await expect.poll(() => qb.query('A').filter.expression()).toBe(FILTER_A);
|
||||
await expect.poll(() => qb.query('B').filter.expression()).toBe(FILTER_B);
|
||||
await expect
|
||||
.poll(() => qb.traceOperator.editor.expression())
|
||||
.toBe(TRACE_MATCH);
|
||||
});
|
||||
});
|
||||
72
tests/e2e/tests/query-builder/dashboards.spec.ts
Normal file
72
tests/e2e/tests/query-builder/dashboards.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { emptyDashboard, test as editorTest } from '../../drivers/panel-editor';
|
||||
import {
|
||||
expect,
|
||||
EXPLORER,
|
||||
withQueryBuilder,
|
||||
} from '../../drivers/query-builder';
|
||||
|
||||
const test = withQueryBuilder(editorTest);
|
||||
|
||||
const FILTER = "severity_text = 'ERROR'";
|
||||
const AGGREGATION = 'count_distinct(service.name)';
|
||||
|
||||
interface StoredQuery {
|
||||
spec: {
|
||||
filter?: { expression?: string };
|
||||
aggregations?: { expression?: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Where a non-List panel keeps its builder queries in the v2 dashboard spec. */
|
||||
interface StoredPanel {
|
||||
spec: {
|
||||
queries: {
|
||||
spec: { plugin: { kind: string; spec: { queries: StoredQuery[] } } };
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('Query builder — explorer to dashboard', () => {
|
||||
test('TC-05 a logs explorer query survives add to dashboard, save and reopen', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
qb,
|
||||
}) => {
|
||||
const dashboard = emptyDashboard();
|
||||
const title = dashboard.spec.display.name;
|
||||
const id = await dashboards.seed(dashboard);
|
||||
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
await qb.query('A').filter.type(FILTER);
|
||||
await qb.query('A').filter.press('Escape');
|
||||
await qb.query('A').aggregation.type(AGGREGATION);
|
||||
await qb.query('A').aggregation.press('Escape');
|
||||
await qb.run();
|
||||
|
||||
await qb.addToDashboard(title);
|
||||
await expect.poll(() => qb.query('A').filter.expression()).toBe(FILTER);
|
||||
await expect
|
||||
.poll(() => qb.query('A').aggregation.expression())
|
||||
.toBe(AGGREGATION);
|
||||
|
||||
await editor.save();
|
||||
// The shared spec types model only bare BuilderQuery plugins.
|
||||
const panels = (await dashboards.get(id)).spec.panels as unknown as Record<
|
||||
string,
|
||||
StoredPanel
|
||||
>;
|
||||
const [panelId] = Object.keys(panels);
|
||||
const plugin = panels[panelId].spec.queries[0].spec.plugin;
|
||||
expect(plugin.kind).toBe('signoz/CompositeQuery');
|
||||
expect(plugin.spec.queries[0].spec).toMatchObject({
|
||||
filter: { expression: FILTER },
|
||||
aggregations: [{ expression: AGGREGATION }],
|
||||
});
|
||||
|
||||
await dashboards.openEditor(id, panelId);
|
||||
await expect.poll(() => qb.query('A').filter.expression()).toBe(FILTER);
|
||||
await expect
|
||||
.poll(() => qb.query('A').aggregation.expression())
|
||||
.toBe(AGGREGATION);
|
||||
});
|
||||
});
|
||||
284
tests/e2e/tests/query-builder/filter.spec.ts
Normal file
284
tests/e2e/tests/query-builder/filter.spec.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { expect, EXPLORER, test } from '../../drivers/query-builder';
|
||||
|
||||
// Typing is real keystrokes: the regressions here (caret jumps, swallowed
|
||||
// Enter, the run shortcut) only show up through the keyboard path.
|
||||
|
||||
const RECENT_KEPT = "service.name = 'frontend' AND severity_text = 'WARN'";
|
||||
const RECENT_REMOVED = 'logger.name EXISTS';
|
||||
|
||||
const COMPOUND: Record<string, string> = {
|
||||
'NOT over a parenthesised OR':
|
||||
"NOT (severity_text = 'INFO' OR severity_text = 'WARN') AND service.name = 'frontend'",
|
||||
'nested groups':
|
||||
"(service.name = 'frontend' AND (severity_text = 'ERROR' OR severity_text = 'WARN'))",
|
||||
'NOT IN with EXISTS':
|
||||
"service.name NOT IN ['frontend', 'adservice'] AND logger.name EXISTS",
|
||||
};
|
||||
|
||||
const queryWithFilter = (expression: string): unknown => ({
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryFormulas: [],
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: 'logs',
|
||||
expression: 'A',
|
||||
filter: { expression },
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const VERBATIM: Record<string, string> = {
|
||||
'an escaped quote': "logger.name = 'it\\'s'",
|
||||
'a double-quoted string': 'logger.name = "double quoted"',
|
||||
'a quoted numeric string': "logger.name = '123'",
|
||||
'a bare number': 'logger.name = 123',
|
||||
'a boolean': 'logger.name = true',
|
||||
'spaces and a backslash': "logger.name = 'a b\\\\c'",
|
||||
};
|
||||
|
||||
test.describe('Query builder — filter editor', () => {
|
||||
test('TC-07 key, operator and value suggestions build the expression', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
|
||||
await filter.type('severity_te');
|
||||
await filter.openSuggestions();
|
||||
await filter.suggestion('severity_text').first().click();
|
||||
await expect.poll(() => filter.expression()).toMatch(/^severity_text\s*$/);
|
||||
|
||||
await filter.openSuggestions();
|
||||
await expect(filter.suggestion(/^=/).first()).toBeVisible();
|
||||
await filter.append('= ');
|
||||
await filter.openSuggestions();
|
||||
await filter.suggestion('ERROR').first().click();
|
||||
await expect
|
||||
.poll(() => filter.expression())
|
||||
.toMatch(/^severity_text = 'ERROR'\s*$/);
|
||||
|
||||
const fired = await qb.run();
|
||||
expect(qb.spec(fired, 'A').filter?.expression?.trim()).toBe(
|
||||
"severity_text = 'ERROR'",
|
||||
);
|
||||
});
|
||||
|
||||
for (const [label, expression] of Object.entries(VERBATIM)) {
|
||||
test(`TC-08 ${label} is sent and restored verbatim`, async ({
|
||||
authedPage: page,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
|
||||
await filter.type(expression);
|
||||
await filter.press('Escape');
|
||||
const fired = await qb.run();
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(expression);
|
||||
|
||||
await page.reload();
|
||||
await expect(filter.content()).toBeVisible();
|
||||
await expect.poll(() => filter.expression()).toBe(expression);
|
||||
await expect(qb.query('A').filterStatus()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-11 the status icon shows only for an unfocused invalid filter', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const query = qb.query('A');
|
||||
|
||||
await query.filter.type('service.name =');
|
||||
await expect(query.filterStatus()).toHaveCount(0);
|
||||
|
||||
await query.filter.content().blur();
|
||||
await expect(query.filterStatus()).toBeVisible();
|
||||
|
||||
await query.filter.type("service.name = 'frontend'");
|
||||
await query.filter.content().blur();
|
||||
await expect(query.filterStatus()).toHaveCount(0);
|
||||
|
||||
await query.filter.clear();
|
||||
await query.filter.content().blur();
|
||||
await expect(query.filterStatus()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-12 Enter and Shift+Enter never insert a newline or run', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
await filter.type("severity_text = 'ERROR'");
|
||||
await filter.press('Escape');
|
||||
await expect(filter.suggestions()).toHaveCount(0);
|
||||
const sentBefore = qb.sent().length;
|
||||
|
||||
await filter.press('Enter');
|
||||
await filter.press('Shift+Enter');
|
||||
|
||||
await expect(filter.lineCount()).toHaveCount(1);
|
||||
await expect.poll(() => filter.expression()).toBe("severity_text = 'ERROR'");
|
||||
expect(qb.sent()).toHaveLength(sentBefore);
|
||||
});
|
||||
|
||||
for (const explorer of ['logs', 'traces'] as const) {
|
||||
test(`TC-13 Cmd/Ctrl+Enter in the ${explorer} filter runs the edited query`, async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER[explorer]);
|
||||
const filter = qb.query('A').filter;
|
||||
await filter.type("service.name = 'frontend'");
|
||||
|
||||
const fired = await qb.runShortcut();
|
||||
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(
|
||||
"service.name = 'frontend'",
|
||||
);
|
||||
await expect(filter.lineCount()).toHaveCount(1);
|
||||
expect(qb.urlQuery()).toMatchObject({ queryType: 'builder' });
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-14 Escape closes suggestions and keeps the text', async ({ qb }) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
|
||||
await filter.type('serv');
|
||||
await filter.openSuggestions();
|
||||
await filter.press('Escape');
|
||||
|
||||
await expect(filter.suggestions()).toHaveCount(0);
|
||||
await expect.poll(() => filter.expression()).toBe('serv');
|
||||
await filter.press('i');
|
||||
await expect.poll(() => filter.expression()).toBe('servi');
|
||||
|
||||
await filter.press('Escape');
|
||||
await filter.press('Escape');
|
||||
await expect.poll(() => filter.expression()).toBe('servi');
|
||||
await expect(filter.content()).toBeFocused();
|
||||
});
|
||||
|
||||
test('TC-09 value suggestions fill an IN list', async ({ qb }) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
|
||||
await filter.type('service.name IN ');
|
||||
await filter.openSuggestions();
|
||||
await filter.suggestion('frontend').first().click();
|
||||
await expect
|
||||
.poll(() => filter.expression())
|
||||
.toBe("service.name IN ['frontend'] ");
|
||||
|
||||
await filter.type("service.name IN ['frontend', 'cartservice']");
|
||||
await filter.press('Escape');
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(
|
||||
"service.name IN ['frontend', 'cartservice']",
|
||||
);
|
||||
await expect(qb.query('A').filterStatus()).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const [label, expression] of Object.entries(COMPOUND)) {
|
||||
test(`TC-10 ${label} is valid and sent verbatim`, async ({ qb }) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const query = qb.query('A');
|
||||
|
||||
await query.filter.type(expression);
|
||||
await query.filter.press('Escape');
|
||||
await query.filter.content().blur();
|
||||
await expect(query.filterStatus()).toHaveCount(0);
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(expression);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-15 an expression with CRLF from the URL loads without a RangeError', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs, {
|
||||
query: queryWithFilter("body CONTAINS 'line1\r\nline2'"),
|
||||
});
|
||||
|
||||
await expect.poll(() => qb.query('A').filter.expression()).toContain('line1');
|
||||
expect(qb.console.errors.filter((e) => e.includes('RangeError'))).toEqual([]);
|
||||
await qb.query('A').filter.append(" AND severity_text = 'ERROR'");
|
||||
await expect
|
||||
.poll(() => qb.query('A').filter.expression())
|
||||
.toMatch(/severity_text = 'ERROR'$/);
|
||||
expect(qb.console.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('TC-15 recent searches are offered, applied and deleted', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
for (const expression of [RECENT_KEPT, RECENT_REMOVED]) {
|
||||
await filter.type(expression);
|
||||
await filter.press('Escape');
|
||||
await qb.run();
|
||||
}
|
||||
|
||||
await filter.clear();
|
||||
await filter.openSuggestions();
|
||||
await expect(filter.suggestion(RECENT_KEPT)).toBeVisible();
|
||||
await expect(filter.suggestion(RECENT_REMOVED)).toBeVisible();
|
||||
|
||||
await filter.suggestion(RECENT_KEPT).click();
|
||||
await expect.poll(() => filter.expression()).toBe(RECENT_KEPT);
|
||||
|
||||
await filter.clear();
|
||||
await filter.openSuggestions();
|
||||
await filter
|
||||
.suggestion(RECENT_REMOVED)
|
||||
.getByRole('button', { name: 'Remove from recent searches' })
|
||||
.click();
|
||||
await expect(filter.suggestion(RECENT_REMOVED)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-15 recent searches survive a reload and stay with their signal', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
await qb.query('A').filter.type(RECENT_KEPT);
|
||||
await qb.query('A').filter.press('Escape');
|
||||
await qb.run();
|
||||
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
await qb.query('A').filter.clear();
|
||||
await qb.query('A').filter.openSuggestions();
|
||||
await expect(qb.query('A').filter.suggestion(RECENT_KEPT)).toBeVisible();
|
||||
|
||||
await qb.openExplorer(EXPLORER.traces);
|
||||
await qb.query('A').filter.clear();
|
||||
await qb.query('A').filter.openSuggestions();
|
||||
await expect(qb.query('A').filter.suggestion(RECENT_KEPT)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-16 a quick-filter value toggles between excluded and cleared', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
const handTyped = "service.name = 'frontend'";
|
||||
const excluded = `${handTyped} severity_text not in ['ERROR']`;
|
||||
const error = qb.quickFilter('Severity Text', 'ERROR');
|
||||
await filter.type(handTyped);
|
||||
await qb.run();
|
||||
|
||||
for (const expected of [excluded, handTyped, excluded]) {
|
||||
const fired = await error.toggle();
|
||||
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(expected);
|
||||
await expect.poll(() => filter.expression()).toBe(expected);
|
||||
}
|
||||
await expect(error.checkbox()).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
});
|
||||
106
tests/e2e/tests/query-builder/metrics.spec.ts
Normal file
106
tests/e2e/tests/query-builder/metrics.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
expect,
|
||||
EXPLORER,
|
||||
type MetricTypes,
|
||||
OTHER_SUM_METRIC,
|
||||
SUM_METRIC,
|
||||
test as base,
|
||||
withMetricTypes,
|
||||
} from '../../drivers/query-builder';
|
||||
|
||||
const test = withMetricTypes(base);
|
||||
|
||||
const MOMENT_AGGREGATIONS = ['Sum', 'Avg', 'Min', 'Max'];
|
||||
|
||||
interface TypeDefaults {
|
||||
metric: (types: MetricTypes) => string;
|
||||
time?: string;
|
||||
space: string;
|
||||
spaceOptions: string[];
|
||||
}
|
||||
|
||||
const DEFAULTS: Record<string, TypeDefaults> = {
|
||||
sum: {
|
||||
metric: () => SUM_METRIC,
|
||||
time: 'Rate',
|
||||
space: 'Sum',
|
||||
spaceOptions: MOMENT_AGGREGATIONS,
|
||||
},
|
||||
gauge: {
|
||||
metric: (types) => types.gauge,
|
||||
time: 'Avg',
|
||||
space: 'Avg',
|
||||
spaceOptions: MOMENT_AGGREGATIONS,
|
||||
},
|
||||
histogram: {
|
||||
metric: (types) => types.histogram,
|
||||
space: 'P90',
|
||||
spaceOptions: ['P50', 'P75', 'P90', 'P95', 'P99'],
|
||||
},
|
||||
};
|
||||
|
||||
test.describe('Query builder — metric aggregations', () => {
|
||||
for (const [type, expected] of Object.entries(DEFAULTS)) {
|
||||
test(`TC-19 a ${type} metric gets its type's default aggregations`, async ({
|
||||
metricTypes,
|
||||
qb,
|
||||
}) => {
|
||||
const metricName = expected.metric(metricTypes);
|
||||
await qb.openExplorer(EXPLORER.metrics);
|
||||
const metric = qb.query('A').metric;
|
||||
|
||||
await metric.select(metricName);
|
||||
|
||||
await expect(metric.spaceAggregation()).toHaveText(expected.space);
|
||||
expect(await metric.spaceOptions()).toEqual(expected.spaceOptions);
|
||||
const fired = await qb.run();
|
||||
expect(qb.spec(fired, 'A').aggregations?.[0]).toMatchObject({
|
||||
metricName,
|
||||
spaceAggregation: expected.space.toLowerCase(),
|
||||
...(expected.time && { timeAggregation: expected.time.toLowerCase() }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-19 a histogram has no time aggregation control', async ({
|
||||
metricTypes,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.metrics);
|
||||
|
||||
await qb.query('A').metric.select(metricTypes.histogram);
|
||||
|
||||
await expect(qb.query('A').metric.spaceAggregation()).toHaveText('P90');
|
||||
await expect(qb.query('A').metric.timeAggregation()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-20 aggregations reset only when the metric type changes', async ({
|
||||
metricTypes,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.metrics);
|
||||
const metric = qb.query('A').metric;
|
||||
|
||||
await metric.select(SUM_METRIC);
|
||||
await metric.setTimeAggregation('Increase');
|
||||
await metric.setSpaceAggregation('Max');
|
||||
|
||||
await metric.select(OTHER_SUM_METRIC);
|
||||
await expect(metric.timeAggregation()).toHaveText('Increase');
|
||||
await expect(metric.spaceAggregation()).toHaveText('Max');
|
||||
|
||||
await metric.select(metricTypes.gauge);
|
||||
await expect(metric.timeAggregation()).toHaveText('Avg');
|
||||
await expect(metric.spaceAggregation()).toHaveText('Avg');
|
||||
|
||||
await metric.select(SUM_METRIC);
|
||||
await expect(metric.timeAggregation()).toHaveText('Rate');
|
||||
await expect(metric.spaceAggregation()).toHaveText('Sum');
|
||||
const fired = await qb.run();
|
||||
expect(qb.spec(fired, 'A').aggregations?.[0]).toMatchObject({
|
||||
metricName: SUM_METRIC,
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
});
|
||||
});
|
||||
});
|
||||
136
tests/e2e/tests/query-builder/multi-query.spec.ts
Normal file
136
tests/e2e/tests/query-builder/multi-query.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
logsCountQuery,
|
||||
metricsQuery,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test as editorTest,
|
||||
} from '../../drivers/panel-editor';
|
||||
import {
|
||||
expect,
|
||||
OTHER_SUM_METRIC,
|
||||
SUM_METRIC,
|
||||
withQueryBuilder,
|
||||
} from '../../drivers/query-builder';
|
||||
|
||||
// An added query continues the previous query's signal. On these metrics
|
||||
// panels it starts with no metric, and Run does nothing until one is picked.
|
||||
const test = withQueryBuilder(editorTest);
|
||||
|
||||
const SIGNAL_LABEL = { logs: 'Logs', traces: 'Traces' } as const;
|
||||
|
||||
const CLONED_FILTER = "operation = '/ads/get'";
|
||||
|
||||
test.describe('Query builder — multiple queries', () => {
|
||||
for (const signal of ['logs', 'traces'] as const) {
|
||||
test(`TC-28 a query added to a ${signal} panel stays ${signal}`, async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: logsCountQuery({ signal }) }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
const added = await qb.addQuery('B');
|
||||
|
||||
await expect(added.signal()).toHaveText(SIGNAL_LABEL[signal]);
|
||||
await expect(added.aggregation.root()).toBeVisible();
|
||||
await expect(added.metric.input()).toHaveCount(0);
|
||||
await added.filter.type("service.name = 'frontend'");
|
||||
await added.filter.press('Escape');
|
||||
await qb.rerunUntil(() => qb.latest('B')?.signal, signal);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-28 a deleted name is reused by the next added query', async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
for (const name of ['B', 'C']) {
|
||||
await (await qb.addQuery(name)).metric.select(OTHER_SUM_METRIC);
|
||||
}
|
||||
expect(await qb.queryNames()).toEqual(['A', 'B', 'C']);
|
||||
|
||||
await qb.query('B').delete();
|
||||
expect(await qb.queryNames()).toEqual(['A', 'C']);
|
||||
const reAdded = await qb.addQuery('B');
|
||||
expect(await qb.queryNames()).toEqual(['A', 'C', 'B']);
|
||||
await reAdded.metric.select(SUM_METRIC);
|
||||
await expect(reAdded.metric.input()).toHaveValue(SUM_METRIC);
|
||||
await expect(qb.query('C').metric.input()).toHaveValue(OTHER_SUM_METRIC);
|
||||
});
|
||||
|
||||
test('TC-28 a clone and a hidden query reach the request', async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery({ filter: CLONED_FILTER }) }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await (await qb.addQuery('B')).metric.select(OTHER_SUM_METRIC);
|
||||
|
||||
await qb.query('A').clone();
|
||||
await expect
|
||||
.poll(() => qb.query('C').filter.expression())
|
||||
.toBe(CLONED_FILTER);
|
||||
await qb.query('B').toggleVisibility();
|
||||
await qb.runButton().click();
|
||||
|
||||
await expect.poll(() => qb.latest('B')?.disabled).toBe(true);
|
||||
expect(qb.latest('C')).toMatchObject({
|
||||
filter: { expression: CLONED_FILTER },
|
||||
aggregations: [expect.objectContaining({ metricName: SUM_METRIC })],
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-28 the last remaining query offers no delete', async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await (await qb.addQuery('B')).metric.select(OTHER_SUM_METRIC);
|
||||
|
||||
await qb.query('B').delete();
|
||||
await qb.query('A').actions().click();
|
||||
|
||||
await expect(qb.query('A').menuItem('clone')).toBeVisible();
|
||||
await expect(qb.query('A').menuItem('delete')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-29 a formula over two queries reaches the request', async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await (await qb.addQuery('B')).metric.select(OTHER_SUM_METRIC);
|
||||
|
||||
const formula = await qb.addFormula('F1');
|
||||
await formula.expression().fill('A / B');
|
||||
await formula.legend().fill('ratio');
|
||||
await qb.runButton().click();
|
||||
await expect
|
||||
.poll(() => qb.latest('F1'))
|
||||
.toMatchObject({ expression: 'A / B', legend: 'ratio' });
|
||||
|
||||
await formula.delete();
|
||||
await qb.rerunUntil(
|
||||
() =>
|
||||
qb
|
||||
.sent()
|
||||
.at(-1)
|
||||
?.compositeQuery.queries.map((q) => q.spec.name),
|
||||
['A', 'B'],
|
||||
);
|
||||
});
|
||||
});
|
||||
203
tests/e2e/tests/query-builder/panel-editor.spec.ts
Normal file
203
tests/e2e/tests/query-builder/panel-editor.spec.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
logsCountQuery,
|
||||
metricsQuery,
|
||||
PanelKind,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test as editorTest,
|
||||
} from '../../drivers/panel-editor';
|
||||
import {
|
||||
expect,
|
||||
reduceToLabel,
|
||||
SUM_METRIC,
|
||||
withMetricTypes,
|
||||
withQueryBuilder,
|
||||
} from '../../drivers/query-builder';
|
||||
|
||||
const test = withMetricTypes(withQueryBuilder(editorTest));
|
||||
|
||||
const LOGS_FILTER = "severity_text = 'ERROR'";
|
||||
const TRACES_FILTER = "http.method = 'GET'";
|
||||
const PROMQL = 'sum(rate(signoz_calls_total[5m]))';
|
||||
|
||||
const QUERIES = {
|
||||
logs: () => logsCountQuery(),
|
||||
traces: () => logsCountQuery({ signal: 'traces' }),
|
||||
metrics: () => metricsQuery(),
|
||||
};
|
||||
|
||||
const ALL = ['group_by', 'having', 'order_by', 'limit', 'legend_format'];
|
||||
const NO_HAVING = ['group_by', 'order_by', 'limit', 'legend_format'];
|
||||
const METRICS = ['having', 'order_by', 'limit', 'legend_format'];
|
||||
|
||||
/**
|
||||
* Add-on toggles each panel kind offers per signal. Metrics group by lives in
|
||||
* the aggregation row, so it is never a toggle there.
|
||||
*/
|
||||
const ADD_ONS: Record<
|
||||
keyof typeof QUERIES,
|
||||
Partial<Record<string, string[]>>
|
||||
> = {
|
||||
logs: {
|
||||
[PanelKind.TimeSeries]: ALL,
|
||||
[PanelKind.BarChart]: ALL,
|
||||
[PanelKind.Table]: ALL,
|
||||
[PanelKind.PieChart]: ALL,
|
||||
[PanelKind.Number]: ['legend_format'],
|
||||
[PanelKind.Heatmap]: NO_HAVING,
|
||||
},
|
||||
traces: {
|
||||
[PanelKind.TimeSeries]: ALL,
|
||||
[PanelKind.BarChart]: ALL,
|
||||
[PanelKind.Table]: ALL,
|
||||
[PanelKind.PieChart]: ALL,
|
||||
[PanelKind.Number]: ['legend_format'],
|
||||
[PanelKind.Heatmap]: NO_HAVING,
|
||||
},
|
||||
metrics: {
|
||||
[PanelKind.TimeSeries]: METRICS,
|
||||
[PanelKind.BarChart]: METRICS,
|
||||
[PanelKind.Table]: [...METRICS, 'reduce_to'],
|
||||
[PanelKind.PieChart]: [...METRICS, 'reduce_to'],
|
||||
[PanelKind.Number]: ['legend_format', 'reduce_to'],
|
||||
[PanelKind.Heatmap]: ['order_by', 'limit', 'legend_format', 'bucket_options'],
|
||||
},
|
||||
};
|
||||
|
||||
test.describe('Query builder — panel editor', () => {
|
||||
for (const [signal, kinds] of Object.entries(ADD_ONS)) {
|
||||
for (const [kind, expected] of Object.entries(kinds)) {
|
||||
test(`TC-27 ${signal} on ${kind.replace('signoz/', '')} offers its add-ons`, async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({
|
||||
kind: kind as PanelKind,
|
||||
query: QUERIES[signal as keyof typeof QUERIES](),
|
||||
}),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await expect(qb.query('A').addOns.toggles()).toHaveCount(
|
||||
expected?.length ?? 0,
|
||||
);
|
||||
const toggles = await qb
|
||||
.query('A')
|
||||
.addOns.toggles()
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.map((node) =>
|
||||
(node.getAttribute('data-testid') ?? '').replace('query-add-on-', ''),
|
||||
),
|
||||
);
|
||||
expect(toggles).toEqual(expected);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('TC-27 a limit hidden by a kind switch reopens with its value', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: logsCountQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
const addOns = qb.query('A').addOns;
|
||||
await addOns.open('limit');
|
||||
await addOns.limit().fill('7');
|
||||
|
||||
await editor.switchType(PanelKind.Number);
|
||||
await expect(addOns.content('limit')).toHaveCount(0);
|
||||
await editor.switchType(PanelKind.TimeSeries);
|
||||
|
||||
await expect(addOns.limit()).toHaveValue('7');
|
||||
// The kind switch already fetched: Run can be a cache hit.
|
||||
await qb.runButton().click();
|
||||
await expect.poll(() => qb.latest('A')?.limit).toBe(7);
|
||||
});
|
||||
|
||||
test('TC-26 reduce-to follows the metric type and keeps a choice within it', async ({
|
||||
dashboards,
|
||||
metricTypes,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table, query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
const query = qb.query('A');
|
||||
await expect(query.addOns.reduceTo.value()).toHaveText(reduceToLabel('Sum'));
|
||||
|
||||
await query.addOns.reduceTo.set('Max');
|
||||
await query.metric.select('signoz_latency_count');
|
||||
await expect(query.addOns.reduceTo.value()).toHaveText(reduceToLabel('Max'));
|
||||
|
||||
await query.metric.select(metricTypes.gauge);
|
||||
await expect(query.addOns.reduceTo.value()).toHaveText(
|
||||
reduceToLabel('Average'),
|
||||
);
|
||||
|
||||
await query.metric.select(SUM_METRIC);
|
||||
await expect(query.addOns.reduceTo.value()).toHaveText(reduceToLabel('Sum'));
|
||||
expect(qb.spec(await qb.run(), 'A').aggregations?.[0]).toMatchObject({
|
||||
metricName: SUM_METRIC,
|
||||
reduceTo: 'sum',
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-31 switching signal restores the query left on each signal', async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: logsCountQuery({ groupBy: [] }) }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
const query = qb.query('A');
|
||||
await query.filter.type(LOGS_FILTER);
|
||||
await query.filter.press('Escape');
|
||||
|
||||
await query.setSignal('Traces');
|
||||
await expect.poll(() => query.filter.expression()).toBe('');
|
||||
await query.filter.type(TRACES_FILTER);
|
||||
await query.filter.press('Escape');
|
||||
|
||||
await query.setSignal('Logs');
|
||||
await expect.poll(() => query.filter.expression()).toBe(LOGS_FILTER);
|
||||
await query.setSignal('Traces');
|
||||
await expect.poll(() => query.filter.expression()).toBe(TRACES_FILTER);
|
||||
await qb.runButton().click();
|
||||
await expect
|
||||
.poll(() => qb.latest('A'))
|
||||
.toMatchObject({
|
||||
signal: 'traces',
|
||||
filter: { expression: TRACES_FILTER },
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-33 a PromQL query is sent as promql and kept across tab switches', async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await qb.tab('promql').click();
|
||||
await qb.promql().fill(PROMQL);
|
||||
const fired = await qb.run();
|
||||
expect(fired.at(-1)?.compositeQuery.queries[0]).toMatchObject({
|
||||
type: 'promql',
|
||||
spec: { name: 'A', query: PROMQL },
|
||||
});
|
||||
|
||||
await qb.tab('clickhouse_sql').click();
|
||||
await qb.tab('builder').click();
|
||||
await expect(qb.query('A').metric.spaceAggregation()).toHaveText('Sum');
|
||||
await qb.tab('promql').click();
|
||||
await expect(qb.promql()).toHaveValue(PROMQL);
|
||||
});
|
||||
});
|
||||
80
tests/e2e/tests/query-builder/run.spec.ts
Normal file
80
tests/e2e/tests/query-builder/run.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { expect, EXPLORER, test } from '../../drivers/query-builder';
|
||||
import {
|
||||
holdQueryRange,
|
||||
mockQueryRange,
|
||||
mockQueryRangeError,
|
||||
QueryRange,
|
||||
unmockQueryRange,
|
||||
} from '../../drivers/query-range-mock';
|
||||
|
||||
test.describe('Query builder — run and cancel', () => {
|
||||
test('TC-34 a held run can be cancelled and the next run goes through', async ({
|
||||
authedPage: page,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const held = await holdQueryRange(page, QueryRange.empty());
|
||||
await qb.query('A').filter.type("severity_text = 'WARN'");
|
||||
await qb.query('A').filter.press('Escape');
|
||||
|
||||
await qb.runButton().click();
|
||||
await expect(qb.runButton()).toHaveText('Cancel');
|
||||
await qb.runButton().click();
|
||||
await expect(qb.runButton()).toHaveText(/Run Query/);
|
||||
await expect(qb.logsResults()).toContainText('Query cancelled');
|
||||
|
||||
held.release();
|
||||
await unmockQueryRange(page);
|
||||
await qb.query('A').filter.type("severity_text = 'ERROR'");
|
||||
await qb.query('A').filter.press('Escape');
|
||||
// The cancelled query is re-sent alongside; the newest request wins.
|
||||
await qb.run();
|
||||
expect(qb.latest('A')?.filter?.expression).toBe("severity_text = 'ERROR'");
|
||||
await expect(qb.logsResults()).not.toContainText('Query cancelled');
|
||||
});
|
||||
|
||||
test('TC-35 a failed run keeps the query editable and the next run recovers', async ({
|
||||
authedPage: page,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
await mockQueryRangeError(page, 'mocked query failure');
|
||||
await qb.query('A').filter.type("severity_text = 'INFO'");
|
||||
await qb.query('A').filter.press('Escape');
|
||||
|
||||
await qb.runButton().click();
|
||||
// The failed request is retried before the error settles.
|
||||
await expect(qb.runButton()).toHaveText(/Run Query/, { timeout: 20_000 });
|
||||
await expect(qb.logsResults()).toContainText('mocked query failure');
|
||||
await expect
|
||||
.poll(() => qb.query('A').filter.expression())
|
||||
.toBe("severity_text = 'INFO'");
|
||||
|
||||
await unmockQueryRange(page);
|
||||
await qb.query('A').filter.type("severity_text = 'WARN'");
|
||||
await qb.query('A').filter.press('Escape');
|
||||
const fired = await qb.run();
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe("severity_text = 'WARN'");
|
||||
await expect(qb.logsResults()).not.toContainText('mocked query failure');
|
||||
});
|
||||
|
||||
test('TC-35 an empty result leaves the query unchanged', async ({
|
||||
authedPage: page,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
await mockQueryRange(page, QueryRange.empty());
|
||||
await qb.query('A').filter.type("service.name = 'nothing-here'");
|
||||
await qb.query('A').filter.press('Escape');
|
||||
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(
|
||||
"service.name = 'nothing-here'",
|
||||
);
|
||||
await expect
|
||||
.poll(() => qb.query('A').filter.expression())
|
||||
.toBe("service.name = 'nothing-here'");
|
||||
expect(qb.console.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
194
tests/e2e/tests/query-builder/url.spec.ts
Normal file
194
tests/e2e/tests/query-builder/url.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { expect, EXPLORER, test } from '../../drivers/query-builder';
|
||||
|
||||
// A shared or hand-edited explorer link must never take the page down: the
|
||||
// param is parsed, repaired and rewritten before the builder mounts.
|
||||
|
||||
const MALFORMED: Record<string, string> = {
|
||||
'not JSON': 'not-json',
|
||||
'truncated JSON': '%7B',
|
||||
'a JSON string': encodeURIComponent('"a string"'),
|
||||
'a JSON array': encodeURIComponent('[]'),
|
||||
'JSON null': 'null',
|
||||
'an empty JSON string': '%22%22',
|
||||
'double encoded': encodeURIComponent(
|
||||
encodeURIComponent(JSON.stringify({ queryType: 'builder' })),
|
||||
),
|
||||
};
|
||||
|
||||
const PARTIAL: Record<string, unknown> = {
|
||||
'an empty object': {},
|
||||
'builder with no builder block': { queryType: 'builder' },
|
||||
'builder with no queryData': { queryType: 'builder', builder: {} },
|
||||
'empty queryData': {
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [], queryFormulas: [] },
|
||||
},
|
||||
'a query with no dataSource': {
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ queryName: 'A' }] },
|
||||
},
|
||||
'an unknown queryType': { queryType: 'zzz' },
|
||||
'non-array promql and clickhouse blocks': {
|
||||
queryType: 'builder',
|
||||
promql: {},
|
||||
clickhouse_sql: 'x',
|
||||
builder: { queryData: [{ queryName: 'A', dataSource: 'logs' }] },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Saved before the v5 builder: structured filters, a having array and an
|
||||
* aggregate operator. The old having filter wrote `columnName` upper-cased
|
||||
* (`COUNT()`).
|
||||
*/
|
||||
const legacyLogsQuery = (having: unknown[]): unknown => ({
|
||||
queryType: 'builder',
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
builder: {
|
||||
queryFormulas: [],
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: 'logs',
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: '', dataType: '', type: '', isColumn: false },
|
||||
filters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'f1',
|
||||
key: {
|
||||
key: 'severity_text',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
isColumn: true,
|
||||
},
|
||||
op: '=',
|
||||
value: 'ERROR',
|
||||
},
|
||||
],
|
||||
},
|
||||
having,
|
||||
groupBy: [],
|
||||
orderBy: [],
|
||||
functions: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
stepInterval: 60,
|
||||
reduceTo: 'avg',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const FIRST = "service.name = 'frontend'";
|
||||
const SECOND = "service.name = 'cartservice'";
|
||||
|
||||
interface UrlQuery {
|
||||
builder?: { queryData?: { filter?: { expression?: string } }[] };
|
||||
}
|
||||
|
||||
test.describe('Query builder — URL handling', () => {
|
||||
for (const [label, raw] of Object.entries(MALFORMED)) {
|
||||
test(`TC-01 logs explorer survives ${label} and still runs`, async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { raw });
|
||||
|
||||
await expect(qb.query('A').filter.content()).toBeVisible();
|
||||
await qb.query('A').filter.type("severity_text = 'ERROR'");
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(
|
||||
"severity_text = 'ERROR'",
|
||||
);
|
||||
expect(qb.urlQuery()).toMatchObject({ queryType: 'builder' });
|
||||
expect(qb.console.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [label, query] of Object.entries(PARTIAL)) {
|
||||
test(`TC-02 logs explorer repairs ${label}`, async ({ qb }) => {
|
||||
const loaded = await qb.openExplorer(EXPLORER.logs, { query });
|
||||
|
||||
expect(qb.spec(loaded, 'A').signal).toBe('logs');
|
||||
await expect
|
||||
.poll(() => qb.urlQuery())
|
||||
.toMatchObject({
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ queryName: 'A', dataSource: 'logs' }] },
|
||||
});
|
||||
expect(qb.console.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-03 legacy filters and aggregate operator convert to v5 expressions', async ({
|
||||
qb,
|
||||
}) => {
|
||||
const loaded = await qb.openExplorer(EXPLORER.logs, {
|
||||
view: 'graph',
|
||||
query: legacyLogsQuery([]),
|
||||
});
|
||||
|
||||
const spec = qb.spec(loaded, 'A');
|
||||
expect(spec.filter?.expression).toMatch(/^severity_text\s*=\s*'ERROR'$/);
|
||||
expect(spec.aggregations?.[0]?.expression).toBe('count()');
|
||||
expect(spec).not.toHaveProperty('filters');
|
||||
await expect
|
||||
.poll(() => qb.query('A').filter.expression())
|
||||
.toMatch(/^severity_text\s*=\s*'ERROR'$/);
|
||||
expect(qb.console.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('TC-03 a legacy having as the old builder stored it still runs', async ({
|
||||
qb,
|
||||
}) => {
|
||||
const loaded = await qb.openExplorer(EXPLORER.logs, {
|
||||
view: 'graph',
|
||||
query: legacyLogsQuery([{ columnName: 'COUNT()', op: '>', value: 5 }]),
|
||||
});
|
||||
|
||||
expect(qb.spec(loaded, 'A').having?.expression).toBe('count() > 5');
|
||||
expect(qb.console.failedResponses).toEqual([]);
|
||||
});
|
||||
|
||||
for (const explorer of ['logs', 'traces'] as const) {
|
||||
test(`TC-04 ${explorer} explorer query follows back, forward and reload`, async ({
|
||||
authedPage: page,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER[explorer]);
|
||||
const filter = qb.query('A').filter;
|
||||
const urlFilter = (): unknown =>
|
||||
(qb.urlQuery() as UrlQuery | null)?.builder?.queryData?.[0]?.filter
|
||||
?.expression;
|
||||
|
||||
await filter.type(FIRST);
|
||||
await qb.run();
|
||||
await filter.type(SECOND);
|
||||
await qb.run();
|
||||
|
||||
await page.goBack();
|
||||
await expect.poll(urlFilter).toBe(FIRST);
|
||||
await expect.poll(() => filter.expression()).toBe(FIRST);
|
||||
|
||||
await page.goForward();
|
||||
await expect.poll(urlFilter).toBe(SECOND);
|
||||
await expect.poll(() => filter.expression()).toBe(SECOND);
|
||||
|
||||
await filter.type("service.name = 'unsaved'");
|
||||
const sentBefore = qb.sent().length;
|
||||
await page.reload();
|
||||
await expect.poll(() => filter.expression()).toBe(SECOND);
|
||||
await expect
|
||||
.poll(() => qb.sent().slice(sentBefore).length)
|
||||
.toBeGreaterThan(0);
|
||||
expect(qb.spec(qb.sent().slice(sentBefore), 'A').filter?.expression).toBe(
|
||||
SECOND,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
19
tests/fixtures/querier.py
vendored
19
tests/fixtures/querier.py
vendored
@@ -300,9 +300,6 @@ def build_builder_query(
|
||||
functions: list[dict] | None = None,
|
||||
bucket_options: dict | None = None,
|
||||
disabled: bool = False,
|
||||
order: list[dict] | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> dict:
|
||||
spec: dict[str, Any] = {
|
||||
"name": name,
|
||||
@@ -339,15 +336,6 @@ def build_builder_query(
|
||||
if functions:
|
||||
spec["functions"] = functions
|
||||
|
||||
if order:
|
||||
spec["order"] = order
|
||||
|
||||
if limit is not None:
|
||||
spec["limit"] = limit
|
||||
|
||||
if offset is not None:
|
||||
spec["offset"] = offset
|
||||
|
||||
return {"type": "builder_query", "spec": spec}
|
||||
|
||||
|
||||
@@ -360,7 +348,6 @@ def build_formula_query(
|
||||
disabled: bool = False,
|
||||
order: list[dict] | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> dict:
|
||||
spec: dict[str, Any] = {
|
||||
"name": name,
|
||||
@@ -375,8 +362,6 @@ def build_formula_query(
|
||||
spec["order"] = order
|
||||
if limit is not None:
|
||||
spec["limit"] = limit
|
||||
if offset is not None:
|
||||
spec["offset"] = offset
|
||||
return {"type": "builder_formula", "spec": spec}
|
||||
|
||||
|
||||
@@ -683,7 +668,6 @@ def build_scalar_query(
|
||||
group_by: list[dict] | None = None,
|
||||
order: list[dict] | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
filter_expression: str | None = None,
|
||||
having_expression: str | None = None,
|
||||
step_interval: int = DEFAULT_STEP_INTERVAL,
|
||||
@@ -710,9 +694,6 @@ def build_scalar_query(
|
||||
if limit is not None:
|
||||
spec["limit"] = limit
|
||||
|
||||
if offset is not None:
|
||||
spec["offset"] = offset
|
||||
|
||||
if filter_expression:
|
||||
spec["filter"] = {"expression": filter_expression}
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
Aggregation,
|
||||
BuilderQuery,
|
||||
RequestType,
|
||||
TelemetryFieldKey,
|
||||
get_all_series,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"limit, offset, expected_services",
|
||||
[
|
||||
# limit=2 selects the top two groups in SQL first; the skipped group must still count toward that page
|
||||
pytest.param(2, 1, ["service-a", "service-b"], id="offset_within_limited_page"),
|
||||
pytest.param(None, 1, ["service-a", "service-b", "service-d"], id="offset_without_limit"),
|
||||
pytest.param(2, 4, [], id="offset_past_the_end"),
|
||||
],
|
||||
)
|
||||
def test_logs_time_series_offset_skips_series_in_order(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
limit: int | None,
|
||||
offset: int,
|
||||
expected_services: list[str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# Ordered by count, descending: service-c 7, service-a 5, service-b 3, service-d 1, all in the bucket before now.
|
||||
service_counts = {"service-a": 5, "service-b": 3, "service-c": 7, "service-d": 1}
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
resources={"service.name": service},
|
||||
body=f"{service} log {i}",
|
||||
)
|
||||
for service, count in service_counts.items()
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type=RequestType.TIME_SERIES,
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
aggregations=[Aggregation("count()")],
|
||||
group_by=[TelemetryFieldKey("service.name", "string", "resource")],
|
||||
step_interval=60,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
bucket_ms = int((now - timedelta(minutes=1)).timestamp() * 1000)
|
||||
assert [
|
||||
(
|
||||
next(label["value"] for label in series["labels"] if label["key"]["name"] == "service.name"),
|
||||
[(point["timestamp"], point["value"]) for point in series["values"]],
|
||||
)
|
||||
for series in get_all_series(response.json(), "A")
|
||||
] == [(service, [(bucket_ms, service_counts[service])]) for service in expected_services]
|
||||
@@ -1,75 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import build_builder_query, get_all_series, make_query_request
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"limit, offset, expected_services",
|
||||
[
|
||||
pytest.param(2, 1, ["service-a", "service-b"], id="offset_within_page"),
|
||||
pytest.param(None, 1, ["service-a", "service-b", "service-d"], id="offset_without_limit"),
|
||||
pytest.param(2, 4, [], id="offset_past_the_end"),
|
||||
],
|
||||
)
|
||||
def test_metrics_time_series_offset_skips_series_in_order(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
limit: int | None,
|
||||
offset: int,
|
||||
expected_services: list[str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# Ordered by value, descending: service-c 70, service-a 50, service-b 30, service-d 10, one sample each in the bucket before now.
|
||||
service_values = {"service-a": 50.0, "service-b": 30.0, "service-c": 70.0, "service-d": 10.0}
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name="offset.gauge",
|
||||
labels={"service.name": service},
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
temporality="Unspecified",
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
value=value,
|
||||
)
|
||||
for service, value in service_values.items()
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
queries=[
|
||||
build_builder_query(
|
||||
"A",
|
||||
"offset.gauge",
|
||||
"avg",
|
||||
"sum",
|
||||
temporality="unspecified",
|
||||
group_by=["service.name"],
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert [
|
||||
(
|
||||
next(label["value"] for label in series["labels"] if label["key"]["name"] == "service.name"),
|
||||
[point["value"] for point in series["values"]],
|
||||
)
|
||||
for series in get_all_series(response.json(), "A")
|
||||
] == [(service, [service_values[service]]) for service in expected_services]
|
||||
@@ -1,249 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import (
|
||||
RequestType,
|
||||
build_aggregation,
|
||||
build_formula_query,
|
||||
build_group_by_field,
|
||||
build_metrics_aggregation,
|
||||
build_order_by,
|
||||
build_scalar_query,
|
||||
find_named_result,
|
||||
get_scalar_table_data,
|
||||
make_query_request,
|
||||
make_scalar_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
# Four services ordered by count, descending: service-c 7, service-a 5, service-b 3, service-d 1.
|
||||
service_counts = {"service-a": 5, "service-b": 3, "service-c": 7, "service-d": 1}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"limit, offset, expected_services",
|
||||
[
|
||||
pytest.param(2, 1, ["service-a", "service-b"], id="offset_within_page"),
|
||||
pytest.param(None, 3, ["service-d"], id="offset_without_limit"),
|
||||
pytest.param(2, 4, [], id="offset_past_the_end"),
|
||||
],
|
||||
)
|
||||
def test_logs_scalar_offset_skips_rows_in_order(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
limit: int | None,
|
||||
offset: int,
|
||||
expected_services: list[str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
resources={"service.name": service},
|
||||
body=f"{service} log {i}",
|
||||
)
|
||||
for service, count in service_counts.items()
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_scalar_query_request(
|
||||
signoz,
|
||||
token,
|
||||
now,
|
||||
[
|
||||
build_scalar_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
aggregations=[build_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name")],
|
||||
order=[build_order_by("count()", "desc")],
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert get_scalar_table_data(response.json()) == [[service, service_counts[service]] for service in expected_services]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"limit, offset, expected_services",
|
||||
[
|
||||
pytest.param(2, 1, ["service-a", "service-b"], id="offset_within_page"),
|
||||
pytest.param(None, 3, ["service-d"], id="offset_without_limit"),
|
||||
pytest.param(2, 4, [], id="offset_past_the_end"),
|
||||
],
|
||||
)
|
||||
def test_traces_scalar_offset_skips_rows_in_order(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
limit: int | None,
|
||||
offset: int,
|
||||
expected_services: list[str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
resources={"service.name": service},
|
||||
name=f"{service} span {i}",
|
||||
)
|
||||
for service, count in service_counts.items()
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_scalar_query_request(
|
||||
signoz,
|
||||
token,
|
||||
now,
|
||||
[
|
||||
build_scalar_query(
|
||||
name="A",
|
||||
signal="traces",
|
||||
aggregations=[build_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name")],
|
||||
order=[build_order_by("count()", "desc")],
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert get_scalar_table_data(response.json()) == [[service, service_counts[service]] for service in expected_services]
|
||||
|
||||
|
||||
def test_metrics_scalar_offset_skips_rows_in_order(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# Ordered by value, descending: service-c 70, service-a 50, service-b 30, service-d 10.
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name="offset.gauge",
|
||||
labels={"service.name": service},
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
temporality="Unspecified",
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
value=value,
|
||||
)
|
||||
for service, value in {"service-a": 50.0, "service-b": 30.0, "service-c": 70.0, "service-d": 10.0}.items()
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_scalar_query_request(
|
||||
signoz,
|
||||
token,
|
||||
now,
|
||||
[
|
||||
build_scalar_query(
|
||||
name="A",
|
||||
signal="metrics",
|
||||
aggregations=[build_metrics_aggregation("offset.gauge", "latest", "sum", "unspecified", reduce_to="last")],
|
||||
group_by=[build_group_by_field("service.name", "string", "attribute")],
|
||||
order=[build_order_by("sum(offset.gauge)", "desc")],
|
||||
limit=2,
|
||||
offset=1,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert get_scalar_table_data(response.json()) == [["service-a", 50.0], ["service-b", 30.0]]
|
||||
|
||||
|
||||
def test_formula_scalar_offset_skips_rows_in_order(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
resources={"service.name": service},
|
||||
body=f"{service} log {i}",
|
||||
)
|
||||
for service, count in service_counts.items()
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type=RequestType.SCALAR,
|
||||
queries=[
|
||||
build_scalar_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
aggregations=[build_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name")],
|
||||
disabled=True,
|
||||
),
|
||||
build_formula_query(
|
||||
"F1",
|
||||
"A * 2",
|
||||
order=[build_order_by("__result", "desc")],
|
||||
limit=2,
|
||||
offset=1,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
result = find_named_result(response.json()["data"]["data"]["results"], "F1")
|
||||
assert result is not None, "formula result F1 not found"
|
||||
service_column = next(i for i, column in enumerate(result["columns"]) if column["name"] == "service.name")
|
||||
value_column = next(i for i, column in enumerate(result["columns"]) if column["name"] == "__result")
|
||||
assert [[row[service_column], row[value_column]] for row in result["data"]] == [["service-a", 10.0], ["service-b", 6.0]]
|
||||
|
||||
|
||||
def test_negative_offset_is_rejected(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_scalar_query_request(
|
||||
signoz,
|
||||
token,
|
||||
datetime.now(tz=UTC).replace(second=0, microsecond=0),
|
||||
[build_scalar_query(name="A", signal="logs", aggregations=[build_aggregation("count()")], offset=-1)],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert response.json()["error"]["message"] == "invalid query 'A': offset must be non-negative, got -1"
|
||||
@@ -1,152 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
Aggregation,
|
||||
BuilderQuery,
|
||||
RequestType,
|
||||
TelemetryFieldKey,
|
||||
find_named_result,
|
||||
get_all_series,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"limit, offset, expected_services",
|
||||
[
|
||||
# limit=2 selects the top two groups in SQL first; the skipped group must still count toward that page
|
||||
pytest.param(2, 1, ["service-a", "service-b"], id="offset_within_limited_page"),
|
||||
pytest.param(None, 1, ["service-a", "service-b", "service-d"], id="offset_without_limit"),
|
||||
pytest.param(2, 4, [], id="offset_past_the_end"),
|
||||
],
|
||||
)
|
||||
def test_traces_time_series_offset_skips_series_in_order(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
limit: int | None,
|
||||
offset: int,
|
||||
expected_services: list[str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# Ordered by count, descending: service-c 7, service-a 5, service-b 3, service-d 1, all in the bucket before now.
|
||||
service_counts = {"service-a": 5, "service-b": 3, "service-c": 7, "service-d": 1}
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
resources={"service.name": service},
|
||||
name=f"{service} span {i}",
|
||||
)
|
||||
for service, count in service_counts.items()
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type=RequestType.TIME_SERIES,
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="traces",
|
||||
name="A",
|
||||
aggregations=[Aggregation("count()")],
|
||||
group_by=[TelemetryFieldKey("service.name", "string", "resource")],
|
||||
step_interval=60,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
bucket_ms = int((now - timedelta(minutes=1)).timestamp() * 1000)
|
||||
assert [
|
||||
(
|
||||
next(label["value"] for label in series["labels"] if label["key"]["name"] == "service.name"),
|
||||
[(point["timestamp"], point["value"]) for point in series["values"]],
|
||||
)
|
||||
for series in get_all_series(response.json(), "A")
|
||||
] == [(service, [(bucket_ms, service_counts[service])]) for service in expected_services]
|
||||
|
||||
|
||||
def test_trace_operator_scalar_offset_skips_rows_in_order(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
span_counts = {"offset-a": 4, "offset-b": 3, "offset-c": 2, "offset-d": 1}
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=10),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id="",
|
||||
name="scalar-offset",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": service},
|
||||
attributes={"operation.type": "scalar-offset"},
|
||||
)
|
||||
for service, count in span_counts.items()
|
||||
for _ in range(count)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type=RequestType.SCALAR,
|
||||
queries=[
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"disabled": True,
|
||||
"filter": {"expression": "operation.type = 'scalar-offset'"},
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A",
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
"groupBy": [{"name": "service.name", "fieldContext": "resource"}],
|
||||
"limit": 2,
|
||||
"offset": 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
result = find_named_result(response.json()["data"]["data"]["results"], "C")
|
||||
assert result is not None, "trace_operator result C not found"
|
||||
assert result["data"] == [["offset-b", 3], ["offset-c", 2]]
|
||||
Reference in New Issue
Block a user