mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-06 08:20:42 +00:00
Compare commits
6 Commits
nv/offset-
...
fix/query-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38f4b85a78 | ||
|
|
7268d5ebf5 | ||
|
|
9fc012d819 | ||
|
|
e17252420c | ||
|
|
ac153c34f3 | ||
|
|
f234da5983 |
@@ -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,11 +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. Requires limit. 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
|
||||
@@ -10108,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. Requires limit.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the formula result.
|
||||
items:
|
||||
@@ -10137,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
|
||||
@@ -10175,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:
|
||||
@@ -10191,11 +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. Requires limit. 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
|
||||
@@ -10257,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
|
||||
@@ -10296,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:
|
||||
@@ -10312,11 +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. Requires limit. 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
|
||||
@@ -10378,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
|
||||
@@ -10417,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:
|
||||
@@ -10433,11 +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. Requires limit. 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
|
||||
@@ -10538,9 +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. Requires
|
||||
limit.
|
||||
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
|
||||
@@ -28316,12 +28287,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28330,15 +28295,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- metrics-field:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- metrics-field:list
|
||||
- VIEWER
|
||||
summary: Get rule history filter keys
|
||||
tags:
|
||||
- rules
|
||||
@@ -28506,12 +28465,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28520,15 +28473,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- logs-field:read
|
||||
- traces-field:read
|
||||
- metrics-field:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- logs-field:read
|
||||
- traces-field:read
|
||||
- metrics-field:read
|
||||
- VIEWER
|
||||
summary: Get rule history filter values
|
||||
tags:
|
||||
- rules
|
||||
@@ -28601,9 +28548,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
summary: Get rule overall status timeline
|
||||
tags:
|
||||
- rules
|
||||
@@ -28673,9 +28620,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
summary: Get rule history stats
|
||||
tags:
|
||||
- rules
|
||||
@@ -28757,12 +28704,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28771,19 +28712,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- VIEWER
|
||||
summary: Get rule history timeline
|
||||
tags:
|
||||
- rules
|
||||
@@ -28848,12 +28779,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28862,19 +28787,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- VIEWER
|
||||
summary: Get top contributors to rule firing
|
||||
tags:
|
||||
- rules
|
||||
|
||||
@@ -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. Requires limit. 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. Requires limit. 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. Requires limit. 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. Requires limit. 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. Requires limit.
|
||||
*/
|
||||
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. Requires limit.
|
||||
* @description The number of raw results to skip before returning. Aggregation requests ignore it.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,6 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
|
||||
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
|
||||
AI_OBSERVABILITY_PANEL_COLUMNS = 'AI_OBSERVABILITY_PANEL_COLUMNS',
|
||||
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useQueryClient } from 'react-query';
|
||||
import { useAppSearchParams } from 'lib/router/useAppSearchParams';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
@@ -86,9 +85,8 @@ function Explorer(): JSX.Element {
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const selectedView = useMemo(
|
||||
() => getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
[searchParams, panelTypesFromUrl],
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
@@ -117,6 +115,8 @@ function Explorer(): JSX.Element {
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
@@ -190,7 +190,6 @@ function Explorer(): JSX.Element {
|
||||
testId="llm-observability-explorer"
|
||||
showFilters={isOpen}
|
||||
onToggleFilters={(): void => setOpen(!isOpen)}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.ai_observability }}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
quickFilterSource: QuickFiltersSource.AI_OBSERVABILITY,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Dispatch, SetStateAction, useCallback } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useAppNavigate } from 'lib/router/useAppNavigate';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type {
|
||||
CellTypographySize,
|
||||
@@ -32,8 +31,6 @@ export type TracesTableProps = {
|
||||
isFilterApplied: boolean;
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
columnSizing?: ColumnSizingState;
|
||||
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||
cellTypographySize?: CellTypographySize;
|
||||
};
|
||||
|
||||
@@ -51,8 +48,6 @@ function TracesTable({
|
||||
isFilterApplied,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
columnSizing,
|
||||
onColumnSizingChange,
|
||||
cellTypographySize = 'medium',
|
||||
}: TracesTableProps): JSX.Element {
|
||||
const navigate = useAppNavigate();
|
||||
@@ -103,8 +98,6 @@ function TracesTable({
|
||||
cellTypographySize={cellTypographySize}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
onColumnRemove={onColumnRemove}
|
||||
columnSizing={columnSizing}
|
||||
onColumnSizingChange={onColumnSizingChange}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
getRowTestId={(row): string => `traces-table-row-${row.id}`}
|
||||
@@ -120,8 +113,6 @@ TracesTable.defaultProps = {
|
||||
respectColumnOrder: false,
|
||||
onColumnOrderChange: undefined,
|
||||
onColumnRemove: undefined,
|
||||
columnSizing: undefined,
|
||||
onColumnSizingChange: undefined,
|
||||
cellTypographySize: 'medium',
|
||||
};
|
||||
|
||||
|
||||
@@ -11,16 +11,13 @@ const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
|
||||
|
||||
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
|
||||
|
||||
// An aggregate that starts hidden, so the persisted defaults are observable.
|
||||
// An aggregate outside the default order starts hidden, so the persisted
|
||||
// defaults are observable.
|
||||
const COLUMNS = buildTraceViewColumns([
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'unlisted_aggregate' },
|
||||
]).map((column) =>
|
||||
column.id === 'unlisted_aggregate'
|
||||
? { ...column, defaultVisibility: false }
|
||||
: column,
|
||||
);
|
||||
]);
|
||||
|
||||
function RaceHarness(): JSX.Element {
|
||||
const [columnsReady, setColumnsReady] = useState(false);
|
||||
|
||||
@@ -12,19 +12,11 @@ import { QueryKey } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
setColumnSizing,
|
||||
useColumnSizing,
|
||||
} from 'components/TanStackTableView/useColumnStore';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { getTraceLink } from '../ListView/utils';
|
||||
import { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import TracesTable from '../TracesTable/TracesTable';
|
||||
@@ -48,11 +40,8 @@ import {
|
||||
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
|
||||
} from '../constants';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import {
|
||||
buildTraceViewColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
REQUIRED_FIELDS,
|
||||
} from './configs';
|
||||
import { PER_PAGE_OPTIONS } from './configs';
|
||||
import { useTraceViewColumns } from './useTraceViewColumns';
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
interface TracesViewProps {
|
||||
@@ -76,37 +65,14 @@ function TracesView({
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
});
|
||||
|
||||
const tableColumns = useMemo(
|
||||
() => buildTraceViewColumns(options.selectColumns),
|
||||
[options.selectColumns],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(cols.map((column) => column.id));
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
// The column store keeps widths only; the picked columns live in the slice.
|
||||
const columnSizing = useColumnSizing(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS,
|
||||
);
|
||||
const handleColumnSizingChange = useCallback<
|
||||
Dispatch<SetStateAction<ColumnSizingState>>
|
||||
>(
|
||||
(updater) => {
|
||||
setColumnSizing(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS,
|
||||
typeof updater === 'function' ? updater(columnSizing) : updater,
|
||||
);
|
||||
},
|
||||
[columnSizing],
|
||||
);
|
||||
const {
|
||||
columns,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
requiredFields,
|
||||
isLoading: isColumnsLoading,
|
||||
canPersistColumns,
|
||||
} = useTraceViewColumns();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
@@ -208,6 +174,24 @@ function TracesView({
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
// Without the full column set there is no pool to pick from, so the control is dropped.
|
||||
const fieldsSelectorConfig = useMemo(
|
||||
() =>
|
||||
canPersistColumns
|
||||
? { fieldsSelector: { value: selectedFields, onFieldsChange } }
|
||||
: null,
|
||||
[canPersistColumns, selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
|
||||
const tableColumns = useMemo(
|
||||
() =>
|
||||
canPersistColumns
|
||||
? columns
|
||||
: columns.filter((column) => column.defaultVisibility !== false),
|
||||
[canPersistColumns, columns],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.actionsContainer}>
|
||||
@@ -231,11 +215,11 @@ function TracesView({
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
config={config}
|
||||
config={fieldsSelectorConfig}
|
||||
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
|
||||
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
|
||||
extraFields={TRACE_VIEW_COLUMN_EXTRA_FIELDS}
|
||||
requiredFields={REQUIRED_FIELDS}
|
||||
requiredFields={requiredFields}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,13 +227,15 @@ function TracesView({
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={tableColumns}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
columnSizing={columnSizing}
|
||||
onColumnSizingChange={handleColumnSizingChange}
|
||||
columnStorageKey={
|
||||
canPersistColumns
|
||||
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
|
||||
: undefined
|
||||
}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isColumnsLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { TRACE_VIEW_DEFAULT_COLUMNS } from '../../constants';
|
||||
import type { TracesTableProps } from '../../TracesTable/TracesTable';
|
||||
import TracesView from '../TracesView';
|
||||
|
||||
jest.mock('../../TracesTable/TracesTable', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
columns,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
onColumnSizingChange,
|
||||
}: TracesTableProps): JSX.Element => (
|
||||
<div>
|
||||
<div data-testid="table-columns">
|
||||
{columns.map((column) => String(column.header)).join(',')}
|
||||
</div>
|
||||
<button
|
||||
data-testid="reverse-columns"
|
||||
type="button"
|
||||
onClick={(): void => onColumnOrderChange?.([...columns].reverse())}
|
||||
>
|
||||
reverse
|
||||
</button>
|
||||
<button
|
||||
data-testid="remove-total-tokens"
|
||||
type="button"
|
||||
onClick={(): void => onColumnRemove?.('trace:total_tokens:float64')}
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
<button
|
||||
data-testid="resize-trace-id"
|
||||
type="button"
|
||||
onClick={(): void => onColumnSizingChange?.({ trace_id: 240 })}
|
||||
>
|
||||
resize
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const PANEL_COLUMNS_KEY = LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS;
|
||||
const SIZING_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
|
||||
|
||||
const TOTAL_TOKENS = {
|
||||
name: 'total_tokens',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
};
|
||||
|
||||
const storedColumnNames = (): string[] | undefined => {
|
||||
const raw = localStorage.getItem(PANEL_COLUMNS_KEY);
|
||||
return raw
|
||||
? (JSON.parse(raw)[PANEL_TYPES.TRACE] as { name: string }[]).map(
|
||||
(field) => field.name,
|
||||
)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const shownColumns = (): string[] =>
|
||||
(screen.getByTestId('table-columns').textContent ?? '').split(',');
|
||||
|
||||
const renderTracesView = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<TracesView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
/>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/llm-observability/traces',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
stagedQuery: initialQueryAIWithType,
|
||||
currentQuery: initialQueryAIWithType,
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
describe('TracesView columns', () => {
|
||||
beforeEach(() => {
|
||||
useColumnStore.setState({ tables: {} });
|
||||
localStorage.clear();
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: { type: 'trace', data: {} } })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows the default columns while nothing is picked, storing nothing', () => {
|
||||
renderTracesView();
|
||||
|
||||
expect(shownColumns()).toStrictEqual(
|
||||
TRACE_VIEW_DEFAULT_COLUMNS.map((field) => field.name),
|
||||
);
|
||||
expect(localStorage.getItem(PANEL_COLUMNS_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows trace_id first when a stored list lacks it', () => {
|
||||
localStorage.setItem(
|
||||
PANEL_COLUMNS_KEY,
|
||||
JSON.stringify({ [PANEL_TYPES.TRACE]: [TOTAL_TOKENS] }),
|
||||
);
|
||||
|
||||
renderTracesView();
|
||||
|
||||
expect(shownColumns()).toStrictEqual(['trace_id', 'total_tokens']);
|
||||
});
|
||||
|
||||
it('stores the order a column drag leaves behind', async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem(
|
||||
PANEL_COLUMNS_KEY,
|
||||
JSON.stringify({
|
||||
[PANEL_TYPES.TRACE]: [{ name: 'trace_id' }, TOTAL_TOKENS],
|
||||
}),
|
||||
);
|
||||
renderTracesView();
|
||||
|
||||
await user.click(screen.getByTestId('reverse-columns'));
|
||||
|
||||
expect(storedColumnNames()).toStrictEqual(['total_tokens', 'trace_id']);
|
||||
expect(shownColumns()).toStrictEqual(['total_tokens', 'trace_id']);
|
||||
});
|
||||
|
||||
it('keeps a dragged trace_id in place when the stored list lacks it', async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem(
|
||||
PANEL_COLUMNS_KEY,
|
||||
JSON.stringify({ [PANEL_TYPES.TRACE]: [TOTAL_TOKENS] }),
|
||||
);
|
||||
renderTracesView();
|
||||
|
||||
await user.click(screen.getByTestId('reverse-columns'));
|
||||
|
||||
expect(storedColumnNames()).toStrictEqual(['total_tokens', 'trace_id']);
|
||||
expect(shownColumns()).toStrictEqual(['total_tokens', 'trace_id']);
|
||||
});
|
||||
|
||||
it('drops a column removed from the table', async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem(
|
||||
PANEL_COLUMNS_KEY,
|
||||
JSON.stringify({
|
||||
[PANEL_TYPES.TRACE]: [{ name: 'trace_id' }, TOTAL_TOKENS],
|
||||
}),
|
||||
);
|
||||
renderTracesView();
|
||||
|
||||
await user.click(screen.getByTestId('remove-total-tokens'));
|
||||
|
||||
expect(storedColumnNames()).toStrictEqual(['trace_id']);
|
||||
expect(shownColumns()).toStrictEqual(['trace_id']);
|
||||
});
|
||||
|
||||
it('keeps column widths in the column store', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
await user.click(screen.getByTestId('resize-trace-id'));
|
||||
|
||||
expect(
|
||||
useColumnStore.getState().tables[SIZING_KEY].columnSizing,
|
||||
).toStrictEqual({ trace_id: 240 });
|
||||
expect(localStorage.getItem(PANEL_COLUMNS_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,8 @@ import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import TracesView from '../TracesView';
|
||||
|
||||
const PANEL_COLUMNS_KEY = LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS;
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
|
||||
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
|
||||
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
|
||||
|
||||
@@ -83,9 +84,9 @@ const mockFieldKeysFailure = (): void => {
|
||||
);
|
||||
};
|
||||
|
||||
const storedOptions = (): Record<string, { name: string }[]> | null => {
|
||||
const raw = localStorage.getItem(PANEL_COLUMNS_KEY);
|
||||
return raw ? (JSON.parse(raw) as Record<string, { name: string }[]>) : null;
|
||||
const persistedState = (): { hiddenColumnIds: string[] } | null => {
|
||||
const raw = localStorage.getItem(PERSISTED_KEY);
|
||||
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
|
||||
};
|
||||
|
||||
const renderTracesView = (): ReturnType<typeof render> =>
|
||||
@@ -120,16 +121,16 @@ describe('TracesView column persistence', () => {
|
||||
// Rows are virtualised, so a mounted table stands in for "rows arrived".
|
||||
const findTable = (): Promise<HTMLElement> => screen.findByRole('table');
|
||||
|
||||
it('shows the default columns once the field keys arrive, storing nothing', async () => {
|
||||
it('seeds the persisted defaults once the field keys arrive', async () => {
|
||||
mockFieldKeys(['llm_call_count', 'tool_call_count']);
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
|
||||
expect(persistedState()?.hiddenColumnIds).toStrictEqual([]);
|
||||
});
|
||||
expect(storedOptions()).toBeNull();
|
||||
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
|
||||
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -139,19 +140,20 @@ describe('TracesView column persistence', () => {
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(storedOptions()).toBeNull();
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
expect(persistedState()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the column picker when the field keys fail', async () => {
|
||||
it('drops the column picker when the field keys fail', async () => {
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
|
||||
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the default columns when the field keys fail', async () => {
|
||||
it('renders the display-only columns when the field keys fail', async () => {
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
@@ -160,27 +162,22 @@ describe('TracesView column persistence', () => {
|
||||
expect(screen.getByText('root_span_name')).toBeInTheDocument();
|
||||
expect(screen.getByText('trace_id')).toBeInTheDocument();
|
||||
expect(screen.getByText('input')).toBeInTheDocument();
|
||||
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
|
||||
expect(screen.queryByText('llm_call_count')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a stored selection, in its order, while the field keys fail', async () => {
|
||||
it('leaves an existing selection untouched while the field keys fail', async () => {
|
||||
const existing = {
|
||||
[PANEL_TYPES.TRACE]: [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'trace_id' },
|
||||
],
|
||||
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
|
||||
columnOrder: ['trace_id', 'resource:service.name'],
|
||||
columnSizing: {},
|
||||
};
|
||||
localStorage.setItem(PANEL_COLUMNS_KEY, JSON.stringify(existing));
|
||||
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
|
||||
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
const headers = screen
|
||||
.getAllByRole('columnheader')
|
||||
.map((header) => header.textContent);
|
||||
expect(headers).toStrictEqual(['service.name', 'trace_id']);
|
||||
expect(storedOptions()).toStrictEqual(existing);
|
||||
expect(persistedState()).toStrictEqual(existing);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { useTraceViewColumns } from '../useTraceViewColumns';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
|
||||
const AGGREGATE_KEYS = [
|
||||
'llm_call_count',
|
||||
'tool_call_count',
|
||||
'distinct_tool_count',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'total_tokens',
|
||||
'estimated_total_cost',
|
||||
'max_llm_duration_nano',
|
||||
];
|
||||
|
||||
const fieldNames = (fields: TelemetryFieldKey[]): string[] =>
|
||||
fields.map((field) => field.name);
|
||||
|
||||
const columnNames = (columns: { header?: unknown }[]): string[] =>
|
||||
columns.map((column) => column.header as string);
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const seenAI: URLSearchParams[] = [];
|
||||
|
||||
const mockAggregateKeys = (names: string[]): void => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
|
||||
(req, res, ctx) => {
|
||||
seenAI.push(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
[
|
||||
{
|
||||
name,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
|
||||
},
|
||||
],
|
||||
]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderColumns = async (): Promise<
|
||||
ReturnType<typeof renderHook<ReturnType<typeof useTraceViewColumns>, unknown>>
|
||||
> => {
|
||||
const rendered = renderHook(() => useTraceViewColumns(), { wrapper });
|
||||
await waitFor(() => {
|
||||
expect(rendered.result.current.isLoading).toBe(false);
|
||||
});
|
||||
return rendered;
|
||||
};
|
||||
|
||||
describe('useTraceViewColumns', () => {
|
||||
beforeEach(() => {
|
||||
seenAI.length = 0;
|
||||
useColumnStore.getState().tables = {};
|
||||
localStorage.clear();
|
||||
mockAggregateKeys(AGGREGATE_KEYS);
|
||||
});
|
||||
|
||||
it('reads the aggregates from the trace context of the keys endpoint', async () => {
|
||||
await renderColumns();
|
||||
|
||||
expect(seenAI).toHaveLength(1);
|
||||
expect(seenAI[0]?.get('searchText')).toBe('');
|
||||
expect(seenAI[0]?.get('fieldContext')).toBe(
|
||||
TelemetrytypesFieldContextDTO.trace,
|
||||
);
|
||||
});
|
||||
|
||||
it('pools the hardcoded display-only columns with the endpoint aggregates', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(columnNames(result.current.columns)).toStrictEqual([
|
||||
'trace_id',
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'estimated_total_cost',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'total_tokens',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'distinct_tool_count',
|
||||
'llm_call_count',
|
||||
'tool_call_count',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'error_count',
|
||||
'input',
|
||||
'output',
|
||||
'max_llm_duration_nano',
|
||||
]);
|
||||
});
|
||||
|
||||
it('selects only the default-visible columns on first render', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'trace_id',
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'estimated_total_cost',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'total_tokens',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'distinct_tool_count',
|
||||
'llm_call_count',
|
||||
'tool_call_count',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'error_count',
|
||||
'input',
|
||||
'output',
|
||||
'max_llm_duration_nano',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a newly reported aggregate hidden until it is picked', async () => {
|
||||
mockAggregateKeys(['brand_new_aggregate']);
|
||||
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(columnNames(result.current.columns)).toContain('brand_new_aggregate');
|
||||
expect(fieldNames(result.current.selectedFields)).not.toContain(
|
||||
'brand_new_aggregate',
|
||||
);
|
||||
});
|
||||
|
||||
describe('when the keys fetch fails', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
|
||||
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not persist defaults', async () => {
|
||||
await renderColumns();
|
||||
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
expect(
|
||||
localStorage.getItem(`@signoz/table-columns/${STORAGE_KEY}`),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reports the column state as not persistable', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(result.current.canPersistColumns).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a selection change instead of persisting a partial set', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'trace_id' }]);
|
||||
});
|
||||
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('seeds the defaults once a later fetch succeeds', async () => {
|
||||
const { unmount } = await renderColumns();
|
||||
unmount();
|
||||
|
||||
mockAggregateKeys(AGGREGATE_KEYS);
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(result.current.canPersistColumns).toBe(true);
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'trace_id',
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'estimated_total_cost',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'total_tokens',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'distinct_tool_count',
|
||||
'llm_call_count',
|
||||
'tool_call_count',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'error_count',
|
||||
'input',
|
||||
'output',
|
||||
'max_llm_duration_nano',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the columns dropped from the selection', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'trace_id',
|
||||
'total_tokens',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a column added back from the pool', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'trace_id' }]);
|
||||
});
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'trace_id' }, { name: 'input' }]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'trace_id',
|
||||
'input',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the trace id column even when the selection drops it', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'span_count' }]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toContain('trace_id');
|
||||
expect(result.current.requiredFields).toStrictEqual(['trace_id']);
|
||||
});
|
||||
|
||||
it('persists the selection order', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([
|
||||
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'total_tokens',
|
||||
'trace_id',
|
||||
'service.name',
|
||||
]);
|
||||
expect(
|
||||
useColumnStore.getState().tables[STORAGE_KEY].columnOrder,
|
||||
).toStrictEqual([
|
||||
'trace:total_tokens:float64',
|
||||
'trace_id',
|
||||
'resource:service.name',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,17 +3,52 @@ import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
import { TRACE_ID_COLUMN_ID } from '../constants';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
export const REQUIRED_FIELDS: readonly string[] = [TRACE_ID_COLUMN_ID];
|
||||
/** Always present: it is the row's link to the trace, but it can be reordered. */
|
||||
export const TRACE_ID_COLUMN_ID = 'trace_id';
|
||||
|
||||
/** Fallback order, until the user drags a column; unlisted fields keep the order the keys endpoint returns them in. */
|
||||
const DEFAULT_COLUMN_ORDER = [
|
||||
TRACE_ID_COLUMN_ID,
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'estimated_total_cost',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'total_tokens',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'distinct_tool_count',
|
||||
'llm_call_count',
|
||||
'tool_call_count',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'error_count',
|
||||
'input',
|
||||
'output',
|
||||
'max_llm_duration_nano',
|
||||
];
|
||||
|
||||
const orderRank = (field: TelemetryFieldKey): number => {
|
||||
const index = DEFAULT_COLUMN_ORDER.indexOf(field.name);
|
||||
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
|
||||
};
|
||||
|
||||
export const sortByDefaultOrder = (
|
||||
fields: TelemetryFieldKey[],
|
||||
): TelemetryFieldKey[] =>
|
||||
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
|
||||
|
||||
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
|
||||
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
|
||||
|
||||
export const buildTraceViewColumns = (
|
||||
fields: TelemetryFieldKey[],
|
||||
): TableColumnDef<TracesTableRow>[] =>
|
||||
fields.map((field) => ({
|
||||
...getFieldColumn(field),
|
||||
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
|
||||
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
|
||||
enableMove: true,
|
||||
enableRemove: field.name !== TRACE_ID_COLUMN_ID,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import { mergeExtraFields } from 'utils/extraFields';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
hideColumn,
|
||||
initializeFromDefaults,
|
||||
setColumnOrder,
|
||||
showColumn,
|
||||
useColumnOrder,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
TRACE_VIEW_BUILDER_QUERY_TYPE,
|
||||
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
|
||||
TRACE_VIEW_FIELD_KEYS,
|
||||
} from '../constants';
|
||||
import {
|
||||
buildTraceViewColumns,
|
||||
sortByDefaultOrder,
|
||||
TRACE_ID_COLUMN_ID,
|
||||
} from './configs';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
|
||||
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
|
||||
const columnIdOf = (field: TelemetryFieldKey): string =>
|
||||
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
|
||||
|
||||
interface UseTraceViewColumns {
|
||||
columns: TableColumnDef<TracesTableRow>[];
|
||||
selectedFields: TelemetryFieldKey[];
|
||||
onFieldsChange: (next: TelemetryFieldKey[]) => void;
|
||||
requiredFields: readonly string[];
|
||||
isLoading: boolean;
|
||||
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
|
||||
canPersistColumns: boolean;
|
||||
}
|
||||
|
||||
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
|
||||
export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
const {
|
||||
data: fetchedFields = [],
|
||||
isFetched,
|
||||
isSuccess,
|
||||
} = useFieldKeysSuggestion(
|
||||
{
|
||||
...TRACE_VIEW_FIELD_KEYS,
|
||||
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
|
||||
searchText: '',
|
||||
},
|
||||
TRACE_VIEW_BUILDER_QUERY_TYPE,
|
||||
);
|
||||
|
||||
const availableFields = useMemo(
|
||||
() =>
|
||||
sortByDefaultOrder(
|
||||
mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
|
||||
),
|
||||
[fetchedFields],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildTraceViewColumns(availableFields),
|
||||
[availableFields],
|
||||
);
|
||||
|
||||
// Defaults from a partial column set would persist as the user's own choice.
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
initializeFromDefaults(STORAGE_KEY, columns);
|
||||
}
|
||||
}, [isSuccess, columns]);
|
||||
|
||||
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
|
||||
const columnOrder = useColumnOrder(STORAGE_KEY);
|
||||
|
||||
const selectedFields = useMemo(() => {
|
||||
const hidden = new Set(hiddenColumnIds);
|
||||
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
|
||||
|
||||
return availableFields
|
||||
.filter((field) => !hidden.has(columnIdOf(field)))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
|
||||
(orderIndex.get(columnIdOf(b)) ?? Infinity),
|
||||
);
|
||||
}, [availableFields, hiddenColumnIds, columnOrder]);
|
||||
|
||||
const onFieldsChange = useCallback(
|
||||
(next: TelemetryFieldKey[]): void => {
|
||||
if (!isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keptIds = new Set(next.map(columnIdOf));
|
||||
|
||||
columns.forEach((column) => {
|
||||
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
|
||||
showColumn(STORAGE_KEY, column.id);
|
||||
} else {
|
||||
hideColumn(STORAGE_KEY, column.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Columns missing from the order sort last, so the visible ones suffice.
|
||||
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
|
||||
},
|
||||
[columns, isSuccess],
|
||||
);
|
||||
|
||||
return {
|
||||
columns,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
requiredFields: [TRACE_ID_COLUMN_ID],
|
||||
isLoading: !isFetched,
|
||||
canPersistColumns: isSuccess,
|
||||
};
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { renderWithExplorerProviders } from 'container/SavedViews/__tests__/savedViewsTestUtils';
|
||||
import { act, screen } from 'tests/test-utils';
|
||||
|
||||
import Explorer from '../Explorer';
|
||||
|
||||
jest.mock('../ListView/ListView', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="ai-list-view" />,
|
||||
}));
|
||||
jest.mock('../TableView/TableView', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="ai-table-view" />,
|
||||
}));
|
||||
|
||||
const explorerUrlWithPanel = (panelType: PANEL_TYPES): string =>
|
||||
`${ROUTES.AI_OBSERVABILITY_EXPLORER}?${QueryParams.panelTypes}=${encodeURIComponent(
|
||||
JSON.stringify(panelType),
|
||||
)}`;
|
||||
|
||||
describe('AI observability Explorer', () => {
|
||||
it('shows the view of the panel type in the url', () => {
|
||||
renderWithExplorerProviders(
|
||||
<Explorer />,
|
||||
explorerUrlWithPanel(PANEL_TYPES.LIST),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('ai-list-view')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('follows the url when the panel type changes, as opening a saved view does', () => {
|
||||
const { history } = renderWithExplorerProviders(
|
||||
<Explorer />,
|
||||
explorerUrlWithPanel(PANEL_TYPES.LIST),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
history.push(explorerUrlWithPanel(PANEL_TYPES.TABLE));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-table-view')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('ai-list-view')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,6 @@ import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
|
||||
|
||||
/** Always present: it is the row's link to the trace, but it can be reordered. */
|
||||
export const TRACE_ID_COLUMN_ID = 'trace_id';
|
||||
|
||||
export const TOOLBAR_VIEWS = {
|
||||
trace: {
|
||||
name: 'trace',
|
||||
@@ -60,81 +57,6 @@ export const TRACE_VIEW_COLUMN_EXTRA_FIELDS: TelemetryFieldKey[] = [
|
||||
{ name: 'output' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
/**
|
||||
* Shown, in this order, until the user picks columns. The aggregates are the
|
||||
* keys the keys endpoint returns for builder_ai_query; the rest match
|
||||
* TRACE_VIEW_COLUMN_EXTRA_FIELDS, so both build the same column ids.
|
||||
*/
|
||||
export const TRACE_VIEW_DEFAULT_COLUMNS: TelemetryFieldKey[] = [
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'root_span_name' },
|
||||
{
|
||||
name: 'estimated_total_cost',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{ name: 'trace_duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{
|
||||
name: 'total_tokens',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{
|
||||
name: 'input_tokens',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{
|
||||
name: 'output_tokens',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{
|
||||
name: 'distinct_tool_count',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{
|
||||
name: 'llm_call_count',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{
|
||||
name: 'tool_call_count',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{ name: 'start_time' },
|
||||
{ name: 'end_time' },
|
||||
{ name: 'error_count' },
|
||||
{ name: 'input' },
|
||||
{ name: 'output' },
|
||||
{
|
||||
name: 'max_llm_duration_nano',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
];
|
||||
|
||||
export const ensureAiRequiredColumns = (
|
||||
columns: TelemetryFieldKey[],
|
||||
panelType: PANEL_TYPES,
|
||||
): TelemetryFieldKey[] =>
|
||||
panelType !== PANEL_TYPES.TRACE ||
|
||||
columns.some((column) => column.name === TRACE_ID_COLUMN_ID)
|
||||
? columns
|
||||
: [{ name: TRACE_ID_COLUMN_ID }, ...columns];
|
||||
|
||||
export const TRACE_VIEW_FIELD_KEYS = {
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
} as const;
|
||||
|
||||
@@ -87,7 +87,6 @@ describe('useOptionsMenu', () => {
|
||||
mockUsePreferenceContext.mockReturnValue({
|
||||
[SavedviewtypesSourceDTO.traces]: slice([], rawFormatting),
|
||||
[SavedviewtypesSourceDTO.logs]: slice([], rawFormatting),
|
||||
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
|
||||
} satisfies PreferenceContextValue);
|
||||
|
||||
mockUseUrlQueryData.mockReturnValue({
|
||||
@@ -237,7 +236,6 @@ describe('useOptionsMenu', () => {
|
||||
mockUsePreferenceContext.mockReturnValue({
|
||||
[SavedviewtypesSourceDTO.traces]: slice([], baseFormatting),
|
||||
[SavedviewtypesSourceDTO.logs]: slice(seedColumns, baseFormatting),
|
||||
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
|
||||
} satisfies PreferenceContextValue);
|
||||
});
|
||||
|
||||
@@ -359,7 +357,6 @@ describe('useOptionsMenu', () => {
|
||||
},
|
||||
),
|
||||
[SavedviewtypesSourceDTO.logs]: slice([], {}),
|
||||
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
|
||||
} satisfies PreferenceContextValue);
|
||||
|
||||
const { result } = renderHook(
|
||||
|
||||
@@ -21,7 +21,6 @@ export const SOURCE_PAGE_SIGNAL: Record<
|
||||
> = {
|
||||
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
|
||||
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
|
||||
[SavedviewtypesSourceDTO.ai_observability]: DataSource.TRACES,
|
||||
};
|
||||
|
||||
export const EXCLUDED_COLUMNS: Record<DataSource, string[]> = {
|
||||
|
||||
@@ -8,10 +8,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
hasPreferences,
|
||||
isPreferenceSourcePage,
|
||||
} from 'providers/preferences/utils';
|
||||
import { isPreferenceSourcePage } from 'providers/preferences/utils';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { UseActiveSavedViewResult } from '../types';
|
||||
@@ -23,12 +20,9 @@ export function useActiveSavedView(
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const viewPanelType = panelType ?? PANEL_TYPES.LIST;
|
||||
const hasOptions = hasPreferences(source, viewPanelType);
|
||||
const hasOptions = isPreferenceSourcePage(source);
|
||||
const { options } = useOptionsMenu({
|
||||
sourcePage: isPreferenceSourcePage(source)
|
||||
? source
|
||||
: SavedviewtypesSourceDTO.logs,
|
||||
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
|
||||
});
|
||||
|
||||
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
|
||||
@@ -67,18 +61,16 @@ export function useActiveSavedView(
|
||||
return hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery,
|
||||
panelType: viewPanelType,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
options: hasOptions ? options : undefined,
|
||||
sourcePage: source,
|
||||
});
|
||||
}, [
|
||||
view,
|
||||
isError,
|
||||
isStagedQueryCurrent,
|
||||
stagedQuery,
|
||||
viewPanelType,
|
||||
panelType,
|
||||
hasOptions,
|
||||
source,
|
||||
options,
|
||||
]);
|
||||
|
||||
|
||||
@@ -21,10 +21,7 @@ import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { usePreferenceSlice } from 'providers/preferences/context/usePreferenceSlice';
|
||||
import {
|
||||
hasPreferences,
|
||||
isPreferenceSourcePage,
|
||||
} from 'providers/preferences/utils';
|
||||
import { isPreferenceSourcePage } from 'providers/preferences/utils';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
@@ -62,12 +59,9 @@ export function useSavedViewActions(
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
|
||||
const viewPanelType = panelType ?? PANEL_TYPES.LIST;
|
||||
const hasOptions = hasPreferences(source, viewPanelType);
|
||||
const hasOptions = isPreferenceSourcePage(source);
|
||||
const { options } = useOptionsMenu({
|
||||
sourcePage: isPreferenceSourcePage(source)
|
||||
? source
|
||||
: SavedviewtypesSourceDTO.logs,
|
||||
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
|
||||
});
|
||||
const preferenceSlice = usePreferenceSlice(source);
|
||||
|
||||
@@ -85,12 +79,12 @@ export function useSavedViewActions(
|
||||
(displayName: string, color?: string): SavedviewtypesSavedViewSpecDTO =>
|
||||
toSavedViewSpec({
|
||||
query: stagedQuery ?? currentQuery,
|
||||
panelType: viewPanelType,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
displayName,
|
||||
options: hasOptions ? options : undefined,
|
||||
color,
|
||||
}),
|
||||
[stagedQuery, currentQuery, viewPanelType, hasOptions, options],
|
||||
[stagedQuery, currentQuery, panelType, hasOptions, options],
|
||||
);
|
||||
|
||||
const selectView = useCallback(
|
||||
@@ -107,11 +101,11 @@ export function useSavedViewActions(
|
||||
const revertView = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
if (preferenceSlice) {
|
||||
const { columns, formatting, panelType } = getViewColumnsAndFormatting(
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
source,
|
||||
);
|
||||
preferenceSlice.updateColumns(columns, panelType);
|
||||
preferenceSlice.updateColumns(columns);
|
||||
if (formatting) {
|
||||
preferenceSlice.updateFormatting(formatting);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export interface HasUnsavedViewChangesArgs {
|
||||
stagedQuery: Query | null;
|
||||
panelType: PANEL_TYPES;
|
||||
options?: SavedViewOptions;
|
||||
sourcePage: SavedviewtypesSourceDTO;
|
||||
}
|
||||
|
||||
export interface UseLastUsedViewResult {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
@@ -121,7 +120,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, selectColumns: [columns[0]] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -141,7 +139,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: bare,
|
||||
stagedQuery: stagedFrom(bare, DataSource.TRACES),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, selectColumns: defaultTraceSelectedColumns },
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -166,7 +163,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: { selectColumns: shownColumns, ...formatting },
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -178,7 +174,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -198,7 +193,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: bare,
|
||||
stagedQuery: stagedFrom(bare, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: {
|
||||
selectColumns: defaultLogsSelectedColumns,
|
||||
format: 'table',
|
||||
@@ -242,7 +236,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: olderView,
|
||||
stagedQuery: stagedFrom(olderView, DataSource.METRICS),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
sourcePage: SavedviewtypesSourceDTO.metrics,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -290,7 +283,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: storedView,
|
||||
stagedQuery: stagedWithTag,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
sourcePage: SavedviewtypesSourceDTO.metrics,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -330,7 +322,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: noOrder,
|
||||
stagedQuery: stagedFrom(noOrder, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -346,7 +337,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: noOrder,
|
||||
stagedQuery: sorted,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
@@ -356,7 +346,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: saved,
|
||||
stagedQuery: stagedFrom(saved, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -373,7 +362,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: legacy,
|
||||
stagedQuery: stagedFrom(legacy, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -385,7 +373,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, ...formatting, maxLines: 5 },
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
@@ -3,18 +3,12 @@ import {
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import {
|
||||
DEFAULT_PANEL_TYPE,
|
||||
ensureAiRequiredColumns,
|
||||
} from 'container/LLMObservability/Explorer/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import getAiObservabilityLoaderConfig from 'providers/preferences/configs/aiObservabilityLoaderConfig';
|
||||
import { Preferences } from 'providers/preferences/types';
|
||||
|
||||
function withColumnNames(
|
||||
@@ -53,18 +47,5 @@ export function getViewColumnsAndFormatting(
|
||||
if (sourcePage === SavedviewtypesSourceDTO.traces) {
|
||||
return { columns: selectedFields || defaultTraceSelectedColumns };
|
||||
}
|
||||
if (sourcePage === SavedviewtypesSourceDTO.ai_observability) {
|
||||
const panelType =
|
||||
(spec?.panelType as unknown as PANEL_TYPES | undefined) ??
|
||||
DEFAULT_PANEL_TYPE;
|
||||
return {
|
||||
columns: ensureAiRequiredColumns(
|
||||
selectedFields ||
|
||||
getAiObservabilityLoaderConfig(panelType).default().columns,
|
||||
panelType,
|
||||
),
|
||||
panelType,
|
||||
};
|
||||
}
|
||||
return { columns: [] };
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import {
|
||||
SavedviewtypesSourceDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { HasUnsavedViewChangesArgs } from '../types';
|
||||
import { getSavedViewQuery } from './getSavedViewQuery';
|
||||
import { toSavedViewPanelType } from './toSavedViewSpec';
|
||||
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
|
||||
import { toSavedViewSource } from './toSavedViewSource';
|
||||
|
||||
// Legacy context names the server stores under their v5 name.
|
||||
const FIELD_CONTEXT_ALIASES: Record<string, TelemetrytypesFieldContextDTO> = {
|
||||
@@ -52,7 +51,6 @@ export function hasUnsavedViewChanges({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
options,
|
||||
sourcePage,
|
||||
}: HasUnsavedViewChangesArgs): boolean {
|
||||
if (!stagedQuery) {
|
||||
return false;
|
||||
@@ -79,9 +77,10 @@ export function hasUnsavedViewChanges({
|
||||
return false;
|
||||
}
|
||||
|
||||
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
sourcePage,
|
||||
toSavedViewSource(dataSource),
|
||||
);
|
||||
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
|
||||
fields.map((field) => field.name ?? field.key);
|
||||
@@ -89,7 +88,7 @@ export function hasUnsavedViewChanges({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sourcePage !== SavedviewtypesSourceDTO.logs || !formatting) {
|
||||
if (dataSource !== DataSource.LOGS || !formatting) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -3,8 +3,8 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewSourcePage } from '../types';
|
||||
|
||||
// The AI assistant names a view's source by DataSource (the signal), the api
|
||||
// keys views by source page.
|
||||
// The views API files each view under a source page, but callers only know the
|
||||
// signal they query: a builder query's dataSource, or the AI assistant's hint.
|
||||
// TODO: remove in a follow-up once callers pass the source page directly.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
OpenTraceSource,
|
||||
TracesExplorerEvents,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
@@ -50,12 +44,6 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const ROW_TEST_ID = `traces-table-row-${listRows[0].data.span_id}`;
|
||||
|
||||
// The row click handler sits on each cell, so a click on the <tr> does nothing.
|
||||
const getRowCell = (row: HTMLElement): HTMLElement =>
|
||||
within(row).getAllByRole('cell')[0];
|
||||
|
||||
describe('Traces ListView - span drawer navigation', () => {
|
||||
beforeEach(() => {
|
||||
mockSuccess();
|
||||
@@ -146,63 +134,6 @@ describe('Traces ListView - span drawer navigation', () => {
|
||||
);
|
||||
expect(getCurrentLocation().pathname).toBe('/traces-explorer');
|
||||
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument();
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
source: OpenTraceSource.RowIcon,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the trace page in a new tab from the drawer', async () => {
|
||||
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
renderListView();
|
||||
|
||||
await user.click(getRowCell(await screen.findByTestId(ROW_TEST_ID)));
|
||||
expect(logEventMock).not.toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{ panelType: PANEL_TYPES.LIST, source: OpenTraceSource.Row },
|
||||
);
|
||||
await user.click(await screen.findByTestId('span-drawer-view-full-trace'));
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/trace\/0000000000000000344ded1387b08a7e\?/),
|
||||
'_blank',
|
||||
);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
source: OpenTraceSource.SpanDrawer,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the trace page in a new tab on a Cmd-click without opening the drawer', async () => {
|
||||
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
renderListView();
|
||||
|
||||
const cell = getRowCell(await screen.findByTestId(ROW_TEST_ID));
|
||||
await user.keyboard('{Meta>}');
|
||||
await user.click(cell);
|
||||
await user.keyboard('{/Meta}');
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/trace\/0000000000000000344ded1387b08a7e\?/),
|
||||
'_blank',
|
||||
'noopener',
|
||||
);
|
||||
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument();
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
source: OpenTraceSource.Row,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the row icon only on the hovered copy of a duplicate span', async () => {
|
||||
|
||||
@@ -146,9 +146,7 @@ function ListView({
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
),
|
||||
];
|
||||
const fieldColumns = fields.map((field) =>
|
||||
getFieldColumn(field, PANEL_TYPES.LIST),
|
||||
);
|
||||
const fieldColumns = fields.map((field) => getFieldColumn(field));
|
||||
return [openTraceColumn, ...fieldColumns];
|
||||
}, [options?.selectColumns, openTraceColumn]);
|
||||
|
||||
|
||||
@@ -2,12 +2,7 @@ import { MouseEvent } from 'react';
|
||||
import { CornerUpRight } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useIsRowHovered } from 'components/TanStackTableView/TanStackTableStateContext';
|
||||
import {
|
||||
logOpenTraceClicked,
|
||||
OpenTraceSource,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
@@ -28,7 +23,6 @@ function OpenTraceButton({
|
||||
const handleClick = (event: MouseEvent): void => {
|
||||
// The row click would open the drawer instead.
|
||||
event.stopPropagation();
|
||||
logOpenTraceClicked(PANEL_TYPES.LIST, OpenTraceSource.RowIcon);
|
||||
openInNewTab(getTraceLink(row));
|
||||
};
|
||||
|
||||
|
||||
@@ -4,12 +4,7 @@ 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 { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { UseRowNavigationResult } from 'components/TanStackTableView/useRowNavigation';
|
||||
import {
|
||||
logOpenTraceClicked,
|
||||
OpenTraceSource,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { SpanDetailVariant } from 'pages/TraceDetailsV3/SpanDetailsPanel/constants';
|
||||
@@ -53,10 +48,7 @@ function SpanDrawer({
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<CornerUpRight />}
|
||||
onClick={(): void => {
|
||||
logOpenTraceClicked(PANEL_TYPES.LIST, OpenTraceSource.SpanDrawer);
|
||||
openInNewTab(getTraceLink(row));
|
||||
}}
|
||||
onClick={(): void => openInNewTab(getTraceLink(row))}
|
||||
testId="span-drawer-view-full-trace"
|
||||
>
|
||||
View full trace
|
||||
|
||||
@@ -4,12 +4,7 @@ import { Minus } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
logOpenTraceClicked,
|
||||
OpenTraceSource,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getMs } from 'utils/timeUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
@@ -26,10 +21,9 @@ import styles from './FieldCell.module.scss';
|
||||
type FieldCellProps = {
|
||||
name: string;
|
||||
value: unknown;
|
||||
panelType: PANEL_TYPES;
|
||||
};
|
||||
|
||||
function FieldCell({ name, value, panelType }: FieldCellProps): JSX.Element {
|
||||
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
if (TIMESTAMP_FIELD_NAMES.has(name)) {
|
||||
@@ -60,15 +54,7 @@ function FieldCell({ name, value, panelType }: FieldCellProps): JSX.Element {
|
||||
<AppLink
|
||||
to={buildRoutePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
logOpenTraceClicked(panelType, OpenTraceSource.TraceId);
|
||||
}}
|
||||
onAuxClick={(e): void => {
|
||||
if (e.button === 1) {
|
||||
logOpenTraceClicked(panelType, OpenTraceSource.TraceId);
|
||||
}
|
||||
}}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
{text}
|
||||
</AppLink>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useRef } from 'react';
|
||||
import { useAppNavigate } from 'lib/router/useAppNavigate';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type {
|
||||
CellTypographySize,
|
||||
TableColumnDef,
|
||||
@@ -11,10 +10,6 @@ import { useRecoverFromEmptyPage } from 'components/TanStackTableView/useRecover
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import {
|
||||
logOpenTraceClicked,
|
||||
OpenTraceSource,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
|
||||
import { noop } from 'lodash-es';
|
||||
@@ -106,18 +101,16 @@ function TracesTable({
|
||||
onRowClick(row);
|
||||
return;
|
||||
}
|
||||
logOpenTraceClicked(PANEL_TYPES[panelType], OpenTraceSource.Row);
|
||||
navigate(getRowHref(row));
|
||||
},
|
||||
[onRowClick, navigate, getRowHref, panelType],
|
||||
[onRowClick, navigate, getRowHref],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
logOpenTraceClicked(PANEL_TYPES[panelType], OpenTraceSource.Row);
|
||||
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
|
||||
},
|
||||
[getRowHref, panelType],
|
||||
[getRowHref],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { TIMESTAMP_FIELD_NAMES } from './constants';
|
||||
import FieldCell from './FieldCell';
|
||||
@@ -11,7 +10,6 @@ export type TracesTableRow = { id: string } & Record<string, unknown>;
|
||||
|
||||
export function getFieldColumn(
|
||||
field: TelemetryFieldKey,
|
||||
panelType: PANEL_TYPES,
|
||||
): TableColumnDef<TracesTableRow> {
|
||||
const { name, fieldContext, fieldDataType } = field;
|
||||
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
|
||||
@@ -27,8 +25,6 @@ export function getFieldColumn(
|
||||
enableRemove: !isTimestamp,
|
||||
canBeHidden: !isTimestamp,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<FieldCell name={name} value={value} panelType={panelType} />
|
||||
),
|
||||
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
OpenTraceSource,
|
||||
TracesExplorerEvents,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
groupedResponse,
|
||||
groupedRows,
|
||||
QUERY_RANGE_URL,
|
||||
renderTracesView,
|
||||
} from './testUtils';
|
||||
|
||||
const TRACE_ID = '0000000000000000344ded1387b08a7e';
|
||||
const ROW_TEST_ID = `traces-table-row-${TRACE_ID}`;
|
||||
|
||||
describe('TracesView - open trace clicked', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(groupedResponse(groupedRows))),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('logs a row click that opens the trace page', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
const row = await screen.findByTestId(ROW_TEST_ID);
|
||||
// The row click handler sits on each cell, so a click on the <tr> does nothing.
|
||||
await user.click(within(row).getAllByRole('cell')[0]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getCurrentLocation().pathname).toBe(`/trace/${TRACE_ID}`),
|
||||
);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
source: OpenTraceSource.Row,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a trace_id link click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
const row = await screen.findByTestId(ROW_TEST_ID);
|
||||
await user.click(within(row).getByTestId('trace-id'));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
source: OpenTraceSource.TraceId,
|
||||
},
|
||||
);
|
||||
expect(logEventMock).not.toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{ panelType: PANEL_TYPES.TRACE, source: OpenTraceSource.Row },
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a middle click on the trace_id link', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
const row = await screen.findByTestId(ROW_TEST_ID);
|
||||
await user.pointer({
|
||||
keys: '[MouseMiddle]',
|
||||
target: within(row).getByTestId('trace-id'),
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
source: OpenTraceSource.TraceId,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
getFieldColumn,
|
||||
@@ -16,7 +15,7 @@ const TRACE_FIELDS = [
|
||||
|
||||
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
|
||||
(field) => ({
|
||||
...getFieldColumn(field, PANEL_TYPES.TRACE),
|
||||
...getFieldColumn(field),
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
}),
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
export enum TracesExplorerEvents {
|
||||
OpenTraceClicked = 'Traces Explorer: Open trace clicked',
|
||||
}
|
||||
|
||||
export enum OpenTraceSource {
|
||||
Row = 'row',
|
||||
RowIcon = 'row_icon',
|
||||
TraceId = 'trace_id',
|
||||
SpanDrawer = 'span_drawer',
|
||||
}
|
||||
|
||||
export function logOpenTraceClicked(
|
||||
panelType: PANEL_TYPES,
|
||||
source: OpenTraceSource,
|
||||
): void {
|
||||
void logEvent(TracesExplorerEvents.OpenTraceClicked, { panelType, source });
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TRACE_VIEW_DEFAULT_COLUMNS } from 'container/LLMObservability/Explorer/constants';
|
||||
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
@@ -12,15 +9,12 @@ import {
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { usePreferenceContext } from '../context/PreferenceContextProvider';
|
||||
import { PanelColumns, PreferenceSourcePage } from '../types';
|
||||
import { PreferenceSourcePage } from '../types';
|
||||
|
||||
const ROUTE_LOGS = '/logs';
|
||||
const ROUTE_TRACES = '/traces';
|
||||
const TESTID_LOGS = 'logs';
|
||||
const TESTID_TRACES = 'traces';
|
||||
const ROUTE_AI = '/ai-observability';
|
||||
const AI_DEFAULT_COUNT = String(TRACE_VIEW_DEFAULT_COLUMNS.length);
|
||||
const TESTID_AI = 'ai';
|
||||
|
||||
type LogsLocalOptions = {
|
||||
selectColumns?: TelemetryFieldKey[];
|
||||
@@ -63,9 +57,9 @@ function Consumer({
|
||||
type="button"
|
||||
onClick={(): void => {
|
||||
const newCols: TelemetryFieldKey[] =
|
||||
sourcePage === SavedviewtypesSourceDTO.logs
|
||||
? (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
|
||||
: (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
|
||||
sourcePage === SavedviewtypesSourceDTO.traces
|
||||
? (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
|
||||
: (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
|
||||
slice.updateColumns(newCols);
|
||||
}}
|
||||
>
|
||||
@@ -442,226 +436,4 @@ describe('PreferencesProvider integration', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI observability traces', () => {
|
||||
const renderAiConsumer = (): void => {
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.ai_observability}
|
||||
testIdPrefix={TESTID_AI}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: ROUTE_AI },
|
||||
);
|
||||
};
|
||||
|
||||
it('starts with the default trace view columns', () => {
|
||||
renderAiConsumer();
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
|
||||
AI_DEFAULT_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
it('loads its own localStorage, not the traces list one', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.TRACES_LIST_OPTIONS, {
|
||||
selectColumns: [{ name: 'traces.list.col' }],
|
||||
});
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.TRACE]: [{ name: 'ai.col.a' }, { name: 'ai.col.b' }],
|
||||
});
|
||||
|
||||
renderAiConsumer();
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
const URL_COLUMNS = [{ name: 'ai.url.a' }, { name: 'ai.url.b' }];
|
||||
|
||||
const renderAtUrl = (
|
||||
pathname: string,
|
||||
params: Record<string, unknown>,
|
||||
renderConsumer: () => void,
|
||||
): void => {
|
||||
const search = new URLSearchParams(
|
||||
Object.entries(params).map(([key, value]) => [key, JSON.stringify(value)]),
|
||||
).toString();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { ...originalLocation, pathname, search: `?${search}` },
|
||||
});
|
||||
|
||||
renderConsumer();
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
};
|
||||
|
||||
const renderAiAtUrl = (params: Record<string, unknown>): void =>
|
||||
renderAtUrl(ROUTES.AI_OBSERVABILITY_EXPLORER, params, renderAiConsumer);
|
||||
|
||||
it('ignores the url options on other explorers', () => {
|
||||
renderAtUrl(
|
||||
ROUTE_TRACES,
|
||||
{ options: { selectColumns: URL_COLUMNS } },
|
||||
renderAiConsumer,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
|
||||
AI_DEFAULT_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
it('loads the url columns for the panel in panelTypes', () => {
|
||||
renderAiAtUrl({
|
||||
options: { selectColumns: URL_COLUMNS },
|
||||
panelTypes: PANEL_TYPES.TRACE,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('files the url columns under the panel in panelTypes', () => {
|
||||
renderAiAtUrl({
|
||||
options: { selectColumns: URL_COLUMNS },
|
||||
panelTypes: PANEL_TYPES.LIST,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
|
||||
AI_DEFAULT_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers its localStorage over the url options', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.TRACE]: [{ name: 'ai.local.col' }],
|
||||
});
|
||||
|
||||
renderAiAtUrl({
|
||||
options: { selectColumns: URL_COLUMNS },
|
||||
panelTypes: PANEL_TYPES.TRACE,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('keeps its url columns out of the traces slice', () => {
|
||||
renderAtUrl(
|
||||
ROUTES.AI_OBSERVABILITY_EXPLORER,
|
||||
{
|
||||
options: { selectColumns: URL_COLUMNS },
|
||||
panelTypes: PANEL_TYPES.TRACE,
|
||||
},
|
||||
(): void => {
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.traces}
|
||||
testIdPrefix={TESTID_TRACES}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: ROUTE_AI },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('traces-columns-len')).toHaveTextContent(
|
||||
String(defaultTraceSelectedColumns.length),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops stored columns without a name or from another signal', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.TRACE]: [
|
||||
{ name: 'ai.col.a' },
|
||||
{ name: 'logs.col', signal: 'logs' },
|
||||
{ key: 'no.name' },
|
||||
{},
|
||||
'not-a-column',
|
||||
],
|
||||
});
|
||||
|
||||
renderAiConsumer();
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('ignores a stored panel that is not a list', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.TRACE]: 'ai.col.a',
|
||||
});
|
||||
|
||||
renderAiConsumer();
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
|
||||
AI_DEFAULT_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the url options when every stored column is invalid', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.TRACE]: [{ name: 'logs.col', signal: 'logs' }],
|
||||
});
|
||||
|
||||
renderAiAtUrl({
|
||||
options: { selectColumns: URL_COLUMNS },
|
||||
panelTypes: PANEL_TYPES.TRACE,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('loads the columns of the panel open in the url', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.TRACE]: [{ name: 'ai.trace.a' }, { name: 'ai.trace.b' }],
|
||||
[PANEL_TYPES.LIST]: [{ name: 'ai.list.col' }],
|
||||
});
|
||||
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.ai_observability}
|
||||
testIdPrefix={TESTID_AI}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: `${ROUTE_AI}?panelTypes=${encodeURIComponent(
|
||||
JSON.stringify(PANEL_TYPES.LIST),
|
||||
)}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('keeps each panel apart in its localStorage', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.LIST]: [{ name: 'ai.list.col' }],
|
||||
});
|
||||
|
||||
renderAiConsumer();
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
|
||||
AI_DEFAULT_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
it('updateColumns writes its own localStorage key in direct mode', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderAiConsumer();
|
||||
|
||||
await user.click(screen.getByTestId('ai-update-columns'));
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('2');
|
||||
expect(
|
||||
getLocalStorageJSON<PanelColumns>(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS,
|
||||
)?.[PANEL_TYPES.TRACE],
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
getLocalStorageJSON<TracesLocalOptions>(LOCALSTORAGE.TRACES_LIST_OPTIONS),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import getAiObservabilityUpdaterConfig from '../configs/aiObservabilityUpdaterConfig';
|
||||
import { PreferenceMode } from '../types';
|
||||
|
||||
const columns = [{ name: 'total_tokens' }] as TelemetryFieldKey[];
|
||||
|
||||
describe('aiObservabilityUpdaterConfig in saved view mode', () => {
|
||||
it('stores the columns under the open panel', () => {
|
||||
const setSavedViewPreferences = jest.fn();
|
||||
const { updateColumns } = getAiObservabilityUpdaterConfig(
|
||||
jest.fn(),
|
||||
setSavedViewPreferences,
|
||||
PANEL_TYPES.TRACE,
|
||||
);
|
||||
|
||||
updateColumns(columns, PreferenceMode.SAVED_VIEW);
|
||||
|
||||
expect(setSavedViewPreferences).toHaveBeenCalledWith({
|
||||
columns,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores the columns under the given panel', () => {
|
||||
const setSavedViewPreferences = jest.fn();
|
||||
const { updateColumns } = getAiObservabilityUpdaterConfig(
|
||||
jest.fn(),
|
||||
setSavedViewPreferences,
|
||||
PANEL_TYPES.LIST,
|
||||
);
|
||||
|
||||
updateColumns(columns, PreferenceMode.SAVED_VIEW, PANEL_TYPES.TRACE);
|
||||
|
||||
expect(setSavedViewPreferences).toHaveBeenCalledWith({
|
||||
columns,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import {
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { TRACE_VIEW_DEFAULT_COLUMNS } from 'container/LLMObservability/Explorer/constants';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
@@ -200,64 +197,6 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('adds trace_id to AI trace selectedFields and uses the defaults when absent', () => {
|
||||
mockViews([
|
||||
makeView('ai-with-fields', SavedviewtypesSourceDTO.ai_observability, {
|
||||
panelType: SavedviewtypesPanelTypeDTO.trace,
|
||||
selectedFields: [{ name: 'total_tokens' }],
|
||||
}),
|
||||
makeView('ai-without-fields', SavedviewtypesSourceDTO.ai_observability, {
|
||||
panelType: SavedviewtypesPanelTypeDTO.trace,
|
||||
}),
|
||||
]);
|
||||
|
||||
const withFields = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
savedViewId: 'ai-with-fields',
|
||||
}),
|
||||
);
|
||||
const withoutFields = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
savedViewId: 'ai-without-fields',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(withFields.result.current.preferences).toStrictEqual({
|
||||
columns: [{ name: 'trace_id' }, { name: 'total_tokens' }],
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
});
|
||||
expect(withoutFields.result.current.preferences).toStrictEqual({
|
||||
columns: TRACE_VIEW_DEFAULT_COLUMNS,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
});
|
||||
});
|
||||
|
||||
it('gives the open panel its defaults when the AI view was saved on another panel', () => {
|
||||
mockViews([
|
||||
makeView('ai-graph', SavedviewtypesSourceDTO.ai_observability, {
|
||||
panelType: SavedviewtypesPanelTypeDTO.graph,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
savedViewId: 'ai-graph',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.preferences).toStrictEqual({
|
||||
columns: TRACE_VIEW_DEFAULT_COLUMNS,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses defaults while the saved view is not loaded', () => {
|
||||
mockViews([makeView('other', SavedviewtypesSourceDTO.logs, {})]);
|
||||
|
||||
@@ -273,21 +212,4 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
ensureLogsRequiredColumns(defaultLogsSelectedColumns),
|
||||
);
|
||||
});
|
||||
it('uses the AI trace defaults while the saved view is not loaded', () => {
|
||||
mockViews([]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
savedViewId: 'missing',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.preferences).toStrictEqual({
|
||||
columns: TRACE_VIEW_DEFAULT_COLUMNS,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,7 +114,6 @@ describe('usePreferenceUpdater', () => {
|
||||
expect(mockLogsUpdateColumns).toHaveBeenCalledWith(
|
||||
newColumns,
|
||||
PreferenceMode.DIRECT,
|
||||
undefined,
|
||||
);
|
||||
expect(mockTracesUpdateColumns).not.toHaveBeenCalled();
|
||||
expect(setReSync).toHaveBeenCalledWith(true);
|
||||
@@ -178,7 +177,6 @@ describe('usePreferenceUpdater', () => {
|
||||
expect(mockTracesUpdateColumns).toHaveBeenCalledWith(
|
||||
newColumns,
|
||||
PreferenceMode.DIRECT,
|
||||
undefined,
|
||||
);
|
||||
expect(mockLogsUpdateColumns).not.toHaveBeenCalled();
|
||||
expect(setReSync).toHaveBeenCalledWith(true);
|
||||
|
||||
@@ -1,47 +1,20 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { hasPreferences, isPreferenceSourcePage } from '../utils';
|
||||
import { isPreferenceSourcePage } from '../utils';
|
||||
|
||||
describe('isPreferenceSourcePage', () => {
|
||||
it.each([
|
||||
SavedviewtypesSourceDTO.logs,
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
SavedviewtypesSourceDTO.ai_observability,
|
||||
])('is true for %s', (source) => {
|
||||
expect(isPreferenceSourcePage(source)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([SavedviewtypesSourceDTO.metrics, SavedviewtypesSourceDTO.meter])(
|
||||
'is false for %s',
|
||||
it.each([SavedviewtypesSourceDTO.logs, SavedviewtypesSourceDTO.traces])(
|
||||
'is true for %s',
|
||||
(source) => {
|
||||
expect(isPreferenceSourcePage(source)).toBe(false);
|
||||
expect(isPreferenceSourcePage(source)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('hasPreferences', () => {
|
||||
it('is true for every panel of the logs and traces explorers', () => {
|
||||
expect(
|
||||
hasPreferences(SavedviewtypesSourceDTO.traces, PANEL_TYPES.TRACE),
|
||||
).toBe(true);
|
||||
expect(hasPreferences(SavedviewtypesSourceDTO.logs, PANEL_TYPES.LIST)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('is true only for the AI observability panels that keep columns', () => {
|
||||
expect(
|
||||
hasPreferences(SavedviewtypesSourceDTO.ai_observability, PANEL_TYPES.TRACE),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasPreferences(SavedviewtypesSourceDTO.ai_observability, PANEL_TYPES.LIST),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for source pages without a slice', () => {
|
||||
expect(
|
||||
hasPreferences(SavedviewtypesSourceDTO.metrics, PANEL_TYPES.LIST),
|
||||
).toBe(false);
|
||||
it.each([
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
SavedviewtypesSourceDTO.meter,
|
||||
SavedviewtypesSourceDTO.ai_observability,
|
||||
])('is false for %s', (source) => {
|
||||
expect(isPreferenceSourcePage(source)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
DEFAULT_PANEL_TYPE,
|
||||
TRACE_VIEW_DEFAULT_COLUMNS,
|
||||
} from 'container/LLMObservability/Explorer/constants';
|
||||
|
||||
import { PanelColumns } from '../types';
|
||||
import { isAiObservabilityExplorerRoute } from '../utils';
|
||||
|
||||
/**
|
||||
* Validates if a column is valid for the AI observability explorer
|
||||
* Needs a name; a column from another signal would fail the trace query
|
||||
*/
|
||||
const isValidAiColumn = (column: unknown): column is TelemetryFieldKey => {
|
||||
if (!column || typeof column !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const { name, signal } = column as { name?: unknown; signal?: unknown };
|
||||
return (
|
||||
typeof name === 'string' && name !== '' && (!signal || signal === 'traces')
|
||||
);
|
||||
};
|
||||
|
||||
// Keeps the valid columns of each panel; a panel left empty falls back to the
|
||||
// defaults, like one that was never stored.
|
||||
const toPanels = (value: unknown): PanelColumns => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return Object.entries(value).reduce<PanelColumns>(
|
||||
(panels, [panelType, columns]) => {
|
||||
if (!Array.isArray(columns)) {
|
||||
return panels;
|
||||
}
|
||||
const validColumns = columns.filter(isValidAiColumn);
|
||||
return validColumns.length
|
||||
? { ...panels, [panelType as PANEL_TYPES]: validColumns }
|
||||
: panels;
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
// Stored as { [panelType]: columns }.
|
||||
export const readStoredPanels = (): PanelColumns => {
|
||||
try {
|
||||
return toPanels(
|
||||
JSON.parse(
|
||||
getLocalStorageKey(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS) || '{}',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
// The url keeps the open panel's columns in `options.selectColumns`, and
|
||||
// `panelTypes` says which panel they belong to.
|
||||
const readUrlPanels = (): PanelColumns => {
|
||||
if (!isAiObservabilityExplorerRoute()) {
|
||||
return {};
|
||||
}
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
try {
|
||||
const options = JSON.parse(urlParams.get('options') || '{}');
|
||||
const panelTypeParam = urlParams.get(QueryParams.panelTypes);
|
||||
const panelType: PANEL_TYPES = panelTypeParam
|
||||
? JSON.parse(panelTypeParam)
|
||||
: DEFAULT_PANEL_TYPE;
|
||||
return toPanels({ [panelType]: options?.selectColumns });
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
type AiObservabilityLoaderConfig = {
|
||||
local: () => { columns: TelemetryFieldKey[] };
|
||||
url: () => { columns: TelemetryFieldKey[] };
|
||||
default: () => { columns: TelemetryFieldKey[] };
|
||||
priority: readonly ['local', 'url', 'default'];
|
||||
};
|
||||
|
||||
// --- AI OBSERVABILITY preferences loader config ---
|
||||
// Each panel keeps its own columns, so the config is built for one panel.
|
||||
const getAiObservabilityLoaderConfig = (
|
||||
panelType: PANEL_TYPES,
|
||||
): AiObservabilityLoaderConfig => ({
|
||||
local: () => ({ columns: readStoredPanels()[panelType] ?? [] }),
|
||||
url: () => ({ columns: readUrlPanels()[panelType] ?? [] }),
|
||||
// Only the trace panel keeps columns today.
|
||||
default: () => ({
|
||||
columns: panelType === PANEL_TYPES.TRACE ? TRACE_VIEW_DEFAULT_COLUMNS : [],
|
||||
}),
|
||||
priority: ['local', 'url', 'default'] as const,
|
||||
});
|
||||
|
||||
export default getAiObservabilityLoaderConfig;
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { defaultOptionsQuery } from 'container/OptionsMenu/constants';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
|
||||
import { PreferenceMode, Preferences } from '../types';
|
||||
import { readStoredPanels } from './aiObservabilityLoaderConfig';
|
||||
|
||||
// --- AI OBSERVABILITY preferences updater config ---
|
||||
const getAiObservabilityUpdaterConfig = (
|
||||
redirectWithOptionsData: (options: OptionsQuery) => void,
|
||||
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>,
|
||||
panelType: PANEL_TYPES | undefined,
|
||||
): {
|
||||
updateColumns: (
|
||||
newColumns: TelemetryFieldKey[],
|
||||
mode: string,
|
||||
columnsPanelType?: PANEL_TYPES,
|
||||
) => void;
|
||||
updateFormatting: () => void;
|
||||
} => ({
|
||||
updateColumns: (newColumns, mode, columnsPanelType = panelType): void => {
|
||||
if (!columnsPanelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === PreferenceMode.SAVED_VIEW) {
|
||||
setSavedViewPreferences({
|
||||
columns: newColumns,
|
||||
panelType: columnsPanelType,
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === PreferenceMode.DIRECT) {
|
||||
const panels = {
|
||||
...readStoredPanels(),
|
||||
[columnsPanelType]: newColumns,
|
||||
};
|
||||
redirectWithOptionsData({
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: newColumns,
|
||||
});
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS,
|
||||
JSON.stringify(panels),
|
||||
);
|
||||
}
|
||||
},
|
||||
updateFormatting: (): void => {}, // no-op for ai observability
|
||||
});
|
||||
|
||||
export default getAiObservabilityUpdaterConfig;
|
||||
@@ -7,7 +7,6 @@ import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { FormattingOptions } from '../types';
|
||||
import { isAiObservabilityExplorerRoute } from '../utils';
|
||||
|
||||
/**
|
||||
* Validates if a column is valid for Logs Explorer
|
||||
@@ -54,9 +53,6 @@ const logsLoaders = {
|
||||
columns: BaseAutocompleteData[];
|
||||
formatting: FormattingOptions;
|
||||
} => {
|
||||
if (isAiObservabilityExplorerRoute()) {
|
||||
return { columns: [], formatting: undefined } as any;
|
||||
}
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
try {
|
||||
const options = JSON.parse(urlParams.get('options') || '{}');
|
||||
|
||||
@@ -5,8 +5,6 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { isAiObservabilityExplorerRoute } from '../utils';
|
||||
|
||||
/**
|
||||
* Validates if a column is valid for Traces Explorer
|
||||
* Filters out Logs-specific columns that would cause query failures
|
||||
@@ -43,9 +41,6 @@ const tracesLoaders = {
|
||||
url: (): {
|
||||
columns: BaseAutocompleteData[];
|
||||
} => {
|
||||
if (isAiObservabilityExplorerRoute()) {
|
||||
return { columns: [] };
|
||||
}
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
try {
|
||||
const options = JSON.parse(urlParams.get('options') || '{}');
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
// Source pages that keep column and formatting preferences.
|
||||
// Only the logs and traces explorers keep column and formatting preferences.
|
||||
export const PREFERENCE_SOURCE_PAGES = [
|
||||
SavedviewtypesSourceDTO.logs,
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
SavedviewtypesSourceDTO.ai_observability,
|
||||
] as const;
|
||||
|
||||
// The AI observability panels that keep their own columns.
|
||||
export const AI_OBSERVABILITY_COLUMN_PANELS: PANEL_TYPES[] = [
|
||||
PANEL_TYPES.TRACE,
|
||||
];
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
PreferenceMode,
|
||||
} from 'providers/preferences/types';
|
||||
|
||||
import { useAiObservabilityPreferenceSync } from '../sync/useAiObservabilityPreferenceSync';
|
||||
import { usePreferenceSync } from '../sync/usePreferenceSync';
|
||||
|
||||
const PreferenceContext = createContext<PreferenceContextValue | undefined>(
|
||||
@@ -48,18 +47,12 @@ export function PreferenceContextProvider({
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
});
|
||||
|
||||
const aiObservabilitySlice = useAiObservabilityPreferenceSync({
|
||||
mode: savedViewId ? PreferenceMode.SAVED_VIEW : PreferenceMode.DIRECT,
|
||||
savedViewId: savedViewId || undefined,
|
||||
});
|
||||
|
||||
const value = useMemo<PreferenceContextValue>(
|
||||
() => ({
|
||||
[SavedviewtypesSourceDTO.logs]: logsSlice,
|
||||
[SavedviewtypesSourceDTO.traces]: tracesSlice,
|
||||
[SavedviewtypesSourceDTO.ai_observability]: aiObservabilitySlice,
|
||||
}),
|
||||
[logsSlice, tracesSlice, aiObservabilitySlice],
|
||||
[logsSlice, tracesSlice],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import getAiObservabilityLoaderConfig from '../configs/aiObservabilityLoaderConfig';
|
||||
import { Preferences } from '../types';
|
||||
|
||||
// The defaults of one panel, for source pages whose panels each keep their own
|
||||
// columns; undefined for the others.
|
||||
export function getPanelDefaults(
|
||||
sourcePage: SavedviewtypesSourceDTO,
|
||||
panelType: PANEL_TYPES,
|
||||
): Preferences | undefined {
|
||||
if (sourcePage !== SavedviewtypesSourceDTO.ai_observability) {
|
||||
return undefined;
|
||||
}
|
||||
return { ...getAiObservabilityLoaderConfig(panelType).default(), panelType };
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ensureAiRequiredColumns } from 'container/LLMObservability/Explorer/constants';
|
||||
import { ensureLogsRequiredColumns } from 'container/OptionsMenu/constants';
|
||||
import { has } from 'lodash-es';
|
||||
|
||||
import getAiObservabilityLoaderConfig from '../configs/aiObservabilityLoaderConfig';
|
||||
import logsLoaderConfig from '../configs/logsLoaderConfig';
|
||||
import tracesLoaderConfig from '../configs/tracesLoaderConfig';
|
||||
import { FormattingOptions, Preferences, PreferenceSourcePage } from '../types';
|
||||
@@ -69,37 +66,17 @@ function tracesPreferencesLoader(): {
|
||||
return { columns };
|
||||
}
|
||||
|
||||
// Loads the open panel only. Same priority as the other loaders: localStorage,
|
||||
// then the url, then the defaults.
|
||||
function aiObservabilityPreferencesLoader(
|
||||
panelType?: PANEL_TYPES,
|
||||
): Preferences {
|
||||
if (!panelType) {
|
||||
return { columns: [] };
|
||||
}
|
||||
const { columns } = preferencesLoader<Preferences>(
|
||||
getAiObservabilityLoaderConfig(panelType),
|
||||
);
|
||||
return { columns: ensureAiRequiredColumns(columns, panelType), panelType };
|
||||
}
|
||||
|
||||
const PREFERENCE_LOADERS: Record<
|
||||
PreferenceSourcePage,
|
||||
(panelType?: PANEL_TYPES) => Preferences
|
||||
> = {
|
||||
const PREFERENCE_LOADERS: Record<PreferenceSourcePage, () => Preferences> = {
|
||||
[SavedviewtypesSourceDTO.logs]: logsPreferencesLoader,
|
||||
[SavedviewtypesSourceDTO.traces]: tracesPreferencesLoader,
|
||||
[SavedviewtypesSourceDTO.ai_observability]: aiObservabilityPreferencesLoader,
|
||||
};
|
||||
|
||||
export function usePreferenceLoader({
|
||||
sourcePage,
|
||||
panelType,
|
||||
reSync,
|
||||
setReSync,
|
||||
}: {
|
||||
sourcePage: PreferenceSourcePage;
|
||||
panelType?: PANEL_TYPES;
|
||||
reSync: boolean;
|
||||
setReSync: (value: boolean) => void;
|
||||
}): {
|
||||
@@ -108,24 +85,17 @@ export function usePreferenceLoader({
|
||||
error: Error | null;
|
||||
} {
|
||||
const loadSlicePreferences = PREFERENCE_LOADERS[sourcePage];
|
||||
const [preferences, setPreferences] = useState<Preferences | null>(() =>
|
||||
loadSlicePreferences(panelType),
|
||||
const [preferences, setPreferences] = useState<Preferences | null>(
|
||||
loadSlicePreferences,
|
||||
);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Switching panels loads the new panel's columns in the same render.
|
||||
const [loadedPanelType, setLoadedPanelType] = useState(panelType);
|
||||
if (loadedPanelType !== panelType) {
|
||||
setLoadedPanelType(panelType);
|
||||
setPreferences(loadSlicePreferences(panelType));
|
||||
}
|
||||
|
||||
useEffect((): void => {
|
||||
function loadPreferences(): void {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setPreferences(loadSlicePreferences(panelType));
|
||||
setPreferences(loadSlicePreferences());
|
||||
} catch (e) {
|
||||
setError(e as Error);
|
||||
} finally {
|
||||
@@ -140,7 +110,7 @@ export function usePreferenceLoader({
|
||||
if (reSync) {
|
||||
loadPreferences();
|
||||
}
|
||||
}, [loadSlicePreferences, panelType, reSync, setReSync]);
|
||||
}, [loadSlicePreferences, reSync, setReSync]);
|
||||
|
||||
return { preferences, loading: false, error };
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { DEFAULT_PANEL_TYPE } from 'container/LLMObservability/Explorer/constants';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import { PreferenceMode, PreferenceSlice } from '../types';
|
||||
import { usePreferenceSync } from './usePreferenceSync';
|
||||
|
||||
export function useAiObservabilityPreferenceSync({
|
||||
mode,
|
||||
savedViewId,
|
||||
}: {
|
||||
mode: PreferenceMode;
|
||||
savedViewId: string | undefined;
|
||||
}): PreferenceSlice {
|
||||
const params = useUrlQuery();
|
||||
|
||||
let panelType = DEFAULT_PANEL_TYPE;
|
||||
const panelTypesParam = params.get(QueryParams.panelTypes);
|
||||
if (panelTypesParam) {
|
||||
try {
|
||||
panelType = JSON.parse(panelTypesParam);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return usePreferenceSync({
|
||||
mode,
|
||||
savedViewId,
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getViewColumnsAndFormatting } from 'container/SavedViews/utils/getViewColumnsAndFormatting';
|
||||
|
||||
import { getPanelDefaults } from '../loader/getPanelDefaults';
|
||||
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
|
||||
import {
|
||||
FormattingOptions,
|
||||
@@ -18,22 +16,16 @@ import { usePreferenceUpdater } from '../updater/usePreferenceUpdater';
|
||||
export function usePreferenceSync({
|
||||
mode,
|
||||
sourcePage,
|
||||
panelType,
|
||||
savedViewId,
|
||||
}: {
|
||||
mode: PreferenceMode;
|
||||
sourcePage: PreferenceSourcePage;
|
||||
// The open panel, for source pages whose panels each keep their own columns.
|
||||
panelType?: PANEL_TYPES;
|
||||
savedViewId: string | undefined;
|
||||
}): {
|
||||
preferences: Preferences | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
updateColumns: (
|
||||
newColumns: TelemetryFieldKey[],
|
||||
panelType?: PANEL_TYPES,
|
||||
) => void;
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
} {
|
||||
const isSavedViewMode = mode === PreferenceMode.SAVED_VIEW && !!savedViewId;
|
||||
@@ -66,36 +58,22 @@ export function usePreferenceSync({
|
||||
const [reSync, setReSync] = useState(false);
|
||||
const { preferences, loading, error } = usePreferenceLoader({
|
||||
sourcePage,
|
||||
panelType,
|
||||
reSync,
|
||||
setReSync,
|
||||
});
|
||||
|
||||
const { updateColumns, updateFormatting } = usePreferenceUpdater({
|
||||
sourcePage,
|
||||
panelType,
|
||||
mode,
|
||||
preferences,
|
||||
setReSync,
|
||||
setSavedViewPreferences,
|
||||
});
|
||||
|
||||
// A view saved on another panel has no columns for the open one, so that
|
||||
// panel starts from its defaults.
|
||||
const viewPreferences = useMemo(
|
||||
() =>
|
||||
savedViewPreferences?.panelType &&
|
||||
panelType &&
|
||||
savedViewPreferences.panelType !== panelType
|
||||
? (getPanelDefaults(sourcePage, panelType) ?? savedViewPreferences)
|
||||
: savedViewPreferences,
|
||||
[savedViewPreferences, panelType, sourcePage],
|
||||
);
|
||||
|
||||
return {
|
||||
preferences:
|
||||
mode === PreferenceMode.SAVED_VIEW && savedViewId
|
||||
? viewPreferences
|
||||
? savedViewPreferences
|
||||
: preferences,
|
||||
loading,
|
||||
error,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
|
||||
|
||||
import { PREFERENCE_SOURCE_PAGES } from '../constants';
|
||||
@@ -11,17 +10,11 @@ export enum PreferenceMode {
|
||||
|
||||
export type PreferenceSourcePage = (typeof PREFERENCE_SOURCE_PAGES)[number];
|
||||
|
||||
export type PanelColumns = Partial<Record<PANEL_TYPES, TelemetryFieldKey[]>>;
|
||||
|
||||
export interface PreferenceSlice {
|
||||
preferences: Preferences | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
// `panelType` defaults to the open panel.
|
||||
updateColumns: (
|
||||
newColumns: TelemetryFieldKey[],
|
||||
panelType?: PANEL_TYPES,
|
||||
) => void;
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
}
|
||||
|
||||
@@ -40,7 +33,4 @@ export interface FormattingOptions {
|
||||
export interface Preferences {
|
||||
columns: TelemetryFieldKey[];
|
||||
formatting?: FormattingOptions;
|
||||
// Set by source pages whose panels each keep their own columns: the panel
|
||||
// these columns belong to.
|
||||
panelType?: PANEL_TYPES;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
defaultOptionsQuery,
|
||||
URL_OPTIONS,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
|
||||
import getAiObservabilityUpdaterConfig from '../configs/aiObservabilityUpdaterConfig';
|
||||
import getLogsUpdaterConfig from '../configs/logsUpdaterConfig';
|
||||
import getTracesUpdaterConfig from '../configs/tracesUpdaterConfig';
|
||||
import { FormattingOptions, Preferences, PreferenceSourcePage } from '../types';
|
||||
@@ -18,15 +16,10 @@ const getUpdaterConfig = (
|
||||
preferences: Preferences | null,
|
||||
redirectWithOptionsData: (options: OptionsQuery) => void,
|
||||
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>,
|
||||
panelType: PANEL_TYPES | undefined,
|
||||
): Record<
|
||||
PreferenceSourcePage,
|
||||
{
|
||||
updateColumns: (
|
||||
newColumns: TelemetryFieldKey[],
|
||||
mode: string,
|
||||
panelType?: PANEL_TYPES,
|
||||
) => void;
|
||||
updateColumns: (newColumns: TelemetryFieldKey[], mode: string) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions, mode: string) => void;
|
||||
}
|
||||
> => ({
|
||||
@@ -39,32 +32,22 @@ const getUpdaterConfig = (
|
||||
redirectWithOptionsData,
|
||||
setSavedViewPreferences,
|
||||
),
|
||||
[SavedviewtypesSourceDTO.ai_observability]: getAiObservabilityUpdaterConfig(
|
||||
redirectWithOptionsData,
|
||||
setSavedViewPreferences,
|
||||
panelType,
|
||||
),
|
||||
});
|
||||
|
||||
export function usePreferenceUpdater({
|
||||
sourcePage,
|
||||
panelType,
|
||||
mode,
|
||||
preferences,
|
||||
setReSync,
|
||||
setSavedViewPreferences,
|
||||
}: {
|
||||
sourcePage: PreferenceSourcePage;
|
||||
panelType?: PANEL_TYPES;
|
||||
mode: string;
|
||||
preferences: Preferences | null;
|
||||
setReSync: Dispatch<SetStateAction<boolean>>;
|
||||
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>;
|
||||
}): {
|
||||
updateColumns: (
|
||||
newColumns: TelemetryFieldKey[],
|
||||
panelType?: PANEL_TYPES,
|
||||
) => void;
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
} {
|
||||
const { redirectWithQuery: redirectWithOptionsData } =
|
||||
@@ -73,15 +56,11 @@ export function usePreferenceUpdater({
|
||||
preferences,
|
||||
redirectWithOptionsData,
|
||||
setSavedViewPreferences,
|
||||
panelType,
|
||||
)[sourcePage];
|
||||
|
||||
return {
|
||||
updateColumns: (
|
||||
newColumns: TelemetryFieldKey[],
|
||||
columnsPanelType?: PANEL_TYPES,
|
||||
): void => {
|
||||
updater.updateColumns(newColumns, mode, columnsPanelType);
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]): void => {
|
||||
updater.updateColumns(newColumns, mode);
|
||||
setReSync(true);
|
||||
},
|
||||
updateFormatting: (newFormatting: FormattingOptions): void => {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import {
|
||||
AI_OBSERVABILITY_COLUMN_PANELS,
|
||||
PREFERENCE_SOURCE_PAGES,
|
||||
} from './constants';
|
||||
import { PREFERENCE_SOURCE_PAGES } from './constants';
|
||||
import { PreferenceSourcePage } from './types';
|
||||
|
||||
export function isPreferenceSourcePage(
|
||||
@@ -16,21 +10,3 @@ export function isPreferenceSourcePage(
|
||||
PREFERENCE_SOURCE_PAGES as readonly SavedviewtypesSourceDTO[]
|
||||
).includes(sourcePage);
|
||||
}
|
||||
|
||||
export function hasPreferences(
|
||||
sourcePage: SavedviewtypesSourceDTO,
|
||||
panelType: PANEL_TYPES,
|
||||
): boolean {
|
||||
if (sourcePage === SavedviewtypesSourceDTO.ai_observability) {
|
||||
return AI_OBSERVABILITY_COLUMN_PANELS.includes(panelType);
|
||||
}
|
||||
return isPreferenceSourcePage(sourcePage);
|
||||
}
|
||||
|
||||
// The url `options` param belongs to the explorer that is open, so a link to
|
||||
// one explorer never seeds another explorer's slice.
|
||||
export function isAiObservabilityExplorerRoute(): boolean {
|
||||
return (
|
||||
window.location.pathname === withBasePath(ROUTES.AI_OBSERVABILITY_EXPLORER)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -16,7 +13,7 @@ import (
|
||||
func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/stats", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryStats, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryStats),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryStats",
|
||||
Tags: []string{"rules"},
|
||||
@@ -27,21 +24,13 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/timeline", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryTimeline, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryTimeline),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryTimeline",
|
||||
Tags: []string{"rules"},
|
||||
@@ -51,27 +40,14 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
Response: new(rulestatehistorytypes.GettableRuleStateTimeline),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}, handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/top_contributors", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryContributors, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryContributors),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryTopContributors",
|
||||
Tags: []string{"rules"},
|
||||
@@ -81,27 +57,14 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
Response: new([]rulestatehistorytypes.GettableRuleStateHistoryContributor),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}, handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/filter_keys", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryFilterKeys, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryFilterKeys),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryFilterKeys",
|
||||
Tags: []string{"rules"},
|
||||
@@ -111,27 +74,14 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
Response: new(telemetrytypes.GettableFieldKeys),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, buildFieldScopes(coretypes.VerbList)...)),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}, handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
Resources: ruler.NewRuleQueryFieldResources(provider.rulerService),
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/filter_values", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryFilterValues, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryFilterValues),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryFilterValues",
|
||||
Tags: []string{"rules"},
|
||||
@@ -141,27 +91,14 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
Response: new(telemetrytypes.GettableFieldValues),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, buildFieldScopes(coretypes.VerbRead)...)),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}, handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
Resources: ruler.NewRuleQueryFieldResources(provider.rulerService),
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/overall_status", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryOverallStatus, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryOverallStatus),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryOverallStatus",
|
||||
Tags: []string{"rules"},
|
||||
@@ -172,26 +109,10 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFieldScopes(verb coretypes.Verb) []string {
|
||||
return []string{
|
||||
coretypes.ResourceMetaResourceLogsField.Scope(verb),
|
||||
coretypes.ResourceMetaResourceTracesField.Scope(verb),
|
||||
coretypes.ResourceMetaResourceMetricsField.Scope(verb),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@ import (
|
||||
// NewValidReferences / NewSuggestionsOnLevenshteinDistance to phrase the
|
||||
// "valid <noun> are ..." list consistently across the codebase.
|
||||
const (
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounRequestTypes = "request types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -182,8 +182,7 @@ func postProcessBuilderQuery[T any](
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
) *qbtypes.Result {
|
||||
|
||||
// the statement's top-N already skipped offset groups by their whole-window value; only reorder here
|
||||
result = q.applySeriesLimit(result, query.Limit, 0, query.Order)
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Order)
|
||||
|
||||
// Apply functions
|
||||
if len(query.Functions) > 0 {
|
||||
@@ -235,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 {
|
||||
@@ -247,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)
|
||||
}
|
||||
@@ -264,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 {
|
||||
@@ -297,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
|
||||
@@ -306,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,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:
|
||||
@@ -371,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:
|
||||
@@ -652,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}
|
||||
|
||||
|
||||
@@ -2,13 +2,10 @@ package ruler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -162,55 +159,6 @@ func orgIDFromContext(ctx context.Context) (valuer.UUID, error) {
|
||||
return valuer.NewUUID(claims.OrgID)
|
||||
}
|
||||
|
||||
func NewRuleQueryTelemetryResources(rulerService Ruler) coretypes.ResourceExtractor {
|
||||
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
body, err := fetchStoredCompositeQuery(ec, rulerService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return querybuilder.QueryRangeResources(coretypes.ExtractorContext{Request: ec.Request, RequestBody: body})
|
||||
}
|
||||
}
|
||||
|
||||
func NewRuleQueryFieldResources(rulerService Ruler) coretypes.ResourceExtractor {
|
||||
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
body, err := fetchStoredCompositeQuery(ec, rulerService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mapFieldResources(body)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchStoredCompositeQuery(ec coretypes.ExtractorContext, rulerService Ruler) ([]byte, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ruleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := rulerService.GetRule(ec.Request.Context(), ruleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(map[string]any{"compositeQuery": stored.RuleCondition.CompositeQuery})
|
||||
}
|
||||
|
||||
func mapFieldResources(body []byte) ([]coretypes.ResourceWithID, error) {
|
||||
telemetry, err := querybuilder.QueryRangeResources(coretypes.ExtractorContext{RequestBody: body})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ruletypes.NewFieldResources(telemetry)
|
||||
}
|
||||
|
||||
func diffLinks(stored, incoming []string) (attached, detached []string) {
|
||||
storedSet := make(map[string]struct{}, len(stored))
|
||||
for _, id := range stored {
|
||||
|
||||
@@ -216,101 +216,3 @@ func TestDowntimeDiffExtractorScopesStoreByOrg(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapFieldResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantKinds []coretypes.Kind
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Builder_Logs_LogsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "Builder_Traces_TracesField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"traces"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindTracesField},
|
||||
},
|
||||
{
|
||||
name: "Builder_Metrics_MetricsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "SubQuery_Metrics_MetricsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_sub_query","spec":{"signal":"metrics"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "BuilderAI_TracesField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindTracesField},
|
||||
},
|
||||
{
|
||||
name: "PromQL_MetricsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "ClickHouseSQL_AllThreeFields",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField, coretypes.KindTracesField, coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "Builder_AuditLogs_LogsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "Builder_MeterMetrics_MetricsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics","source":"meter"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "TwoBuilderSameSignal_Deduped",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics"}},{"type":"builder_query","spec":{"signal":"metrics"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "FormulaAlongsideBuilder_FormulaSkipped",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}},{"type":"builder_formula","spec":{}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "NoQueries_Errors",
|
||||
body: `{"compositeQuery":{"queries":[]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownSignal_Errors",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"events"}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownQueryType_Errors",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"graphql","spec":{}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
refs, err := mapFieldResources([]byte(testCase.body))
|
||||
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
kinds := make([]coretypes.Kind, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
kinds = append(kinds, ref.Resource.Kind())
|
||||
}
|
||||
assert.Equal(t, testCase.wantKinds, kinds)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,10 +536,6 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
|
||||
sb.Limit(query.Limit)
|
||||
}
|
||||
|
||||
if query.Offset > 0 {
|
||||
sb.Offset(query.Offset)
|
||||
}
|
||||
|
||||
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||
|
||||
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||
|
||||
@@ -707,10 +707,6 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
sb.Limit(query.Limit)
|
||||
}
|
||||
|
||||
if query.Offset > 0 {
|
||||
sb.Offset(query.Offset)
|
||||
}
|
||||
|
||||
combinedArgs := allAggChArgs
|
||||
|
||||
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||
|
||||
@@ -722,9 +722,6 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
|
||||
if query.Limit > 0 {
|
||||
sb.Limit(query.Limit)
|
||||
}
|
||||
if query.Offset > 0 {
|
||||
sb.Offset(query.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -771,9 +768,6 @@ func outerLimitSQL(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], tr
|
||||
sb.OrderBy("__result_0 DESC")
|
||||
}
|
||||
sb.Limit(query.Limit)
|
||||
if query.Offset > 0 {
|
||||
sb.Offset(query.Offset)
|
||||
}
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
|
||||
@@ -835,10 +835,6 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
sb.Limit(query.Limit)
|
||||
}
|
||||
|
||||
if query.Offset > 0 {
|
||||
sb.Offset(query.Offset)
|
||||
}
|
||||
|
||||
combinedArgs := allAggChArgs
|
||||
|
||||
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||
|
||||
@@ -841,10 +841,6 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
sb.Limit(b.operator.Limit)
|
||||
}
|
||||
|
||||
if b.operator.Offset > 0 {
|
||||
sb.Offset(b.operator.Offset)
|
||||
}
|
||||
|
||||
combinedArgs := allAggChArgs
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||
@@ -926,10 +922,6 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
|
||||
sb.Limit(b.operator.Limit)
|
||||
}
|
||||
|
||||
if b.operator.Offset > 0 {
|
||||
sb.Offset(b.operator.Offset)
|
||||
}
|
||||
|
||||
combinedArgs := allAggChArgs
|
||||
|
||||
// Add HAVING clause if specified
|
||||
|
||||
@@ -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. Requires limit. 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. Requires limit."`
|
||||
|
||||
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,18 +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,
|
||||
)
|
||||
}
|
||||
|
||||
if f.Offset > 0 && f.Limit == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset requires a limit")
|
||||
}
|
||||
|
||||
// Validate expression is parseable
|
||||
if _, err := govaluate.NewEvaluableExpressionWithFunctions(f.Expression, EvalFuncs()); err != nil {
|
||||
return errors.NewInvalidInputf(
|
||||
@@ -118,6 +104,10 @@ func (f QueryBuilderFormula) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
if f.Limit < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be non-negative, got %d", f.Limit)
|
||||
}
|
||||
|
||||
// Validate functions if present
|
||||
for i, fn := range f.Functions {
|
||||
if err := fn.Validate(); err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ func (r *RequestType) UnmarshalJSON(data []byte) error {
|
||||
*r = v
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`, `heatmap`")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q", s).
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. Requires limit."`
|
||||
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."`
|
||||
@@ -272,10 +272,6 @@ func (q *QueryBuilderTraceOperator) ValidatePagination() error {
|
||||
)
|
||||
}
|
||||
|
||||
if q.Offset > 0 && q.Limit == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset requires a limit")
|
||||
}
|
||||
|
||||
// For production use, you might want to enforce maximum limits
|
||||
if q.Limit > 10000 {
|
||||
return errors.WrapInvalidInputf(
|
||||
|
||||
@@ -437,10 +437,6 @@ func (q *QueryBuilderQuery[T]) validateLimitAndPagination(cfg validationConfig)
|
||||
)
|
||||
}
|
||||
|
||||
if q.Offset > 0 && q.Limit == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset requires a limit")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -499,6 +495,18 @@ func (q *QueryBuilderQuery[T]) validateOrderBy(cfg validationConfig) error {
|
||||
return q.validateOrderByForAggregation()
|
||||
}
|
||||
|
||||
// a raw order key resolves like a select field, so only an empty one is
|
||||
// invalid before resolution
|
||||
for i, order := range q.Order {
|
||||
if order.Key.Name == "" {
|
||||
orderID := fmt.Sprintf("order by clause #%d", i+1)
|
||||
if q.Name != "" {
|
||||
orderID = fmt.Sprintf("order by clause #%d in query '%s'", i+1, q.Name)
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid empty key name for %s", orderID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -586,19 +594,10 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate request type
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid request type: %s",
|
||||
r.RequestType,
|
||||
).WithAdditional(
|
||||
"Valid request types are: raw, timeseries, scalar, heatmap",
|
||||
)
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return err
|
||||
}
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
|
||||
if err := r.validateHeatmap(); err != nil {
|
||||
return err
|
||||
@@ -639,14 +638,10 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []ValidationOption
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
opts = GetValidationOptions(r.RequestType)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
|
||||
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap")
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := GetValidationOptions(r.RequestType)
|
||||
|
||||
if err := r.validateHeatmap(); err != nil {
|
||||
return nil, err
|
||||
@@ -703,6 +698,20 @@ func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
|
||||
return validateQueryEnvelope(e, opts...)
|
||||
}
|
||||
|
||||
// Enum is the one list of request types a query can use, so the spec and the
|
||||
// suggestions agree with what is accepted.
|
||||
func (r *QueryRangeRequest) validateRequestType() error {
|
||||
if slices.Contains(RequestType{}.Enum(), any(r.RequestType)) {
|
||||
return nil
|
||||
}
|
||||
if r.RequestType == RequestTypeUnknown {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "requestType is required").
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request type %q is not supported", r.RequestType.StringValue()).
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) validateTimeRange() error {
|
||||
if r.RequestType == RequestTypeRawStream {
|
||||
return nil
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
@@ -521,6 +526,28 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "expression cannot be blank",
|
||||
},
|
||||
{
|
||||
name: "Formula_NegativeLimit_Rejected",
|
||||
request: QueryRangeRequest{
|
||||
Start: 1640995200000,
|
||||
End: 1640998800000,
|
||||
RequestType: RequestTypeTimeSeries,
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeFormula,
|
||||
Spec: QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A * 2",
|
||||
Limit: -5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid formula 'F1': limit must be non-negative, got -5",
|
||||
},
|
||||
{
|
||||
name: "promql with empty query should return error",
|
||||
request: QueryRangeRequest{
|
||||
@@ -696,6 +723,31 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "raw request type is not supported for metric queries",
|
||||
},
|
||||
{
|
||||
name: "Raw_EmptyOrderKey_Rejected",
|
||||
request: QueryRangeRequest{
|
||||
Start: 1640995200000,
|
||||
End: 1640998800000,
|
||||
RequestType: RequestTypeRaw,
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeBuilder,
|
||||
Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Order: []OrderBy{
|
||||
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp"}}, Direction: OrderDirectionDesc},
|
||||
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ""}}, Direction: OrderDirectionAsc},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid query 'B': invalid empty key name for order by clause #2 in query 'B'",
|
||||
},
|
||||
{
|
||||
name: "timeseries request with group by timestamp should return error",
|
||||
request: QueryRangeRequest{
|
||||
@@ -1407,6 +1459,44 @@ func TestRequestType_IsAggregation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRangeRequest_ValidateRequestType(t *testing.T) {
|
||||
validRequestTypes := []string{"valid request types are `scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `heatmap`"}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "Missing_Required",
|
||||
requestType: "",
|
||||
message: "requestType is required",
|
||||
},
|
||||
{
|
||||
name: "Timeseries_Unknown",
|
||||
requestType: `"requestType": "timeseries",`,
|
||||
message: `unknown request type "timeseries"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
body := fmt.Sprintf(`{"schemaVersion": "v1", "start": 1640995200000, "end": 1640998800000, %s "compositeQuery": {"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs"}}]}}`, testCase.requestType)
|
||||
|
||||
var request QueryRangeRequest
|
||||
err := json.Unmarshal([]byte(body), &request)
|
||||
if err == nil {
|
||||
err = request.Validate()
|
||||
}
|
||||
require.Error(t, err)
|
||||
|
||||
got := errors.AsJSON(err)
|
||||
assert.Equal(t, testCase.message, got.Message)
|
||||
assert.Equal(t, validRequestTypes, got.Suggestions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonAggregationFieldsSkipped(t *testing.T) {
|
||||
// Fields that only apply to aggregation queries (groupBy, having, aggregations)
|
||||
// should be silently skipped for non-aggregation request types.
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
)
|
||||
|
||||
func NewFieldResources(telemetry []coretypes.ResourceWithID) ([]coretypes.ResourceWithID, error) {
|
||||
seen := make(map[string]struct{}, 3)
|
||||
resources := make([]coretypes.ResourceWithID, 0, 3)
|
||||
for _, ref := range telemetry {
|
||||
field, err := fieldResource(ref.Resource.Kind())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kind := field.Kind().String()
|
||||
if _, ok := seen[kind]; ok {
|
||||
continue
|
||||
}
|
||||
seen[kind] = struct{}{}
|
||||
resources = append(resources, coretypes.ResourceWithID{Resource: field})
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func fieldResource(kind coretypes.Kind) (coretypes.Resource, error) {
|
||||
switch kind {
|
||||
case coretypes.KindLogs, coretypes.KindAuditLogs:
|
||||
return coretypes.ResourceMetaResourceLogsField, nil
|
||||
case coretypes.KindTraces:
|
||||
return coretypes.ResourceMetaResourceTracesField, nil
|
||||
case coretypes.KindMetrics, coretypes.KindMeterMetrics:
|
||||
return coretypes.ResourceMetaResourceMetricsField, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "no field resource for telemetry kind %q", kind.String())
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewFieldResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
telemetry []coretypes.Resource
|
||||
wantKinds []coretypes.Kind
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Logs_LogsField",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceLogs},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "AuditLogs_LogsField",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceAuditLogs},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "Traces_TracesField",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceTraces},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindTracesField},
|
||||
},
|
||||
{
|
||||
name: "Metrics_MetricsField",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceMetrics},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "MeterMetrics_MetricsField",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceMeterMetrics},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "MetricsAndMeterMetrics_Deduped",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceMetrics, coretypes.ResourceTelemetryResourceMeterMetrics},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "AllSignals_AllThreeFields",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceLogs, coretypes.ResourceTelemetryResourceTraces, coretypes.ResourceTelemetryResourceMetrics},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField, coretypes.KindTracesField, coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "Empty_Empty",
|
||||
wantKinds: []coretypes.Kind{},
|
||||
},
|
||||
{
|
||||
name: "FieldKind_Errors",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceMetaResourceLogsField},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
telemetry := make([]coretypes.ResourceWithID, 0, len(testCase.telemetry))
|
||||
for _, resource := range testCase.telemetry {
|
||||
telemetry = append(telemetry, coretypes.ResourceWithID{Resource: resource})
|
||||
}
|
||||
|
||||
resources, err := NewFieldResources(telemetry)
|
||||
|
||||
if testCase.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
kinds := make([]coretypes.Kind, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
kinds = append(kinds, resource.Resource.Kind())
|
||||
}
|
||||
assert.Equal(t, testCase.wantKinds, kinds)
|
||||
})
|
||||
}
|
||||
}
|
||||
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,145 +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(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,
|
||||
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]
|
||||
|
||||
|
||||
def test_logs_time_series_offset_pages_are_disjoint(
|
||||
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)
|
||||
# steady: 2 logs in each of 5 minutes, 10 in all. spike: 6 logs in one minute.
|
||||
# steady has the larger total, spike the larger single-minute count.
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(minutes=minute, seconds=i + 1),
|
||||
resources={"service.name": "steady"},
|
||||
body=f"steady log {minute}-{i}",
|
||||
)
|
||||
for minute in range(1, 6)
|
||||
for i in range(2)
|
||||
]
|
||||
+ [
|
||||
Logs(
|
||||
timestamp=now - timedelta(minutes=1, seconds=i + 1),
|
||||
resources={"service.name": "spike"},
|
||||
body=f"spike log {i}",
|
||||
)
|
||||
for i in range(6)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
pages = []
|
||||
for offset in (0, 1):
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=10)).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=1,
|
||||
offset=offset,
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
pages.append(
|
||||
[
|
||||
(
|
||||
next(label["value"] for label in series["labels"] if label["key"]["name"] == "service.name"),
|
||||
sorted(point["value"] for point in series["values"]),
|
||||
)
|
||||
for series in get_all_series(response.json(), "A")
|
||||
]
|
||||
)
|
||||
|
||||
# Pages follow the total count over the window: steady (10) first, then spike (6).
|
||||
assert pages == [[("steady", [2, 2, 2, 2, 2])], [("spike", [6])]]
|
||||
@@ -1,74 +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(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,
|
||||
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,264 +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(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,
|
||||
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(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,
|
||||
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"
|
||||
|
||||
|
||||
def test_offset_without_limit_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 requires a limit"
|
||||
@@ -1,224 +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(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,
|
||||
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]]
|
||||
|
||||
|
||||
def test_traces_time_series_offset_pages_are_disjoint(
|
||||
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)
|
||||
# steady: 2 spans in each of 5 minutes, 10 in all. spike: 6 spans in one minute.
|
||||
# steady has the larger total, spike the larger single-minute count.
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(minutes=minute, 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": "steady"},
|
||||
name=f"steady span {minute}-{i}",
|
||||
)
|
||||
for minute in range(1, 6)
|
||||
for i in range(2)
|
||||
]
|
||||
+ [
|
||||
Traces(
|
||||
timestamp=now - timedelta(minutes=1, 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": "spike"},
|
||||
name=f"spike span {i}",
|
||||
)
|
||||
for i in range(6)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
pages = []
|
||||
for offset in (0, 1):
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=10)).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=1,
|
||||
offset=offset,
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
pages.append(
|
||||
[
|
||||
(
|
||||
next(label["value"] for label in series["labels"] if label["key"]["name"] == "service.name"),
|
||||
sorted(point["value"] for point in series["values"]),
|
||||
)
|
||||
for series in get_all_series(response.json(), "A")
|
||||
]
|
||||
)
|
||||
|
||||
# Pages follow the total count over the window: steady (10) first, then spike (6).
|
||||
assert pages == [[("steady", [2, 2, 2, 2, 2])], [("spike", [6])]]
|
||||
@@ -1,5 +1,4 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
@@ -899,156 +898,6 @@ def test_downtime_widening_to_all_rules_forbidden_without_wildcard_attach(
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_history_labels_need_telemetry_and_field_grants(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={"description": "", "transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS + [transaction_group("read", "metaresource", "rule", ["*"])]},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
|
||||
|
||||
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
|
||||
start_ms = end_ms - 30 * 60 * 1000
|
||||
history_url = f"{V2_RULES_URL}/{target_id}/history"
|
||||
rule_scoped = (
|
||||
(f"{history_url}/stats", {"start": start_ms, "end": end_ms}),
|
||||
(f"{history_url}/overall_status", {"start": start_ms, "end": end_ms}),
|
||||
)
|
||||
rule_and_telemetry_scoped = (
|
||||
(f"{history_url}/timeline", {"start": start_ms, "end": end_ms}),
|
||||
(f"{history_url}/top_contributors", {"start": start_ms, "end": end_ms}),
|
||||
)
|
||||
field_scoped = (
|
||||
(f"{history_url}/filter_keys", {"startUnixMilli": start_ms, "endUnixMilli": end_ms}),
|
||||
(f"{history_url}/filter_values", {"startUnixMilli": start_ms, "endUnixMilli": end_ms, "name": "severity"}),
|
||||
)
|
||||
label_scoped = rule_and_telemetry_scoped + field_scoped
|
||||
|
||||
for path, params in rule_scoped:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"GET {path}: expected 200, got {response.status_code}: {response.text}"
|
||||
|
||||
for path, params in label_scoped:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"GET {path}: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS
|
||||
+ [
|
||||
transaction_group("read", "telemetryresource", "metrics", ["builder_query/*"]),
|
||||
transaction_group("list", "metaresource", "metrics-field", ["*"]),
|
||||
transaction_group("read", "metaresource", "metrics-field", ["*"]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
for path, params in label_scoped:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"GET {path}: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS
|
||||
+ [
|
||||
transaction_group("read", "metaresource", "rule", ["*"]),
|
||||
transaction_group("read", "telemetryresource", "logs", ["builder_query/*"]),
|
||||
transaction_group("list", "metaresource", "metrics-field", ["*"]),
|
||||
transaction_group("read", "metaresource", "metrics-field", ["*"]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
for path, params in rule_and_telemetry_scoped:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"GET {path}: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
for path, params in field_scoped:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"GET {path}: expected 200, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS
|
||||
+ [
|
||||
transaction_group("read", "metaresource", "rule", ["*"]),
|
||||
transaction_group("read", "telemetryresource", "metrics", ["builder_query/*"]),
|
||||
transaction_group("list", "metaresource", "metrics-field", ["*"]),
|
||||
transaction_group("read", "metaresource", "metrics-field", ["*"]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
for path, params in label_scoped:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"GET {path}: expected 200, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_alert_authz_cleanup(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
|
||||
Reference in New Issue
Block a user