mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-11 04:30:39 +01:00
Compare commits
10 Commits
issue_nerv
...
test/route
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
179e607b6b | ||
|
|
8ad3410c34 | ||
|
|
534c06d781 | ||
|
|
ee4dde1736 | ||
|
|
ec4bb0dff9 | ||
|
|
b19ee361df | ||
|
|
490a2fccc7 | ||
|
|
de635f8baf | ||
|
|
f33b887426 | ||
|
|
5cafe15b17 |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1,3 +1,4 @@
|
||||
*.css linguist-detectable=false
|
||||
*.stories.mocks.tsx linguist-generated=true
|
||||
**/__story_mockdata__/** linguist-generated=true
|
||||
tests/e2e/drivers/** linguist-generated=true
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -68,7 +68,6 @@ jobs:
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- tracedetail
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -9844,53 +9844,6 @@ components:
|
||||
required:
|
||||
- aggregations
|
||||
type: object
|
||||
SpantypesGettableTraceSummary:
|
||||
properties:
|
||||
ai:
|
||||
$ref: '#/components/schemas/SpantypesTraceAISummary'
|
||||
endTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
hasMissingSpans:
|
||||
type: boolean
|
||||
rootServiceEntryPoint:
|
||||
type: string
|
||||
rootServiceName:
|
||||
type: string
|
||||
rootSpanStatusCode:
|
||||
type: string
|
||||
startTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalErrorSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- startTimestampMillis
|
||||
- endTimestampMillis
|
||||
- rootServiceName
|
||||
- rootServiceEntryPoint
|
||||
- rootSpanStatusCode
|
||||
- totalSpansCount
|
||||
- totalErrorSpansCount
|
||||
- hasMissingSpans
|
||||
type: object
|
||||
SpantypesGettableTraceThread:
|
||||
properties:
|
||||
nextCursor:
|
||||
type: string
|
||||
prevCursor:
|
||||
type: string
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesThreadSpan'
|
||||
type: array
|
||||
required:
|
||||
- spans
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
@@ -10205,94 +10158,6 @@ components:
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesThreadSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
duration_nano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
events:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
type: array
|
||||
has_error:
|
||||
type: boolean
|
||||
kind_string:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
references:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesOtelSpanRef'
|
||||
type: array
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
span_id:
|
||||
type: string
|
||||
status_code_string:
|
||||
type: string
|
||||
status_message:
|
||||
type: string
|
||||
time_unix:
|
||||
minimum: 0
|
||||
type: integer
|
||||
trace_id:
|
||||
type: string
|
||||
required:
|
||||
- span_id
|
||||
- trace_id
|
||||
- parent_span_id
|
||||
- name
|
||||
- kind_string
|
||||
- time_unix
|
||||
- duration_nano
|
||||
- has_error
|
||||
- status_code_string
|
||||
- status_message
|
||||
- resource
|
||||
- attributes
|
||||
- events
|
||||
- references
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
tokens:
|
||||
$ref: '#/components/schemas/SpantypesTraceAITokens'
|
||||
totalCost:
|
||||
type: number
|
||||
required:
|
||||
- tokens
|
||||
type: object
|
||||
SpantypesTraceAITokens:
|
||||
properties:
|
||||
cacheRead:
|
||||
minimum: 0
|
||||
type: integer
|
||||
cacheWrite:
|
||||
minimum: 0
|
||||
type: integer
|
||||
input:
|
||||
minimum: 0
|
||||
type: integer
|
||||
output:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reasoning:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- input
|
||||
- output
|
||||
- cacheRead
|
||||
- cacheWrite
|
||||
- reasoning
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -16017,163 +15882,6 @@ paths:
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/traces/{traceID}/summary:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the trace's time range, root span, span and error counts,
|
||||
and whether any spans are missing. AI traces also include token and cost totals.
|
||||
operationId: GetTraceSummary
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceSummary'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get summary for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/traces/{traceID}/thread:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the spans carrying gen_ai input or output messages in timestamp
|
||||
order. Pass nextCursor as after or prevCursor as before to page, or spanId
|
||||
to open the page around a span.
|
||||
operationId: GetTraceThread
|
||||
parameters:
|
||||
- description: Page size, at most 100. 0 means 20.
|
||||
in: query
|
||||
name: limit
|
||||
schema:
|
||||
description: Page size, at most 100. 0 means 20.
|
||||
type: integer
|
||||
- description: The nextCursor of a page; returns the spans after it. Set only
|
||||
one of after, before and spanId.
|
||||
in: query
|
||||
name: after
|
||||
schema:
|
||||
description: The nextCursor of a page; returns the spans after it. Set only
|
||||
one of after, before and spanId.
|
||||
type: string
|
||||
- description: The prevCursor of a page; returns the spans before it. Set only
|
||||
one of after, before and spanId.
|
||||
in: query
|
||||
name: before
|
||||
schema:
|
||||
description: The prevCursor of a page; returns the spans before it. Set
|
||||
only one of after, before and spanId.
|
||||
type: string
|
||||
- description: Returns the page around this span. Set only one of after, before
|
||||
and spanId.
|
||||
in: query
|
||||
name: spanId
|
||||
schema:
|
||||
description: Returns the page around this span. Set only one of after, before
|
||||
and spanId.
|
||||
type: string
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceThread'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get thread view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: true
|
||||
|
||||
@@ -86,6 +86,9 @@ tests/
|
||||
├── .env.local # generated by bootstrap/setup.py (gitignored)
|
||||
├── bootstrap/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── drivers/ # the driver kit and one driver per feature
|
||||
│ ├── kit.ts
|
||||
│ └── routing.ts
|
||||
├── fixtures/ # Playwright test fixtures (test.extend) only
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
@@ -112,6 +115,24 @@ These two folders look similar but mean different things:
|
||||
|
||||
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
|
||||
|
||||
### Drivers
|
||||
|
||||
New suites use a driver rather than loose helpers. A driver is a function that closes over the page and returns the feature's actions and locators. `drivers/kit.ts` turns it into a fixture, so a spec imports one module and never passes a `page` around:
|
||||
|
||||
```ts
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
await routing.goto(PATH.metricsSummary);
|
||||
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
|
||||
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
|
||||
- A driver reuses helpers by calling them, never by reaching for another fixture.
|
||||
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
|
||||
|
||||
### Extended fixtures
|
||||
|
||||
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.
|
||||
|
||||
@@ -417,7 +417,7 @@ for (const theme of themes.length ? themes : [null]) {
|
||||
break;
|
||||
}
|
||||
|
||||
height = Math.ceil(needed);
|
||||
height = needed;
|
||||
await page.setViewportSize({ width: Number(opts.width), height });
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
}
|
||||
|
||||
@@ -11335,82 +11335,6 @@ export interface SpantypesGettableTraceAggregationsDTO {
|
||||
aggregations: SpantypesSpanAggregationResultDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAITokensDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheRead: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheWrite: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
input: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
output: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reasoning: number;
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAISummaryDTO {
|
||||
tokens: SpantypesTraceAITokensDTO;
|
||||
/**
|
||||
* @type number
|
||||
*/
|
||||
totalCost?: number;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceSummaryDTO {
|
||||
ai?: SpantypesTraceAISummaryDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
endTimestampMillis: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMissingSpans: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceEntryPoint: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootSpanStatusCode: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
startTimestampMillis: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalErrorSpansCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
export interface SpantypesOtelSpanRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -11426,86 +11350,6 @@ export interface SpantypesOtelSpanRefDTO {
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export type SpantypesThreadSpanDTOAttributes = { [key: string]: unknown };
|
||||
|
||||
export type SpantypesThreadSpanDTOResource = { [key: string]: string };
|
||||
|
||||
export interface SpantypesThreadSpanDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
attributes: SpantypesThreadSpanDTOAttributes;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
duration_nano: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
events: SpantypesEventDTO[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_error: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind_string: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
references: SpantypesOtelSpanRefDTO[];
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
resource: SpantypesThreadSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
span_id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_code_string: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_message: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
time_unix: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_id: string;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceThreadDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
nextCursor?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
prevCursor?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
spans: SpantypesThreadSpanDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -13179,51 +13023,6 @@ export type GetTraceAggregations200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceSummaryPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceSummary200 = {
|
||||
data: SpantypesGettableTraceSummaryDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceThreadPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceThreadParams = {
|
||||
/**
|
||||
* @type integer
|
||||
* @description Page size, at most 100. 0 means 20.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description The nextCursor of a page; returns the spans after it. Set only one of after, before and spanId.
|
||||
*/
|
||||
after?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The prevCursor of a page; returns the spans before it. Set only one of after, before and spanId.
|
||||
*/
|
||||
before?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Returns the page around this span. Set only one of after, before and spanId.
|
||||
*/
|
||||
spanId?: string;
|
||||
};
|
||||
|
||||
export type GetTraceThread200 = {
|
||||
data: SpantypesGettableTraceThreadDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUserPreferences200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -4,17 +4,11 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
@@ -22,11 +16,6 @@ import type {
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceSummary200,
|
||||
GetTraceSummaryPathParameters,
|
||||
GetTraceThread200,
|
||||
GetTraceThreadParams,
|
||||
GetTraceThreadPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -38,26 +27,6 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes span aggregations grouped by requested field.
|
||||
* @summary Get aggregations for a trace
|
||||
@@ -158,223 +127,6 @@ export const useGetTraceAggregations = <
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const getTraceSummary = (
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceSummary200>({
|
||||
url: `/api/v1/traces/${traceID}/summary`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryKey = ({
|
||||
traceID,
|
||||
}: GetTraceSummaryPathParameters) => {
|
||||
return [`/api/v1/traces/${traceID}/summary`] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
|
||||
signal,
|
||||
}) => getTraceSummary({ traceID }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceSummaryQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>
|
||||
>;
|
||||
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceSummary<
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const invalidateGetTraceSummary = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const getTraceThread = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceThread200>({
|
||||
url: `/api/v1/traces/${traceID}/thread`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryKey = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/traces/${traceID}/thread`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
|
||||
signal,
|
||||
}) => getTraceThread({ traceID }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceThreadQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceThread>>
|
||||
>;
|
||||
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceThread<
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceThreadQueryOptions(
|
||||
{ traceID },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const invalidateGetTraceThread = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
|
||||
@@ -72,6 +72,8 @@ interface CustomTimePickerProps {
|
||||
maxTime: number;
|
||||
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
|
||||
isModalTimeSelection?: boolean;
|
||||
/** Lands on the trigger input. Not spread — the component takes no rest props. */
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -95,6 +97,7 @@ function CustomTimePicker({
|
||||
minTime,
|
||||
maxTime,
|
||||
isModalTimeSelection = false,
|
||||
'data-testid': dataTestId,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [selectedTimePlaceholderValue, setSelectedTimePlaceholderValue] =
|
||||
useState('Select / Enter Time Range');
|
||||
@@ -596,6 +599,7 @@ function CustomTimePicker({
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
data-testid={dataTestId}
|
||||
autoComplete="off"
|
||||
className={cx(
|
||||
'timeSelection-input',
|
||||
@@ -682,4 +686,5 @@ CustomTimePicker.defaultProps = {
|
||||
onExitLiveLogs: noop,
|
||||
showLiveLogs: false,
|
||||
showRecentlyUsed: true,
|
||||
'data-testid': undefined,
|
||||
};
|
||||
|
||||
@@ -180,6 +180,7 @@ function CustomTimePickerPopoverContent({
|
||||
type="text"
|
||||
className="time-btns"
|
||||
key={option.label + option.value}
|
||||
data-testid={`time-chip-${option.value}`}
|
||||
onClick={(): void => {
|
||||
handleExitLiveLogs();
|
||||
onSelectHandler(option.label, option.value);
|
||||
@@ -259,6 +260,7 @@ function CustomTimePickerPopoverContent({
|
||||
<Button
|
||||
type="text"
|
||||
key={option.label + option.value}
|
||||
data-testid={`time-option-${option.value}`}
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
@@ -98,6 +98,7 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
|
||||
|
||||
<div
|
||||
class="c0"
|
||||
data-testid="not-found"
|
||||
>
|
||||
<img
|
||||
alt="not-found"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button, Container, Text, TextContainer } from './styles';
|
||||
|
||||
function NotFound({ text = defaultText }: Props): JSX.Element {
|
||||
return (
|
||||
<Container>
|
||||
<Container data-testid="not-found">
|
||||
<NotFoundImage />
|
||||
|
||||
<TextContainer>
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
clip-path: inset(0 -100vw 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
showFilterCollapse = true,
|
||||
showQueryName = true,
|
||||
useFieldApis,
|
||||
savedViewsHeader,
|
||||
} = props;
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
@@ -380,7 +379,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
return (
|
||||
<div className="quick-filters-container">
|
||||
<div className="quick-filters">
|
||||
{savedViewsHeader}
|
||||
{source !== QuickFiltersSource.INFRA_MONITORING && (
|
||||
<section className="header">
|
||||
{renderLeftActions()}
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
// Positioned so overlays (settings drawer) paint above the content pane
|
||||
// without changing this pane's layout width.
|
||||
.filters {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
@@ -19,44 +21,6 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.savedViews {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--l1-background);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.quickFilters {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--l1-background);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
z-index: 1;
|
||||
|
||||
&.isOpen {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
// No quick filters: the header sits in flow and the list stays under it
|
||||
.isStatic {
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
.quickFilters {
|
||||
position: static;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.savedViews {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
|
||||
// `height: 100%`), which owns the scrolling.
|
||||
.content {
|
||||
@@ -67,7 +31,3 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.contentInner {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { ComponentProps, ReactNode, useState } from 'react';
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
|
||||
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
|
||||
@@ -15,15 +12,8 @@ type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>;
|
||||
|
||||
// What the page configures; the layout adds the open / close wiring.
|
||||
type SavedViewsElementProps = Omit<
|
||||
ComponentProps<typeof SavedViewsPanel>,
|
||||
'onClose'
|
||||
>;
|
||||
|
||||
export interface QuickFiltersLayoutProps {
|
||||
quickFilterProps?: QuickFiltersElementProps;
|
||||
savedViewProps?: SavedViewsElementProps;
|
||||
quickFilterProps: QuickFiltersElementProps;
|
||||
showFilters: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
@@ -33,60 +23,20 @@ export interface QuickFiltersLayoutProps {
|
||||
|
||||
function QuickFiltersLayout({
|
||||
quickFilterProps,
|
||||
savedViewProps,
|
||||
showFilters,
|
||||
className,
|
||||
contentClassName,
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
const [isViewsOpen, setIsViewsOpen] = useState(false);
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const hasQuickFilters = !!quickFilterProps;
|
||||
const hasSavedViews = !!savedViewProps && isSavedViewEnabled;
|
||||
|
||||
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
|
||||
|
||||
const isPanelPinned = hasSavedViews && !hasQuickFilters;
|
||||
|
||||
const isSliding = hasQuickFilters && isViewsOpen;
|
||||
|
||||
const savedViewsHeader = hasSavedViews ? (
|
||||
<SavedViewsHeader
|
||||
{...savedViewProps}
|
||||
onOpenViews={isPanelPinned ? undefined : (): void => setIsViewsOpen(true)}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{showSidebar && (
|
||||
{showFilters && (
|
||||
<aside
|
||||
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
|
||||
className={styles.filters}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<div
|
||||
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
|
||||
data-testid="quick-filters-layout-drawer"
|
||||
>
|
||||
{hasQuickFilters ? (
|
||||
<QuickFilters
|
||||
{...quickFilterProps}
|
||||
savedViewsHeader={savedViewsHeader}
|
||||
/>
|
||||
) : (
|
||||
savedViewsHeader
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.savedViews}>
|
||||
{hasSavedViews && (isPanelPinned || isViewsOpen) && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={isPanelPinned ? undefined : (): void => setIsViewsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<QuickFilters {...quickFilterProps} />
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
@@ -94,7 +44,7 @@ function QuickFiltersLayout({
|
||||
data-testid="quick-filters-layout-content"
|
||||
>
|
||||
<OverlayScrollbar>
|
||||
<div className={styles.contentInner}>{children}</div>
|
||||
<div>{children}</div>
|
||||
</OverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import QuickFiltersLayout from '../QuickFiltersLayout';
|
||||
@@ -9,33 +7,15 @@ jest.mock('../QuickFiltersLayout.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
layout: 'layout',
|
||||
sidebar: 'sidebar',
|
||||
isStatic: 'isStatic',
|
||||
savedViews: 'savedViews',
|
||||
quickFilters: 'quickFilters',
|
||||
isOpen: 'isOpen',
|
||||
filters: 'filters',
|
||||
content: 'content',
|
||||
contentInner: 'contentInner',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSavedViewEnabled', () => ({
|
||||
useSavedViewEnabled: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
source,
|
||||
savedViewsHeader,
|
||||
}: {
|
||||
source: string;
|
||||
savedViewsHeader?: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="quick-filters">
|
||||
{savedViewsHeader}
|
||||
{source}
|
||||
</div>
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="quick-filters">{source}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -43,16 +23,8 @@ const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
handleFilterVisibilityChange: jest.fn(),
|
||||
};
|
||||
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
|
||||
|
||||
const drawer = (): HTMLElement =>
|
||||
screen.getByTestId('quick-filters-layout-drawer');
|
||||
|
||||
describe('QuickFiltersLayout', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(useSavedViewEnabled).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('renders QuickFilters with the given props inside the filters pane', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
|
||||
@@ -68,16 +40,11 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
|
||||
'content',
|
||||
);
|
||||
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the filters pane when showFilters is false', () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters={false}
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
@@ -89,138 +56,24 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no sidebar at all without quick filters or saved views', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('merges classNames onto the root and content panes', () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
className="root-extra"
|
||||
contentClassName="content-extra"
|
||||
testId="layout"
|
||||
className="page-root"
|
||||
contentClassName="page-content"
|
||||
testId="page"
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('layout')).toHaveClass('layout', 'root-extra');
|
||||
const root = screen.getByTestId('page');
|
||||
expect(root).toHaveClass('layout', 'page-root');
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
|
||||
'content',
|
||||
'content-extra',
|
||||
'page-content',
|
||||
);
|
||||
});
|
||||
|
||||
describe('saved views', () => {
|
||||
it('ignores savedViewProps while the flag is off', () => {
|
||||
jest.mocked(useSavedViewEnabled).mockReturnValue(false);
|
||||
const { unmount } = render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
|
||||
// Saved views only, so nothing is left to show.
|
||||
unmount();
|
||||
render(
|
||||
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const quickFilters = screen.getByTestId('quick-filters');
|
||||
|
||||
expect(quickFilters).toContainElement(
|
||||
screen.getByTestId('saved-views-header'),
|
||||
);
|
||||
expect(drawer()).toContainElement(quickFilters);
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
|
||||
await user.click(screen.getByTestId('saved-views-open'));
|
||||
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(drawer()).toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
|
||||
await user.click(screen.getByTestId('saved-views-close'));
|
||||
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
});
|
||||
|
||||
it('hands the source to the header and the panel', async () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('saved-views-header')).toHaveAttribute(
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
|
||||
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
});
|
||||
|
||||
it('without quick filters: static sidebar, header on top, list always on screen with no open or close controls', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const sidebar = screen.getByTestId('quick-filters-layout-filters');
|
||||
expect(sidebar).toHaveClass('isStatic');
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-close')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,20 +8,12 @@
|
||||
width: 342px;
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
transition: width 0.05s ease-in-out;
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
transition:
|
||||
transform 0.2s ease-in-out,
|
||||
visibility 0s;
|
||||
|
||||
&.hidden {
|
||||
transform: translateX(-100%);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform 0.2s ease-in-out,
|
||||
visibility 0s 0.2s;
|
||||
width: 0;
|
||||
}
|
||||
.qf-header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -62,7 +61,6 @@ export interface IQuickFiltersProps {
|
||||
showFilterCollapse?: boolean;
|
||||
showQueryName?: boolean;
|
||||
useFieldApis?: QuickFilterCheckboxUseFieldApis;
|
||||
savedViewsHeader?: ReactNode;
|
||||
}
|
||||
|
||||
export enum QuickFiltersSource {
|
||||
|
||||
@@ -35,7 +35,8 @@ function TanStackRowCellInner<TData>({
|
||||
<td
|
||||
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
|
||||
data-single-column={hasSingleColumn || undefined}
|
||||
onClick={onClick}
|
||||
// While loading, rowData may be a placeholder ({ id: 'skeleton-N' }).
|
||||
onClick={showSkeleton ? undefined : onClick}
|
||||
>
|
||||
{showSkeleton ? (
|
||||
<Skeleton.Input
|
||||
|
||||
@@ -83,6 +83,18 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not call onRowClick for skeleton rows', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
renderTanStackTable({
|
||||
props: { data: [], isLoading: true, onRowClick },
|
||||
});
|
||||
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows loading spinner for infinite scroll when loading', () => {
|
||||
renderTanStackTable({
|
||||
props: { isLoading: true, onEndReached: jest.fn() },
|
||||
|
||||
@@ -10,8 +10,6 @@ import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
@@ -349,62 +347,57 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.metrics }}
|
||||
>
|
||||
<div className="metrics-explorer-explore-container">
|
||||
<div className="explore-header">
|
||||
<div className="explore-header-left-actions">
|
||||
<span>1 chart/query</span>
|
||||
<Tooltip
|
||||
open={disableOneChartPerQuery ? undefined : false}
|
||||
title={oneChartPerQueryDisabledTooltip}
|
||||
>
|
||||
<Switch
|
||||
value={showOneChartPerQuery}
|
||||
onChange={handleToggleShowOneChartPerQuery}
|
||||
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="explore-header-right-actions">
|
||||
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
|
||||
<DateTimeSelector showAutoRefresh />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
<div className="metrics-explorer-explore-container">
|
||||
<div className="explore-header">
|
||||
<div className="explore-header-left-actions">
|
||||
<span>1 chart/query</span>
|
||||
<Tooltip
|
||||
open={disableOneChartPerQuery ? undefined : false}
|
||||
title={oneChartPerQueryDisabledTooltip}
|
||||
>
|
||||
<Switch
|
||||
value={showOneChartPerQuery}
|
||||
onChange={handleToggleShowOneChartPerQuery}
|
||||
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<QueryBuilderV2
|
||||
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
<div className="explore-content">
|
||||
<TimeSeries
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
showOneChartPerQuery={showOneChartPerQuery}
|
||||
setWarning={setWarning}
|
||||
areAllMetricUnitsSame={areAllMetricUnitsSame}
|
||||
isMetricUnitsLoading={isMetricUnitsLoading}
|
||||
isMetricUnitsError={isMetricUnitsError}
|
||||
metricUnits={units}
|
||||
metricNames={metricNames}
|
||||
metrics={metrics}
|
||||
handleOpenMetricDetails={handleOpenMetricDetails}
|
||||
yAxisUnit={yAxisUnit}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
isCancelled={isCancelled}
|
||||
exportDefaultQuery={exportDefaultQuery}
|
||||
<div className="explore-header-right-actions">
|
||||
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
|
||||
<DateTimeSelector showAutoRefresh />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
<QueryBuilderV2
|
||||
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
<div className="explore-content">
|
||||
<TimeSeries
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
showOneChartPerQuery={showOneChartPerQuery}
|
||||
setWarning={setWarning}
|
||||
areAllMetricUnitsSame={areAllMetricUnitsSame}
|
||||
isMetricUnitsLoading={isMetricUnitsLoading}
|
||||
isMetricUnitsError={isMetricUnitsError}
|
||||
metricUnits={units}
|
||||
metricNames={metricNames}
|
||||
metrics={metrics}
|
||||
handleOpenMetricDetails={handleOpenMetricDetails}
|
||||
yAxisUnit={yAxisUnit}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
isCancelled={isCancelled}
|
||||
exportDefaultQuery={exportDefaultQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
|
||||
@@ -308,7 +308,7 @@ function CreateEditRolePageContent(): JSX.Element {
|
||||
cancelText="Keep editing"
|
||||
onConfirm={confirmNavigation}
|
||||
onCancel={cancelNavigation}
|
||||
data-testid="discard-changes-dialog"
|
||||
testId="discard-changes-dialog"
|
||||
>
|
||||
<Typography>
|
||||
{isCreateMode
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Menu } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './SavedViewsHeader.module.scss';
|
||||
|
||||
// Temp Placeholder data till main functionality is plugged in
|
||||
const PLACEHOLDER_VIEW_NAME = 'My view';
|
||||
|
||||
function SavedViewsHeader({
|
||||
source,
|
||||
onOpenViews,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
// Absent when the list is always on screen.
|
||||
onOpenViews?: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.header}
|
||||
data-testid="saved-views-header"
|
||||
data-source={source}
|
||||
>
|
||||
<Typography.Text className={styles.name}>
|
||||
{PLACEHOLDER_VIEW_NAME}
|
||||
</Typography.Text>
|
||||
{onOpenViews && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="All views"
|
||||
prefix={<Menu size={14} />}
|
||||
onClick={onOpenViews}
|
||||
data-testid="saved-views-open"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsHeader;
|
||||
@@ -1,21 +0,0 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './SavedViewsPanel.module.scss';
|
||||
|
||||
function SavedViewsPanel({
|
||||
source,
|
||||
onClose,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
// Absent when the list is always on screen.
|
||||
onClose?: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-testid="saved-views-panel"
|
||||
data-source={source}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.title}>All views</Typography.Text>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Close"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
data-testid="saved-views-close"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsPanel;
|
||||
@@ -2,7 +2,6 @@ import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -23,7 +22,6 @@ const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Panel Editor',
|
||||
tags: ['play'],
|
||||
// The page is wrapped in `withAuthZPage`, which types its props as an index
|
||||
// signature; the story's args are what the controls resolve to.
|
||||
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
|
||||
@@ -40,25 +38,6 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<PanelEditorArgs>;
|
||||
|
||||
const expandConfigSections: Story['play'] = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const headers = await canvas.findAllByTestId(
|
||||
/^config-section-/,
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
for (const header of headers) {
|
||||
if (header.getAttribute('aria-expanded') === 'false') {
|
||||
await userEvent.click(header);
|
||||
}
|
||||
}
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
for (let el = headers[0].parentElement; el; el = el.parentElement) {
|
||||
el.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Editing a saved time series panel: the live preview over the query builder on
|
||||
* the left, the panel's formatting, legend, axes and thresholds on the right.
|
||||
@@ -107,57 +86,3 @@ export const ReadOnly: Story = {
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
};
|
||||
|
||||
/** A new time series panel's config pane, every section expanded. */
|
||||
export const ConfigTimeSeries: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'time-series' },
|
||||
play: expandConfigSections,
|
||||
};
|
||||
|
||||
/** A new number panel's config pane, every section expanded. */
|
||||
export const ConfigNumber: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'number' },
|
||||
play: expandConfigSections,
|
||||
};
|
||||
|
||||
/** A new table panel's config pane, every section expanded. */
|
||||
export const ConfigTable: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'table' },
|
||||
play: expandConfigSections,
|
||||
};
|
||||
|
||||
/** A new bar chart panel's config pane, every section expanded. */
|
||||
export const ConfigBarChart: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'bar-chart' },
|
||||
play: expandConfigSections,
|
||||
};
|
||||
|
||||
/** A new area chart panel's config pane, every section expanded. */
|
||||
export const ConfigAreaChart: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'area-chart' },
|
||||
play: expandConfigSections,
|
||||
};
|
||||
|
||||
/** A new pie chart panel's config pane, every section expanded. */
|
||||
export const ConfigPieChart: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'pie-chart' },
|
||||
play: expandConfigSections,
|
||||
};
|
||||
|
||||
/** A new histogram panel's config pane, every section expanded. */
|
||||
export const ConfigHistogram: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'histogram' },
|
||||
play: expandConfigSections,
|
||||
};
|
||||
|
||||
/** A new list panel's config pane, every section expanded. */
|
||||
export const ConfigList: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'list' },
|
||||
play: expandConfigSections,
|
||||
};
|
||||
|
||||
/** A new text panel's config pane, every section expanded. */
|
||||
export const ConfigText: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'text' },
|
||||
play: expandConfigSections,
|
||||
};
|
||||
|
||||
@@ -21,14 +21,12 @@ import type { PanelKind } from '../../../DashboardContainer/Panels/types/panelKi
|
||||
*/
|
||||
export const NEW_PANEL_KINDS = [
|
||||
'time-series',
|
||||
'number',
|
||||
'table',
|
||||
'bar-chart',
|
||||
'area-chart',
|
||||
'number',
|
||||
'pie-chart',
|
||||
'table',
|
||||
'histogram',
|
||||
'list',
|
||||
'text',
|
||||
] as const;
|
||||
|
||||
export type NewPanelKind = (typeof NEW_PANEL_KINDS)[number];
|
||||
@@ -36,13 +34,11 @@ export type NewPanelKind = (typeof NEW_PANEL_KINDS)[number];
|
||||
const KIND_BY_OPTION: Record<NewPanelKind, PanelKind> = {
|
||||
'time-series': 'signoz/TimeSeriesPanel',
|
||||
'bar-chart': 'signoz/BarChartPanel',
|
||||
'area-chart': 'signoz/AreaChartPanel',
|
||||
number: 'signoz/NumberPanel',
|
||||
'pie-chart': 'signoz/PieChartPanel',
|
||||
table: 'signoz/TablePanel',
|
||||
histogram: 'signoz/HistogramPanel',
|
||||
list: 'signoz/ListPanel',
|
||||
text: 'signoz/TextPanel',
|
||||
};
|
||||
|
||||
export const newPanelKindOf = (option: NewPanelKind): PanelKind =>
|
||||
|
||||
@@ -285,70 +285,6 @@ export const SectionActionsMenu: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The new-panel picker opened from the toolbar: the panel kinds as tiles, with
|
||||
* the target section to place the panel in.
|
||||
*/
|
||||
export const NewPanelPicker: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('add-panel-header', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByTestId('panel-type-signoz/TimeSeriesPanel');
|
||||
},
|
||||
};
|
||||
|
||||
/** The new-panel picker with a kind picked, waiting on the section to confirm. */
|
||||
export const NewPanelPickerTypeSelected: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('add-panel-header', {}, { timeout: 10000 }),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('panel-type-signoz/TimeSeriesPanel'),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The new-panel picker on a dashboard without sections, where there is no
|
||||
* section to choose.
|
||||
*/
|
||||
export const NewPanelPickerNoSections: Story = {
|
||||
args: { sectioned: false },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('add-panel-header', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByTestId('panel-type-signoz/TimeSeriesPanel');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The new-panel picker opened from the second section's menu, targeting that
|
||||
* section.
|
||||
*/
|
||||
export const NewPanelPickerFromSection: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const [, secondSection] = await canvas.findAllByRole(
|
||||
'button',
|
||||
{ name: 'Section actions' },
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await userEvent.click(secondSection);
|
||||
await userEvent.click(await screen.findByText('Add panel'));
|
||||
await screen.findByTestId('panel-type-signoz/TimeSeriesPanel');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
|
||||
*
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.log-quick-filter-left-section {
|
||||
width: 0%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-module-right-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
|
||||
.log-explorer-query-container {
|
||||
@@ -34,4 +39,26 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
.log-quick-filter-left-section {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.log-module-right-section {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useQueryClient } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -225,19 +225,21 @@ function LogsExplorer(): JSX.Element {
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<EventSourceProvider>
|
||||
<QuickFiltersLayout
|
||||
className="logs-module-page"
|
||||
showFilters={showFilters}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.logs }}
|
||||
quickFilterProps={{
|
||||
className: 'qf-logs-explorer',
|
||||
signal: SignalType.LOGS,
|
||||
source: QuickFiltersSource.LOGS_EXPLORER,
|
||||
handleFilterVisibilityChange,
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
<div
|
||||
className={cx('logs-module-page', showFilters ? 'filter-visible' : '')}
|
||||
>
|
||||
<section className="log-module-right-section">
|
||||
{showFilters && (
|
||||
<section className={cx('log-quick-filter-left-section')}>
|
||||
<QuickFilters
|
||||
className="qf-logs-explorer"
|
||||
signal={SignalType.LOGS}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<section className={cx('log-module-right-section')}>
|
||||
<Toolbar
|
||||
showAutoRefresh={false}
|
||||
leftActions={
|
||||
@@ -290,7 +292,7 @@ function LogsExplorer(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</QuickFiltersLayout>
|
||||
</div>
|
||||
</EventSourceProvider>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
@@ -286,7 +285,6 @@ function TracesExplorer(): JSX.Element {
|
||||
<QuickFiltersLayout
|
||||
className="trace-explorer-page"
|
||||
showFilters={isOpen}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.traces }}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
|
||||
@@ -10,23 +10,6 @@ import (
|
||||
)
|
||||
|
||||
func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/traces/{traceID}/summary", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetTraceSummary),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetTraceSummary",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get summary for a trace",
|
||||
Description: "Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.",
|
||||
Response: new(spantypes.GettableTraceSummary),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
|
||||
handler.OpenAPIDef{
|
||||
@@ -84,23 +67,5 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/traces/{traceID}/thread", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetThread),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetTraceThread",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get thread view for a trace",
|
||||
Description: "Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
|
||||
RequestQuery: new(spantypes.GetTraceThreadParams),
|
||||
Response: new(spantypes.GettableTraceThread),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -20,22 +18,6 @@ func NewHandler(module tracedetail.Module) tracedetail.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (h *handler) GetTraceSummary(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.module.GetTraceSummary(r.Context(), valuer.MustNewUUID(claims.OrgID), mux.Vars(r)["traceID"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, spantypes.NewGettableTraceSummary(stats))
|
||||
}
|
||||
|
||||
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(spantypes.PostableWaterfall)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
@@ -93,31 +75,3 @@ func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) GetThread(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(spantypes.GetTraceThreadParams)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
query, err := spantypes.NewThreadQuery(req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.GetThread(r.Context(), valuer.MustNewUUID(claims.OrgID), mux.Vars(r)["traceID"], query)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
@@ -40,41 +39,26 @@ func NewModule(traceStore spantypes.TraceStore, providerSettings factory.Provide
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *module) GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error) {
|
||||
bounds, err := m.store.GetTraceBounds(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats, err := m.store.GetTraceStats(ctx, orgID, traceID, bounds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stats.TotalSpans == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetWaterfallV4 is the OOM-safe V4 waterfall.
|
||||
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
|
||||
// minimal fields for all spans to build the tree, then full fields for the
|
||||
// visible window only. Aggregations are not returned.
|
||||
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error) {
|
||||
bounds, err := m.store.GetTraceBounds(ctx, traceID)
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bounds.NumSpans > uint64(m.config.Waterfall.MaxLimitToSelectAllSpans) {
|
||||
if summary.NumSpans > uint64(m.config.Waterfall.MaxLimitToSelectAllSpans) {
|
||||
attrs := metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed))
|
||||
m.metrics.waterfallRequestCount.Add(ctx, 1, attrs)
|
||||
m.metrics.waterfallSpanCount.Add(ctx, int64(bounds.NumSpans), attrs)
|
||||
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, bounds.Start, bounds.End)
|
||||
m.metrics.waterfallSpanCount.Add(ctx, int64(summary.NumSpans), attrs)
|
||||
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, summary.Start, summary.End)
|
||||
}
|
||||
return m.getFullWaterfall(ctx, traceID, bounds)
|
||||
return m.getFullWaterfall(ctx, traceID, summary)
|
||||
}
|
||||
|
||||
func (m *module) getFullWaterfall(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) (*spantypes.GettableWaterfallTrace, error) {
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, traceID, bounds)
|
||||
func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *spantypes.TraceSummary) (*spantypes.GettableWaterfallTrace, error) {
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -94,24 +78,24 @@ func (m *module) getFullWaterfall(ctx context.Context, traceID string, bounds *s
|
||||
}
|
||||
|
||||
func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error) {
|
||||
bounds, err := m.store.GetTraceBounds(ctx, traceID)
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
traceDurationNs := uint64(bounds.End.UnixNano()) - uint64(bounds.Start.UnixNano())
|
||||
traceDurationNs := uint64(summary.End.UnixNano()) - uint64(summary.Start.UnixNano())
|
||||
|
||||
results := make([]spantypes.SpanAggregationResult, 0, len(req.Aggregations))
|
||||
for _, agg := range req.Aggregations {
|
||||
result := spantypes.SpanAggregationResult{Field: agg.Field, Aggregation: agg.Aggregation}
|
||||
switch agg.Aggregation {
|
||||
case spantypes.SpanAggregationSpanCount:
|
||||
result.Value, err = m.store.GetSpanCountByField(ctx, traceID, bounds, agg.Field)
|
||||
result.Value, err = m.store.GetSpanCountByField(ctx, traceID, summary, agg.Field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case spantypes.SpanAggregationDuration:
|
||||
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, bounds, agg.Field)
|
||||
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, summary, agg.Field)
|
||||
if err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
@@ -120,7 +104,7 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
|
||||
result.Value[k] = ns / 1_000_000
|
||||
}
|
||||
case spantypes.SpanAggregationExecutionTimePercentage:
|
||||
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, bounds, agg.Field)
|
||||
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, summary, agg.Field)
|
||||
if err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
@@ -137,15 +121,15 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
|
||||
}
|
||||
|
||||
func (m *module) GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
bounds, err := m.store.GetTraceBounds(ctx, traceID)
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bounds.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
|
||||
return m.getFullFlamegraph(ctx, traceID, bounds, selectFields)
|
||||
if summary.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
|
||||
return m.getFullFlamegraph(ctx, traceID, summary, selectFields)
|
||||
}
|
||||
m.metrics.flamegraphRequestCount.Add(ctx, 1, metric.WithAttributes(attrResponseType.String(attrResponseTypeSampled)))
|
||||
return m.getWindowedFlamegraph(ctx, traceID, selectedSpanID, bounds, selectFields)
|
||||
return m.getWindowedFlamegraph(ctx, traceID, selectedSpanID, summary, selectFields)
|
||||
}
|
||||
|
||||
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
|
||||
@@ -189,51 +173,8 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
|
||||
), nil
|
||||
}
|
||||
|
||||
func (m *module) GetThread(ctx context.Context, orgID valuer.UUID, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error) {
|
||||
bounds, err := m.store.GetTraceBounds(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// One extra row per side signals a next/prev page; NewGettableTraceThread trims the response back to query.Limit.
|
||||
page := spantypes.ThreadPage{Limit: query.Limit + 1}
|
||||
switch {
|
||||
case query.SpanID != "":
|
||||
// Window centred on the span: fetch both directions, NewGettableTraceThread splits the limit.
|
||||
anchor, err := m.store.GetThreadCursor(ctx, traceID, bounds, query.SpanID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, spantypes.ThreadPage{Cursor: anchor, From: spantypes.ThreadBefore, Limit: page.Limit})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ThreadAt keeps the anchor when it carries messages; otherwise the filter drops it.
|
||||
after, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, spantypes.ThreadPage{Cursor: anchor, From: spantypes.ThreadAt, Limit: page.Limit})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewGettableTraceThread(traceID, query, before, after), nil
|
||||
case query.Before != nil:
|
||||
page.Cursor, page.From = query.Before, spantypes.ThreadBefore
|
||||
before, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewGettableTraceThread(traceID, query, before, nil), nil
|
||||
default:
|
||||
// nil for the first page.
|
||||
page.Cursor = query.After
|
||||
after, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewGettableTraceThread(traceID, query, nil, after), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, bounds.Start, bounds.End, nil)
|
||||
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -246,8 +187,8 @@ func (m *module) getFullFlamegraph(ctx context.Context, traceID string, bounds *
|
||||
}
|
||||
|
||||
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
|
||||
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, bounds *spantypes.TraceBounds, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, bounds.Start, bounds.End)
|
||||
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary.Start, summary.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -266,7 +207,7 @@ func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpa
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, bounds.Start, bounds.End, spantypes.FlamegraphWindowSpanIDs(selectedSpans))
|
||||
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, spantypes.FlamegraphWindowSpanIDs(selectedSpans))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,22 +4,15 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
|
||||
@@ -45,21 +38,13 @@ type spanDurationRow struct {
|
||||
|
||||
type traceStore struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
storage qbtypes.Storage
|
||||
flagger flagger.Flagger
|
||||
}
|
||||
|
||||
func NewTraceStore(ts telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, fl flagger.Flagger) *traceStore {
|
||||
return &traceStore{
|
||||
telemetryStore: ts,
|
||||
metadataStore: metadataStore,
|
||||
storage: tracestelemetryschema.NewStorage(),
|
||||
flagger: fl,
|
||||
}
|
||||
func NewTraceStore(ts telemetrystore.TelemetryStore) *traceStore {
|
||||
return &traceStore{telemetryStore: ts}
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceBounds(ctx context.Context, traceID string) (*spantypes.TraceBounds, error) {
|
||||
func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*spantypes.TraceSummary, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("trace_id", "min(start) AS start", "max(end) AS end", "sum(num_spans) AS num_spans")
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceSummaryTable))
|
||||
@@ -67,148 +52,20 @@ func (s *traceStore) GetTraceBounds(ctx context.Context, traceID string) (*spant
|
||||
sb.GroupBy("trace_id")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var bounds spantypes.TraceBounds
|
||||
var summary spantypes.TraceSummary
|
||||
err := s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(
|
||||
&bounds.TraceID, &bounds.Start, &bounds.End, &bounds.NumSpans,
|
||||
&summary.TraceID, &summary.Start, &summary.End, &summary.NumSpans,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace bounds")
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace summary")
|
||||
}
|
||||
return &bounds, nil
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds) (*spantypes.TraceStats, error) {
|
||||
table := fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable)
|
||||
spans := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
genAIColumns, err := s.genAISpanColumns(ctx, orgID, bounds, spans)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Spans whose parent is absent from the trace; these also count as roots and surface as "Missing Span".
|
||||
ids := sqlbuilder.NewSelectBuilder()
|
||||
ids.Select("span_id")
|
||||
ids.From(table)
|
||||
ids.Where(
|
||||
ids.E("trace_id", traceID),
|
||||
ids.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
ids.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
)
|
||||
missingParent := fmt.Sprintf("parent_span_id <> '' AND parent_span_id GLOBAL NOT IN (%s)", spans.Var(ids))
|
||||
|
||||
spans.Select(
|
||||
"toUnixTimestamp64Nano(timestamp) AS span_start_ns",
|
||||
"span_start_ns + duration_nano AS span_end_ns",
|
||||
"span_id",
|
||||
"has_error",
|
||||
"("+missingParent+") AS has_missing_parent",
|
||||
"(parent_span_id = '' OR has_missing_parent) AS is_root",
|
||||
"if(parent_span_id = '', name, 'Missing Span') AS root_name",
|
||||
"if(parent_span_id = '', "+colServiceName+", '') AS root_service",
|
||||
"if(parent_span_id = '', response_status_code, '') AS root_status_code",
|
||||
)
|
||||
|
||||
spans.SelectMore(genAIColumns...)
|
||||
spans.From(table)
|
||||
spans.Where(
|
||||
spans.E("trace_id", traceID),
|
||||
spans.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
spans.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
)
|
||||
// A span can be ingested more than once; keep one row per span_id.
|
||||
spans.SQL("LIMIT 1 BY span_id")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"toUInt64(min(span_start_ns)) AS start_ns",
|
||||
"toUInt64(max(span_end_ns)) AS end_ns",
|
||||
"count() AS total_spans",
|
||||
"countIf(has_error) AS total_error_spans",
|
||||
"countIf(has_missing_parent) > 0 AS has_missing_spans",
|
||||
"argMinIf(root_service, (span_start_ns, root_name), is_root) AS root_service_name",
|
||||
"argMinIf(root_name, (span_start_ns, root_name), is_root) AS root_entry_point",
|
||||
"argMinIf(root_status_code, (span_start_ns, root_name), is_root) AS root_span_status_code",
|
||||
"countIf(is_gen_ai) AS gen_ai_span_count",
|
||||
"toUInt64(coalesce(sum(input_tokens_value), 0)) AS input_tokens",
|
||||
"toUInt64(coalesce(sum(output_tokens_value), 0)) AS output_tokens",
|
||||
"toUInt64(coalesce(sum(cache_read_tokens_value), 0)) AS cache_read_tokens",
|
||||
"toUInt64(coalesce(sum(cache_write_tokens_value), 0)) AS cache_write_tokens",
|
||||
"toUInt64(coalesce(sum(reasoning_tokens_value), 0)) AS reasoning_tokens",
|
||||
"sum(total_cost_value) AS total_cost",
|
||||
)
|
||||
sb.From(sb.BuilderAs(spans, "spans"))
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var stats spantypes.TraceStats
|
||||
err = s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(
|
||||
&stats.StartNs, &stats.EndNs, &stats.TotalSpans, &stats.TotalErrorSpans, &stats.HasMissingSpans,
|
||||
&stats.RootServiceName, &stats.RootEntryPoint, &stats.RootSpanStatusCode, &stats.GenAISpanCount,
|
||||
&stats.Tokens.Input, &stats.Tokens.Output, &stats.Tokens.CacheRead, &stats.Tokens.CacheWrite, &stats.Tokens.Reasoning,
|
||||
&stats.TotalCost,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace stats")
|
||||
}
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// genAISpanColumns returns the gen_ai columns aggregated per span, resolved across attribute evolutions.
|
||||
func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bounds *spantypes.TraceBounds, sb *sqlbuilder.SelectBuilder) ([]string, error) {
|
||||
attributeKey := func(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}
|
||||
}
|
||||
|
||||
values := []struct{ key, alias string }{
|
||||
{aiobservabilitytypes.GenAIUsageInputTokens, "input_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageOutputTokens, "output_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageCacheReadInputTokens, "cache_read_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, "cache_write_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageReasoningOutputTokens, "reasoning_tokens_value"},
|
||||
{aiobservabilitytypes.SignozGenAITotalCost, "total_cost_value"},
|
||||
}
|
||||
|
||||
names := slices.Clone(aiobservabilitytypes.GenAISpanGateKeys)
|
||||
for _, value := range values {
|
||||
names = append(names, value.key)
|
||||
}
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(names))
|
||||
for _, name := range names {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact})
|
||||
}
|
||||
keys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, s.flagger, selectors))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
|
||||
|
||||
gate := make([]string, 0, len(aiobservabilitytypes.GenAISpanGateKeys))
|
||||
for _, name := range aiobservabilitytypes.GenAISpanGateKeys {
|
||||
conds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name, telemetrytypes.FieldDataTypeString), qbtypes.FilterOperatorExists, nil, keys, false, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gate = append(gate, conds...)
|
||||
}
|
||||
columns := []string{sb.Or(gate...) + " AS is_gen_ai"}
|
||||
|
||||
for _, value := range values {
|
||||
// lookup by number, the type metadata stores numeric attributes under; float64 is only the output cast
|
||||
expr, err := querybuilder.ResolveColumn(ctx, q, s.storage, attributeKey(value.key, telemetrytypes.FieldDataTypeNumber), telemetrytypes.FieldDataTypeFloat64, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// a materialized column name carries `$$`, which Build would otherwise unescape
|
||||
columns = append(columns, sqlbuilder.Escape(expr)+" AS "+value.alias)
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) ([]spantypes.StorableSpan, error) {
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary) ([]spantypes.StorableSpan, error) {
|
||||
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder
|
||||
query := fmt.Sprintf(`
|
||||
SELECT DISTINCT ON (span_id)
|
||||
@@ -228,8 +85,8 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, bounds *
|
||||
err := s.telemetryStore.ClickhouseDB().Select(
|
||||
ctx, &spanItems, query,
|
||||
traceID,
|
||||
bounds.Start.Unix()-1800,
|
||||
bounds.End.Unix(),
|
||||
summary.Start.Unix()-1800,
|
||||
summary.End.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace spans")
|
||||
@@ -267,7 +124,8 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "has_error", "kind",
|
||||
"DISTINCT ON (span_id) timestamp",
|
||||
"duration_nano", "span_id", "has_error", "kind",
|
||||
colServiceName, "name",
|
||||
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
@@ -297,119 +155,6 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds, page spantypes.ThreadPage) ([]spantypes.StorableSpan, error) {
|
||||
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "parent_span_id", "has_error", "name", "kind_string",
|
||||
"status_code_string", "status_message", "resources_string",
|
||||
"attributes_string", "attributes_number", "attributes_bool",
|
||||
"events", "links as references",
|
||||
)
|
||||
if q.TraceAttrsJSONOn {
|
||||
sb.SelectMore("attributes")
|
||||
}
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
hasMessages, err := s.messagesExistCondition(ctx, q, orgID, bounds, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
hasMessages,
|
||||
)
|
||||
if cursor := page.Cursor; cursor != nil {
|
||||
// ClickHouse can't use an index for a tuple comparison, so the separate timestamp and
|
||||
// ts_bucket_start bounds are what skip the data on the far side of the cursor.
|
||||
key := "(toUnixTimestamp64Nano(timestamp), span_id)"
|
||||
bucket := int64(cursor.TimeUnixNano / uint64(time.Second))
|
||||
timestamp := fmt.Sprintf("fromUnixTimestamp64Nano(toInt64(%s))", sb.Var(cursor.TimeUnixNano))
|
||||
tuple := sqlbuilder.Tuple(cursor.TimeUnixNano, cursor.SpanID)
|
||||
switch page.From {
|
||||
case spantypes.ThreadBefore:
|
||||
sb.Where(sb.LE("ts_bucket_start", bucket), "timestamp <= "+timestamp, sb.LT(key, tuple))
|
||||
case spantypes.ThreadAt:
|
||||
sb.Where(sb.GE("ts_bucket_start", bucket-1800), "timestamp >= "+timestamp, sb.GE(key, tuple))
|
||||
default:
|
||||
sb.Where(sb.GE("ts_bucket_start", bucket-1800), "timestamp >= "+timestamp, sb.GT(key, tuple))
|
||||
}
|
||||
}
|
||||
// span_id breaks timestamp ties so the order matches the cursor key; otherwise tied spans
|
||||
// can be skipped or repeated across pages.
|
||||
if page.From == spantypes.ThreadBefore {
|
||||
sb.OrderByDesc("timestamp")
|
||||
sb.OrderByDesc("span_id")
|
||||
} else {
|
||||
sb.OrderByAsc("timestamp")
|
||||
sb.OrderByAsc("span_id")
|
||||
}
|
||||
sb.Limit(page.Limit)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var spans []spantypes.StorableSpan
|
||||
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying thread spans")
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
// messagesExistCondition resolves the gen_ai message keys through the attribute evolution metadata
|
||||
// and the use_trace_attributes_json flag, so the filter reads the same columns the query builder does.
|
||||
func (s *traceStore) messagesExistCondition(ctx context.Context, q qbtypes.QueryInfo, orgID valuer.UUID, bounds *spantypes.TraceBounds, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
names := []string{aiobservabilitytypes.GenAIInputMessages, aiobservabilitytypes.GenAIOutputMessages}
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, len(names))
|
||||
for i, name := range names {
|
||||
selectors[i] = &telemetrytypes.FieldKeySelector{
|
||||
StartUnixMilli: bounds.Start.UnixMilli(),
|
||||
EndUnixMilli: bounds.End.UnixMilli(),
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
Name: name,
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
}
|
||||
}
|
||||
fieldKeys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "error fetching thread field keys")
|
||||
}
|
||||
|
||||
conds := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
key := &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute}
|
||||
keyConds, _, err := querybuilder.Conditions(ctx, q, s.storage, key, qbtypes.FilterOperatorExists, nil, fieldKeys, false, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conds = append(conds, keyConds...)
|
||||
}
|
||||
return sb.Or(conds...), nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetThreadCursor(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, spanID string) (*spantypes.ThreadCursor, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("toUnixTimestamp64Nano(timestamp)")
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
sb.E("span_id", spanID),
|
||||
)
|
||||
sb.Limit(1)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var timeUnixNano int64
|
||||
if err := s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&timeUnixNano); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.NewNotFoundf(spantypes.ErrCodeThreadSpanNotFound, "span %s not found in trace %s", spanID, traceID)
|
||||
}
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying thread span")
|
||||
}
|
||||
return &spantypes.ThreadCursor{TimeUnixNano: uint64(timeUnixNano), SpanID: spanID}, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
@@ -451,7 +196,7 @@ func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, sta
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
|
||||
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
|
||||
fieldExpr, err := buildFieldExpr(fieldKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -461,8 +206,8 @@ func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, bo
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", summary.End.Unix()),
|
||||
"notEmpty("+fieldExpr+")",
|
||||
)
|
||||
sb.GroupBy("field_value")
|
||||
@@ -479,7 +224,7 @@ func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, bo
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
|
||||
func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
|
||||
fieldExpr, err := buildFieldExpr(fieldKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -495,8 +240,8 @@ func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string,
|
||||
allSpansSB.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
allSpansSB.Where(
|
||||
allSpansSB.E("trace_id", traceID),
|
||||
allSpansSB.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
allSpansSB.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
allSpansSB.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
allSpansSB.LE("ts_bucket_start", summary.End.Unix()),
|
||||
"notEmpty(field_value)",
|
||||
)
|
||||
allSpansSB.OrderByAsc("timestamp")
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,23 +6,18 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Handler exposes HTTP handlers for trace detail APIs.
|
||||
type Handler interface {
|
||||
GetTraceSummary(http.ResponseWriter, *http.Request)
|
||||
GetWaterfallV4(http.ResponseWriter, *http.Request)
|
||||
GetTraceAggregations(http.ResponseWriter, *http.Request)
|
||||
GetFlamegraph(http.ResponseWriter, *http.Request)
|
||||
GetThread(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
type Module interface {
|
||||
GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error)
|
||||
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
|
||||
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
|
||||
GetThread(ctx context.Context, orgID valuer.UUID, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error)
|
||||
}
|
||||
|
||||
@@ -566,6 +566,24 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// flattenJSONPaths flattens a decoded JSON document into dotted keys, overwriting existing keys in out.
|
||||
func flattenJSONPaths(prefix string, m map[string]any, out map[string]any) {
|
||||
for k, v := range m {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
switch child := v.(type) {
|
||||
case map[string]any:
|
||||
flattenJSONPaths(key, child, out)
|
||||
case telemetrystoretypes.JSONValue:
|
||||
flattenJSONPaths(key, child, out)
|
||||
default:
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into
|
||||
// unified "attributes" and "resource" keys, and parses the stringified `events`
|
||||
// and `links` columns into structured slices. Raw DB columns are removed.
|
||||
@@ -580,7 +598,7 @@ func mergeSpanAttributeColumns(data map[string]any) {
|
||||
resStr, hasRes := data["resources_string"]
|
||||
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
|
||||
attributes := make(map[string]any)
|
||||
attrJSON.FlattenInto("", attributes)
|
||||
flattenJSONPaths("", attrJSON, attributes)
|
||||
if m, ok := attrStr.(map[string]string); ok {
|
||||
for k, v := range m {
|
||||
attributes[k] = v
|
||||
|
||||
@@ -161,7 +161,7 @@ func NewModules(
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, fl), providerSettings, config.TraceDetail),
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: spanMapper,
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
|
||||
Tag: tagModule,
|
||||
|
||||
@@ -260,7 +260,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddChannelSpecFactory(sqlschema),
|
||||
sqlmigration.NewAddUserTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddRuleViewFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
// Mirrors the zeus LLMPriceFilter and its default model authors.
|
||||
// https://github.com/SigNoz/zeus/pull/591/changes
|
||||
var (
|
||||
llmPricingRuleProviders = map[string]struct{}{
|
||||
"openai": {}, "anthropic": {}, "google": {}, "mistralai": {}, "deepseek": {},
|
||||
"qwen": {}, "x-ai": {}, "meta-llama": {}, "cohere": {}, "amazon": {},
|
||||
}
|
||||
qwenHostedModelPattern = regexp.MustCompile(`max|plus|flash|turbo`)
|
||||
)
|
||||
|
||||
type llmPricingRuleSyncedRow struct {
|
||||
bun.BaseModel `bun:"table:llm_pricing_rule"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
Provider string `bun:"provider"`
|
||||
Model string `bun:"model"`
|
||||
Pricing string `bun:"pricing"`
|
||||
}
|
||||
|
||||
type llmPricingRulePrices struct {
|
||||
Input float64 `json:"input"`
|
||||
Output float64 `json:"output"`
|
||||
}
|
||||
|
||||
type keepRelevantLLMPricingRules struct {
|
||||
settings factory.ProviderSettings
|
||||
}
|
||||
|
||||
func NewKeepRelevantLLMPricingRulesFactory() factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("keep_relevant_llm_pricing_rules"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &keepRelevantLLMPricingRules{settings: ps}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *keepRelevantLLMPricingRules) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *keepRelevantLLMPricingRules) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var rows []*llmPricingRuleSyncedRow
|
||||
if err := tx.NewSelect().
|
||||
Model(&rows).
|
||||
Where("source_id IS NOT NULL").
|
||||
Where("NOT is_override").
|
||||
Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ids := make([]string, 0)
|
||||
for _, row := range rows {
|
||||
var prices llmPricingRulePrices
|
||||
if err := json.Unmarshal([]byte(row.Pricing), &prices); err != nil {
|
||||
migration.settings.Logger.WarnContext(ctx, "llm pricing rule has unparseable pricing, leaving it untouched", slog.String("rule_id", row.ID), slog.String("raw_pricing", row.Pricing))
|
||||
continue
|
||||
}
|
||||
if !llmPricingRuleRelevant(row.Provider, row.Model, prices) {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ids) > 0 {
|
||||
if _, err := tx.NewDelete().
|
||||
Model((*llmPricingRuleSyncedRow)(nil)).
|
||||
Where("id IN (?)", bun.In(ids)).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
migration.settings.Logger.InfoContext(ctx, "deleted irrelevant llm pricing rules", slog.Int("total", len(rows)), slog.Int("deleted", len(ids)))
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *keepRelevantLLMPricingRules) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The provider allowlist also rejects "~" alias ids, whose provider segment
|
||||
// starts with "~", and ids without a "/" that were stored as "unknown".
|
||||
func llmPricingRuleRelevant(provider, model string, prices llmPricingRulePrices) bool {
|
||||
if _, ok := llmPricingRuleProviders[provider]; !ok {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(model, ":") {
|
||||
return false
|
||||
}
|
||||
if prices.Input <= 0 || prices.Output <= 0 {
|
||||
return false
|
||||
}
|
||||
if provider == "qwen" && !qwenHostedModelPattern.MatchString(model) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -15,12 +15,11 @@ var (
|
||||
aiobservabilitytypes.GenAIAgentName: genAIAttribute(aiobservabilitytypes.GenAIAgentName, telemetrytypes.FieldDataTypeString),
|
||||
aiobservabilitytypes.GenAIProviderName: genAIAttribute(aiobservabilitytypes.GenAIProviderName, telemetrytypes.FieldDataTypeString),
|
||||
|
||||
aiobservabilitytypes.GenAIUsageInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageInputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageOutputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageCacheReadInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheReadInputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageCacheCreationInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageReasoningOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageReasoningOutputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.SignozGenAITotalCost: genAIAttribute(aiobservabilitytypes.SignozGenAITotalCost, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageInputTokens, telemetrytypes.FieldDataTypeFloat64),
|
||||
aiobservabilitytypes.GenAIUsageOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageOutputTokens, telemetrytypes.FieldDataTypeFloat64),
|
||||
aiobservabilitytypes.GenAIUsageCacheReadInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheReadInputTokens, telemetrytypes.FieldDataTypeFloat64),
|
||||
aiobservabilitytypes.GenAIUsageCacheCreationInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, telemetrytypes.FieldDataTypeFloat64),
|
||||
aiobservabilitytypes.SignozGenAITotalCost: genAIAttribute(aiobservabilitytypes.SignozGenAITotalCost, telemetrytypes.FieldDataTypeFloat64),
|
||||
|
||||
aiobservabilitytypes.GenAIInputMessages: genAIAttribute(aiobservabilitytypes.GenAIInputMessages, telemetrytypes.FieldDataTypeString),
|
||||
aiobservabilitytypes.GenAIOutputMessages: genAIAttribute(aiobservabilitytypes.GenAIOutputMessages, telemetrytypes.FieldDataTypeString),
|
||||
|
||||
@@ -15,7 +15,6 @@ const (
|
||||
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
||||
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
||||
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
|
||||
GenAIUsageReasoningOutputTokens = "gen_ai.usage.reasoning.output_tokens"
|
||||
|
||||
GenAIInputMessages = "gen_ai.input.messages"
|
||||
GenAIOutputMessages = "gen_ai.output.messages"
|
||||
|
||||
@@ -31,15 +31,12 @@ type SpanMapperStore interface {
|
||||
|
||||
// TraceStore defines the data access interface for trace detail queries.
|
||||
type TraceStore interface {
|
||||
GetTraceBounds(ctx context.Context, traceID string) (*TraceBounds, error)
|
||||
GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds) (*TraceStats, error)
|
||||
GetTraceSpans(ctx context.Context, traceID string, bounds *TraceBounds) ([]StorableSpan, error)
|
||||
GetTraceSummary(ctx context.Context, traceID string) (*TraceSummary, error)
|
||||
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
|
||||
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
|
||||
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
GetThreadSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds, page ThreadPage) ([]StorableSpan, error)
|
||||
GetThreadCursor(ctx context.Context, traceID string, bounds *TraceBounds, spanID string) (*ThreadCursor, error)
|
||||
|
||||
GetSpanCountByField(ctx context.Context, traceID string, bounds *TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
|
||||
GetSpanDurationByField(ctx context.Context, traceID string, bounds *TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
|
||||
GetSpanCountByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
|
||||
GetSpanDurationByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
threadDefaultLimit = 20
|
||||
threadMaxLimit = 100
|
||||
)
|
||||
|
||||
const (
|
||||
ThreadAfter ThreadFrom = iota // > cursor, ascending
|
||||
ThreadAt // >= cursor, ascending
|
||||
ThreadBefore // < cursor, descending
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeThreadInvalidLimit = errors.MustNewCode("trace_thread_invalid_limit")
|
||||
ErrCodeThreadInvalidCursor = errors.MustNewCode("trace_thread_invalid_cursor")
|
||||
ErrCodeThreadInvalidAnchor = errors.MustNewCode("trace_thread_invalid_anchor")
|
||||
ErrCodeThreadSpanNotFound = errors.MustNewCode("trace_thread_span_not_found")
|
||||
)
|
||||
|
||||
type GetTraceThreadParams struct {
|
||||
Limit int `query:"limit" description:"Page size, at most 100. 0 means 20."`
|
||||
After string `query:"after" description:"The nextCursor of a page; returns the spans after it. Set only one of after, before and spanId."`
|
||||
Before string `query:"before" description:"The prevCursor of a page; returns the spans before it. Set only one of after, before and spanId."`
|
||||
SpanID string `query:"spanId" description:"Returns the page around this span. Set only one of after, before and spanId."`
|
||||
}
|
||||
|
||||
type ThreadQuery struct {
|
||||
Limit int
|
||||
After *ThreadCursor
|
||||
Before *ThreadCursor
|
||||
SpanID string
|
||||
}
|
||||
|
||||
// ThreadCursor is the (TimeUnixNano, SpanID) key of a span.
|
||||
type ThreadCursor struct {
|
||||
TimeUnixNano uint64 `json:"timeUnixNano"`
|
||||
SpanID string `json:"spanId"`
|
||||
}
|
||||
|
||||
type ThreadFrom int
|
||||
|
||||
type ThreadPage struct {
|
||||
Cursor *ThreadCursor
|
||||
From ThreadFrom
|
||||
Limit int
|
||||
}
|
||||
|
||||
type GettableTraceThread struct {
|
||||
Spans []*ThreadSpan `json:"spans" required:"true" nullable:"false"`
|
||||
PrevCursor string `json:"prevCursor,omitempty"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
// ThreadSpan carries the fields the span details pane reads; snake_case keys match WaterfallSpan.
|
||||
type ThreadSpan struct {
|
||||
SpanID string `json:"span_id" required:"true"`
|
||||
TraceID string `json:"trace_id" required:"true"`
|
||||
ParentSpanID string `json:"parent_span_id" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
KindString string `json:"kind_string" required:"true"`
|
||||
TimeUnix uint64 `json:"time_unix" required:"true"`
|
||||
DurationNano uint64 `json:"duration_nano" required:"true"`
|
||||
HasError bool `json:"has_error" required:"true"`
|
||||
StatusCodeString string `json:"status_code_string" required:"true"`
|
||||
StatusMessage string `json:"status_message" required:"true"`
|
||||
Resource map[string]string `json:"resource" required:"true" nullable:"false"`
|
||||
Attributes map[string]any `json:"attributes" required:"true" nullable:"false"`
|
||||
Events []Event `json:"events" required:"true" nullable:"false"`
|
||||
References []OtelSpanRef `json:"references" required:"true" nullable:"false"`
|
||||
|
||||
timeUnixNano uint64
|
||||
}
|
||||
|
||||
func NewThreadQuery(params *GetTraceThreadParams) (*ThreadQuery, error) {
|
||||
query := &ThreadQuery{Limit: params.Limit, SpanID: params.SpanID}
|
||||
if query.Limit < 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidLimit, "limit cannot be negative, got %d", query.Limit)
|
||||
}
|
||||
if query.Limit == 0 {
|
||||
query.Limit = threadDefaultLimit
|
||||
}
|
||||
if query.Limit > threadMaxLimit {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidLimit, "limit cannot exceed %d, got %d", threadMaxLimit, query.Limit)
|
||||
}
|
||||
|
||||
anchors := 0
|
||||
for _, value := range []string{params.After, params.Before, params.SpanID} {
|
||||
if value != "" {
|
||||
anchors++
|
||||
}
|
||||
}
|
||||
if anchors > 1 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidAnchor, "only one of after, before and spanId can be set")
|
||||
}
|
||||
|
||||
encoded := params.After
|
||||
if encoded == "" {
|
||||
encoded = params.Before
|
||||
}
|
||||
if encoded == "" {
|
||||
return query, nil
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeThreadInvalidCursor, "invalid cursor")
|
||||
}
|
||||
cursor := new(ThreadCursor)
|
||||
if err := json.Unmarshal(data, cursor); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeThreadInvalidCursor, "invalid cursor")
|
||||
}
|
||||
if cursor.SpanID == "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidCursor, "invalid cursor: missing span id")
|
||||
}
|
||||
if params.After != "" {
|
||||
query.After = cursor
|
||||
} else {
|
||||
query.Before = cursor
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func (c ThreadCursor) Encode() string {
|
||||
data, _ := json.Marshal(c)
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// NewGettableTraceThread takes up to limit+1 spans on each side of the anchor: before in
|
||||
// descending order, after in ascending order. Half the page goes to before, the rest to after,
|
||||
// and a short side gives its room to the other. An extra span on a side sets that side's cursor.
|
||||
func NewGettableTraceThread(traceID string, query *ThreadQuery, before, after []StorableSpan) *GettableTraceThread {
|
||||
nAfter := min(len(after), query.Limit-min(len(before), query.Limit/2))
|
||||
nBefore := min(len(before), query.Limit-nAfter)
|
||||
hasPrev := len(before) > nBefore || query.After != nil
|
||||
hasNext := len(after) > nAfter || query.Before != nil
|
||||
|
||||
spans := make([]*ThreadSpan, 0, nBefore+nAfter)
|
||||
for i := nBefore - 1; i >= 0; i-- {
|
||||
spans = append(spans, newThreadSpan(traceID, &before[i]))
|
||||
}
|
||||
for i := range nAfter {
|
||||
spans = append(spans, newThreadSpan(traceID, &after[i]))
|
||||
}
|
||||
|
||||
thread := &GettableTraceThread{Spans: spans}
|
||||
if len(spans) == 0 {
|
||||
return thread
|
||||
}
|
||||
if hasPrev {
|
||||
thread.PrevCursor = spans[0].cursor().Encode()
|
||||
}
|
||||
if hasNext {
|
||||
thread.NextCursor = spans[len(spans)-1].cursor().Encode()
|
||||
}
|
||||
return thread
|
||||
}
|
||||
|
||||
func (s *ThreadSpan) cursor() ThreadCursor {
|
||||
return ThreadCursor{TimeUnixNano: s.timeUnixNano, SpanID: s.SpanID}
|
||||
}
|
||||
|
||||
func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
|
||||
resources := make(map[string]string, len(storable.ResourcesString))
|
||||
maps.Copy(resources, storable.ResourcesString)
|
||||
timeUnixNano := uint64(storable.StartTime.UnixNano())
|
||||
return &ThreadSpan{
|
||||
SpanID: storable.SpanID,
|
||||
TraceID: traceID,
|
||||
ParentSpanID: storable.ParentSpanID,
|
||||
Name: storable.Name,
|
||||
KindString: storable.SpanKind,
|
||||
TimeUnix: timeUnixNano / 1_000_000, // client expects millis, as in the waterfall
|
||||
DurationNano: storable.DurationNano,
|
||||
HasError: storable.HasError,
|
||||
StatusCodeString: storable.StatusCodeString,
|
||||
StatusMessage: storable.StatusMessage,
|
||||
Resource: resources,
|
||||
Attributes: threadAttributes(storable),
|
||||
Events: storable.UnmarshalledEvents(),
|
||||
References: storable.UnmarshalledRefs(),
|
||||
timeUnixNano: timeUnixNano,
|
||||
}
|
||||
}
|
||||
|
||||
// threadAttributes reads the JSON column and falls back to the legacy maps for spans written
|
||||
// before the JSON rollout.
|
||||
func threadAttributes(storable *StorableSpan) map[string]any {
|
||||
if len(storable.AttributesJSON) > 0 {
|
||||
attributes := make(map[string]any, len(storable.AttributesJSON))
|
||||
storable.AttributesJSON.FlattenInto("", attributes)
|
||||
return attributes
|
||||
}
|
||||
return storable.Attributes()
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewThreadQuery_Cursor(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
cursor string
|
||||
want *ThreadCursor
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "EncodedCursor_RoundTrips", cursor: ThreadCursor{TimeUnixNano: 1757500000123456789, SpanID: "f1fa1bc863e94dd0"}.Encode(), want: &ThreadCursor{TimeUnixNano: 1757500000123456789, SpanID: "f1fa1bc863e94dd0"}},
|
||||
{name: "NotBase64_Rejected", cursor: "not base64!", wantErr: true},
|
||||
{name: "NotJSON_Rejected", cursor: "bm90IGpzb24", wantErr: true},
|
||||
{name: "MissingSpanID_Rejected", cursor: "eyJ0aW1lVW5peE5hbm8iOiAxfQ", wantErr: true},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
after, err := NewThreadQuery(&GetTraceThreadParams{After: testCase.cursor})
|
||||
before, errBefore := NewThreadQuery(&GetTraceThreadParams{Before: testCase.cursor})
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Error(t, errBefore)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, errBefore)
|
||||
assert.Equal(t, testCase.want, after.After)
|
||||
assert.Nil(t, after.Before)
|
||||
assert.Equal(t, testCase.want, before.Before)
|
||||
assert.Nil(t, before.After)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadAttributes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
span StorableSpan
|
||||
wantAttrs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "JSONColumnPresent_LegacyMapsIgnored",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"request": map[string]any{"model": "json"}}},
|
||||
AttributesString: map[string]string{"gen_ai.request.model": "map", "http.method": "GET"},
|
||||
},
|
||||
wantAttrs: map[string]any{"gen_ai.request.model": "json"},
|
||||
},
|
||||
{
|
||||
name: "NoJSONColumn_FallsBackToLegacyMaps",
|
||||
span: StorableSpan{
|
||||
AttributesString: map[string]string{"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`},
|
||||
AttributesNumber: map[string]float64{"gen_ai.usage.input_tokens": 12},
|
||||
AttributesBool: map[string]bool{"gen_ai.stream": true},
|
||||
},
|
||||
wantAttrs: map[string]any{
|
||||
"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`,
|
||||
"gen_ai.usage.input_tokens": float64(12),
|
||||
"gen_ai.stream": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.wantAttrs, threadAttributes(&testCase.span))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
// TraceStats is the single-row result of the trace summary aggregate query.
|
||||
type TraceStats struct {
|
||||
StartNs uint64
|
||||
EndNs uint64
|
||||
RootServiceName string
|
||||
RootEntryPoint string
|
||||
RootSpanStatusCode string
|
||||
TotalSpans uint64
|
||||
TotalErrorSpans uint64
|
||||
HasMissingSpans bool
|
||||
GenAISpanCount uint64
|
||||
Tokens TraceAITokens
|
||||
TotalCost *float64
|
||||
}
|
||||
|
||||
// GettableTraceSummary is the response for the trace summary API; the trace-level
|
||||
// fields match the waterfall response.
|
||||
type GettableTraceSummary struct {
|
||||
StartTimestampMillis uint64 `json:"startTimestampMillis" required:"true"`
|
||||
EndTimestampMillis uint64 `json:"endTimestampMillis" required:"true"`
|
||||
RootServiceName string `json:"rootServiceName" required:"true"`
|
||||
RootServiceEntryPoint string `json:"rootServiceEntryPoint" required:"true"`
|
||||
RootSpanStatusCode string `json:"rootSpanStatusCode" required:"true"`
|
||||
TotalSpansCount uint64 `json:"totalSpansCount" required:"true"`
|
||||
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount" required:"true"`
|
||||
HasMissingSpans bool `json:"hasMissingSpans" required:"true"`
|
||||
AI *TraceAISummary `json:"ai,omitempty"`
|
||||
}
|
||||
|
||||
// TraceAISummary is present when any span carries a gen_ai gate key.
|
||||
type TraceAISummary struct {
|
||||
Tokens TraceAITokens `json:"tokens" required:"true"`
|
||||
// TotalCost is omitted when no span carries a cost attribute.
|
||||
TotalCost *float64 `json:"totalCost,omitempty" nullable:"false"`
|
||||
}
|
||||
|
||||
type TraceAITokens struct {
|
||||
Input uint64 `json:"input" required:"true"`
|
||||
Output uint64 `json:"output" required:"true"`
|
||||
CacheRead uint64 `json:"cacheRead" required:"true"`
|
||||
CacheWrite uint64 `json:"cacheWrite" required:"true"`
|
||||
Reasoning uint64 `json:"reasoning" required:"true"`
|
||||
}
|
||||
|
||||
func NewGettableTraceSummary(stats *TraceStats) *GettableTraceSummary {
|
||||
summary := &GettableTraceSummary{
|
||||
StartTimestampMillis: stats.StartNs / 1_000_000,
|
||||
EndTimestampMillis: stats.EndNs / 1_000_000,
|
||||
RootServiceName: stats.RootServiceName,
|
||||
RootServiceEntryPoint: stats.RootEntryPoint,
|
||||
RootSpanStatusCode: stats.RootSpanStatusCode,
|
||||
TotalSpansCount: stats.TotalSpans,
|
||||
TotalErrorSpansCount: stats.TotalErrorSpans,
|
||||
HasMissingSpans: stats.HasMissingSpans,
|
||||
}
|
||||
if stats.GenAISpanCount > 0 {
|
||||
summary.AI = &TraceAISummary{Tokens: stats.Tokens, TotalCost: stats.TotalCost}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
@@ -94,36 +93,35 @@ type WaterfallSpan struct {
|
||||
|
||||
// StorableSpan is the ClickHouse scan struct for the v3 waterfall query.
|
||||
type StorableSpan struct {
|
||||
StartTime time.Time `ch:"timestamp"`
|
||||
DurationNano uint64 `ch:"duration_nano"`
|
||||
SpanID string `ch:"span_id"`
|
||||
HasError bool `ch:"has_error"`
|
||||
Kind int8 `ch:"kind"`
|
||||
ServiceName string `ch:"resource_string_service$$name"`
|
||||
Name string `ch:"name"`
|
||||
AttributesString map[string]string `ch:"attributes_string"`
|
||||
AttributesNumber map[string]float64 `ch:"attributes_number"`
|
||||
AttributesBool map[string]bool `ch:"attributes_bool"`
|
||||
AttributesJSON telemetrystoretypes.JSONValue `ch:"attributes"`
|
||||
ResourcesString map[string]string `ch:"resources_string"`
|
||||
Events []string `ch:"events"`
|
||||
StatusMessage string `ch:"status_message"`
|
||||
StatusCodeString string `ch:"status_code_string"`
|
||||
SpanKind string `ch:"kind_string"`
|
||||
ParentSpanID string `ch:"parent_span_id"`
|
||||
Flags uint32 `ch:"flags"`
|
||||
IsRemote string `ch:"is_remote"`
|
||||
TraceState string `ch:"trace_state"`
|
||||
StatusCode int16 `ch:"status_code"`
|
||||
DBName string `ch:"db_name"`
|
||||
DBOperation string `ch:"db_operation"`
|
||||
HTTPMethod string `ch:"http_method"`
|
||||
HTTPURL string `ch:"http_url"`
|
||||
HTTPHost string `ch:"http_host"`
|
||||
ExternalHTTPMethod string `ch:"external_http_method"`
|
||||
ExternalHTTPURL string `ch:"external_http_url"`
|
||||
ResponseStatusCode string `ch:"response_status_code"`
|
||||
References string `ch:"references"`
|
||||
StartTime time.Time `ch:"timestamp"`
|
||||
DurationNano uint64 `ch:"duration_nano"`
|
||||
SpanID string `ch:"span_id"`
|
||||
HasError bool `ch:"has_error"`
|
||||
Kind int8 `ch:"kind"`
|
||||
ServiceName string `ch:"resource_string_service$$name"`
|
||||
Name string `ch:"name"`
|
||||
AttributesString map[string]string `ch:"attributes_string"`
|
||||
AttributesNumber map[string]float64 `ch:"attributes_number"`
|
||||
AttributesBool map[string]bool `ch:"attributes_bool"`
|
||||
ResourcesString map[string]string `ch:"resources_string"`
|
||||
Events []string `ch:"events"`
|
||||
StatusMessage string `ch:"status_message"`
|
||||
StatusCodeString string `ch:"status_code_string"`
|
||||
SpanKind string `ch:"kind_string"`
|
||||
ParentSpanID string `ch:"parent_span_id"`
|
||||
Flags uint32 `ch:"flags"`
|
||||
IsRemote string `ch:"is_remote"`
|
||||
TraceState string `ch:"trace_state"`
|
||||
StatusCode int16 `ch:"status_code"`
|
||||
DBName string `ch:"db_name"`
|
||||
DBOperation string `ch:"db_operation"`
|
||||
HTTPMethod string `ch:"http_method"`
|
||||
HTTPURL string `ch:"http_url"`
|
||||
HTTPHost string `ch:"http_host"`
|
||||
ExternalHTTPMethod string `ch:"external_http_method"`
|
||||
ExternalHTTPURL string `ch:"external_http_url"`
|
||||
ResponseStatusCode string `ch:"response_status_code"`
|
||||
References string `ch:"references"`
|
||||
}
|
||||
|
||||
// MinimalSpan with only the fields needed to build the parent-child tree.
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/cachetypes"
|
||||
)
|
||||
|
||||
type TraceBounds struct {
|
||||
type TraceSummary struct {
|
||||
TraceID string `ch:"trace_id"`
|
||||
Start time.Time `ch:"start"`
|
||||
End time.Time `ch:"end"`
|
||||
|
||||
@@ -35,21 +35,3 @@ func (v *JSONValue) Scan(src any) error {
|
||||
*v = decoded
|
||||
return nil
|
||||
}
|
||||
|
||||
// FlattenInto writes v into out under dotted keys, overwriting existing keys.
|
||||
func (v JSONValue) FlattenInto(prefix string, out map[string]any) {
|
||||
for k, value := range v {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
switch child := value.(type) {
|
||||
case map[string]any:
|
||||
JSONValue(child).FlattenInto(key, out)
|
||||
case JSONValue:
|
||||
child.FlattenInto(key, out)
|
||||
default:
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,10 @@ SIGNOZ_E2E_BASE_URL=https://app.us.staging.signoz.cloud
|
||||
# Test credentials (required only when SIGNOZ_E2E_BASE_URL is set — i.e. staging mode)
|
||||
SIGNOZ_E2E_USERNAME=
|
||||
SIGNOZ_E2E_PASSWORD=
|
||||
|
||||
# Base path mode. `pnpm env:start:base-path` serves SigNoz under /signoz, the
|
||||
# way SIGNOZ_GLOBAL_EXTERNAL__URL deploys it, and writes this to .env.local;
|
||||
# specs then run unchanged (see helpers/base-path.ts). The root and base path
|
||||
# stacks share one database, so stop one and run `pnpm env:clean` before
|
||||
# starting the other.
|
||||
# SIGNOZ_E2E_BASE_PATH=/signoz
|
||||
|
||||
0
tests/e2e/bootstrap/basepath/__init__.py
Normal file
0
tests/e2e/bootstrap/basepath/__init__.py
Normal file
55
tests/e2e/bootstrap/basepath/conftest.py
Normal file
55
tests/e2e/bootstrap/basepath/conftest.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import license_applier, register_admin
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
# The e2e stack served under a URL path prefix, as SIGNOZ_GLOBAL_EXTERNAL__URL
|
||||
# deploys it. These shadow the root fixtures of the same name; the separate
|
||||
# cache keys keep the two stacks from restoring each other's containers.
|
||||
BASE_PATH = "/signoz"
|
||||
|
||||
|
||||
@pytest.fixture(name="base_path", scope="package")
|
||||
def base_path() -> str:
|
||||
return BASE_PATH
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz_base_path( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
e2e_env: dict,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz_base_path",
|
||||
env_overrides=e2e_env | {"SIGNOZ_GLOBAL_EXTERNAL__URL": f"http://localhost:8080{BASE_PATH}"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="create_user_admin", scope="package")
|
||||
def create_user_admin_base_path(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
|
||||
return register_admin(signoz, request, pytestconfig, cache_key="create_user_admin_base_path", base_path=BASE_PATH)
|
||||
|
||||
|
||||
@pytest.fixture(name="apply_license", scope="package")
|
||||
def apply_license_base_path(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.Operation:
|
||||
return license_applier(signoz, request, pytestconfig, cache_key="apply_license_base_path", base_path=BASE_PATH)
|
||||
5
tests/e2e/bootstrap/basepath/setup.py
Normal file
5
tests/e2e/bootstrap/basepath/setup.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# The root bootstrap's tests, collected again here so they resolve the
|
||||
# base-path fixtures from this package's conftest.py.
|
||||
from e2e.bootstrap.setup import test_setup, test_teardown
|
||||
|
||||
__all__ = ["test_setup", "test_teardown"]
|
||||
45
tests/e2e/bootstrap/conftest.py
Normal file
45
tests/e2e/bootstrap/conftest.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
|
||||
@pytest.fixture(name="base_path", scope="package")
|
||||
def base_path() -> str:
|
||||
"""URL path prefix SigNoz is served under. Empty serves it at the root;
|
||||
basepath/conftest.py overrides it."""
|
||||
return ""
|
||||
|
||||
|
||||
@pytest.fixture(name="e2e_env", scope="package")
|
||||
def e2e_env() -> dict:
|
||||
"""Environment every e2e SigNoz gets. Each Playwright worker logs in once,
|
||||
and the default cap of 5 sessions per user evicts the oldest, which turns a
|
||||
worker's requests into 401s mid-run."""
|
||||
return {"SIGNOZ_TOKENIZER_OPAQUE_TOKEN_MAX__PER__USER": 100}
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
tls: types.TLS,
|
||||
e2e_env: dict,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
tls=tls,
|
||||
env_overrides=e2e_env,
|
||||
)
|
||||
@@ -15,11 +15,12 @@ def _env_file(pytestconfig: pytest.Config) -> Path:
|
||||
return pytestconfig.rootpath / "e2e" / ".env.local"
|
||||
|
||||
|
||||
def test_setup(
|
||||
def test_setup( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
apply_license: types.Operation, # pylint: disable=unused-argument
|
||||
seeder: types.TestContainerDocker,
|
||||
base_path: str,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> None:
|
||||
"""Bring the backend up and write e2e coordinates to .env.local."""
|
||||
@@ -33,6 +34,8 @@ def test_setup(
|
||||
f.write(f"SIGNOZ_E2E_USERNAME={USER_ADMIN_EMAIL}\n")
|
||||
f.write(f"SIGNOZ_E2E_PASSWORD={USER_ADMIN_PASSWORD}\n")
|
||||
f.write(f"SIGNOZ_E2E_SEEDER_URL={seeder_cfg.base()}\n")
|
||||
if base_path:
|
||||
f.write(f"SIGNOZ_E2E_BASE_PATH={base_path}\n")
|
||||
|
||||
|
||||
def test_teardown(
|
||||
|
||||
93
tests/e2e/drivers/kit.ts
generated
Normal file
93
tests/e2e/drivers/kit.ts
generated
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import type { Browser, Page, TestType } from '@playwright/test';
|
||||
|
||||
import { newAdminContext } from '../helpers/auth';
|
||||
|
||||
export { expect, test as kit } from '../fixtures/auth';
|
||||
|
||||
export interface DriverContext {
|
||||
page: Page;
|
||||
}
|
||||
|
||||
type Fixtures = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Adds test-scoped fixtures to any test carrying `authedPage`. `T` is inferred
|
||||
* from the argument: TypeScript cannot invert `T & { authedPage: Page }`, so
|
||||
* constraining that way widens every existing fixture to `unknown`.
|
||||
*/
|
||||
export type Layer<Adds> = <T extends { authedPage: Page }, W extends object>(
|
||||
base: TestType<T, W>,
|
||||
) => TestType<T & Adds, W>;
|
||||
|
||||
export type WorkerLayer<Adds> = <T extends object, W extends object>(
|
||||
base: TestType<T, W>,
|
||||
) => TestType<T, W & Adds>;
|
||||
|
||||
/** A `withX` layer binding `build` to the authenticated page. */
|
||||
export function driver<Name extends string, T>(
|
||||
name: Name,
|
||||
build: (ctx: DriverContext) => T,
|
||||
): Layer<{ [K in Name]: T }> {
|
||||
const fixture = {
|
||||
// Playwright reads fixture dependencies from the source text, so
|
||||
// `authedPage` must stay destructured literally.
|
||||
[name]: async (
|
||||
{ authedPage }: { authedPage: Page },
|
||||
use: (value: T) => Promise<void>,
|
||||
) => {
|
||||
await use(build({ page: authedPage }));
|
||||
},
|
||||
};
|
||||
return ((test: TestType<Fixtures, Fixtures>) =>
|
||||
test.extend(fixture as never)) as Layer<{ [K in Name]: T }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `withX` layer that seeds once per worker and tears down after. `open` and
|
||||
* `close` run on a throwaway admin page: worker fixtures cannot reach
|
||||
* `authedPage`.
|
||||
*/
|
||||
export function seeded<Name extends string, T>(
|
||||
name: Name,
|
||||
open: (ctx: { page: Page; workerIndex: number }) => Promise<T>,
|
||||
close?: (ctx: { page: Page; value: T }) => Promise<void>,
|
||||
options: { timeout?: number } = {},
|
||||
): WorkerLayer<{ [K in Name]: T }> {
|
||||
const fixture = {
|
||||
[name]: [
|
||||
async (
|
||||
{ browser }: { browser: Browser },
|
||||
use: (value: T) => Promise<void>,
|
||||
workerInfo: { workerIndex: number },
|
||||
) => {
|
||||
const value = await onAdminPage(browser, (page) =>
|
||||
open({ page, workerIndex: workerInfo.workerIndex }),
|
||||
);
|
||||
await use(value);
|
||||
if (close) {
|
||||
await onAdminPage(browser, (page) => close({ page, value }));
|
||||
}
|
||||
},
|
||||
{ scope: 'worker', timeout: options.timeout },
|
||||
],
|
||||
};
|
||||
return ((test: TestType<Fixtures, Fixtures>) =>
|
||||
test.extend(fixture as never)) as WorkerLayer<{ [K in Name]: T }>;
|
||||
}
|
||||
|
||||
/** Run `body` on a throwaway page authenticated as the admin. */
|
||||
export async function onAdminPage<T>(
|
||||
browser: Browser,
|
||||
body: (page: Page) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const ctx = await newAdminContext(browser);
|
||||
try {
|
||||
return await body(await ctx.newPage());
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
392
tests/e2e/drivers/routing.ts
generated
Normal file
392
tests/e2e/drivers/routing.ts
generated
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { ADMIN } from '../helpers/auth';
|
||||
import { authToken } from '../helpers/common';
|
||||
import {
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../helpers/dashboards';
|
||||
import { seedPodMetricsViaSeeder } from '../helpers/infra-monitoring';
|
||||
import { gotoLogsExplorer } from '../helpers/logs-explorer';
|
||||
import {
|
||||
gotoTraceUntilLoaded,
|
||||
type LargeTrace,
|
||||
loadLargeTrace,
|
||||
seedTracesViaSeeder,
|
||||
} from '../helpers/trace-details';
|
||||
|
||||
import { driver, kit, seeded } from './kit';
|
||||
|
||||
// The react-router v5 -> v7 regression net. Router-sensitive mechanics (history
|
||||
// depth, POP, same-url suppression, the `newTab` branch) live here so the specs
|
||||
// read as the property they guard.
|
||||
|
||||
export const PATH = {
|
||||
alerts: '/alerts',
|
||||
dashboards: '/dashboard',
|
||||
home: '/home',
|
||||
k8s: '/infrastructure-monitoring/kubernetes',
|
||||
login: '/login',
|
||||
metricsSummary: '/metrics-explorer/summary',
|
||||
metricsViews: '/metrics-explorer/views',
|
||||
roles: '/settings/roles',
|
||||
roleCreate: '/settings/roles/new',
|
||||
services: '/services',
|
||||
tracesExplorer: '/traces-explorer',
|
||||
/** Matches no entry of `AppRoutes/routes.ts`. */
|
||||
unknown: '/definitely-not-a-route',
|
||||
} as const;
|
||||
|
||||
/** A resource attribute every seeded pod carries. */
|
||||
const NAMESPACE_ATTR = 'k8s.namespace.name';
|
||||
|
||||
const RELATIVE_TIME_LABELS: Record<string, string> = {
|
||||
'30m': 'Last 30 minutes',
|
||||
'1h': 'Last 1 hour',
|
||||
'6h': 'Last 6 hours',
|
||||
};
|
||||
|
||||
/** Three browser projects share one backend, so names need more than a timestamp. */
|
||||
function uniqueSuffix(): string {
|
||||
return randomBytes(4).toString('hex');
|
||||
}
|
||||
|
||||
/** Role names allow lowercase letters and hyphens only. */
|
||||
export function uniqueAlphaSuffix(): string {
|
||||
return [...randomBytes(6)]
|
||||
.map((byte) => String.fromCharCode(97 + (byte % 26)))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function dashboardListParams(title: string): Record<string, string> {
|
||||
return { search: title, columnKey: 'updatedAt', order: 'descend', page: '1' };
|
||||
}
|
||||
|
||||
function groupByOf(url: URL): string {
|
||||
return url.searchParams.get('groupBy') ?? '';
|
||||
}
|
||||
|
||||
export interface Routing {
|
||||
goto(path: string): Promise<void>;
|
||||
reload(): Promise<void>;
|
||||
goBack(): Promise<void>;
|
||||
goForward(): Promise<void>;
|
||||
/** Go back one entry and wait for `pathname`. */
|
||||
goBackTo(pathname: string): Promise<void>;
|
||||
url(): URL;
|
||||
params(): Record<string, string>;
|
||||
waitForPath(pathname: string): Promise<void>;
|
||||
waitForParam(key: string, value: string): Promise<void>;
|
||||
/** `history.length`: Chromium keeps it on POP and REPLACE, grows it on PUSH. */
|
||||
historyDepth(): Promise<number>;
|
||||
/** Complete the login form on the page as it stands, without navigating. */
|
||||
login(): Promise<void>;
|
||||
/** Sidebar entries are `div.nav-item`, not links. */
|
||||
sidebarItem(label: string): Locator;
|
||||
/** antd puts a `RouteTab` item's route on `data-node-key`. */
|
||||
routeTab(route: string): Locator;
|
||||
activeRouteTab(route: string): Locator;
|
||||
/** The picker `TopNav` renders. Several pages mount their own. */
|
||||
globalTimePicker(): Locator;
|
||||
selectRelativeTime(value: string): Promise<void>;
|
||||
expectRelativeTime(value: string): Promise<void>;
|
||||
/** Ctrl/Cmd-click `target`, run `body` on the tab it opens, then close it. */
|
||||
inNewTab(
|
||||
target: Locator,
|
||||
body: (tab: Routing) => Promise<void>,
|
||||
): Promise<void>;
|
||||
/** Hold every script requested during `body`, so lazy route chunks suspend. */
|
||||
whileScriptsHeld(body: () => Promise<void>): Promise<void>;
|
||||
gotoLogsExplorer(): Promise<void>;
|
||||
dashboards: {
|
||||
gotoList(title?: string): Promise<void>;
|
||||
/** Not `dashboard-title-0`: the search tokenises on hyphens and matches leftovers. */
|
||||
row(title: string): Locator;
|
||||
landmark(): Locator;
|
||||
title(): Locator;
|
||||
};
|
||||
k8s: {
|
||||
/** Open the pods list once its mount-time `compositeQuery` rewrite landed. */
|
||||
goto(params: Record<string, string>): Promise<void>;
|
||||
/** One nuqs push: `groupBy`, `page` and `orderBy` flush together. */
|
||||
groupBy(attribute: string): Promise<void>;
|
||||
waitUntilGrouped(attribute: string, grouped?: boolean): Promise<void>;
|
||||
groupBySelect(): Locator;
|
||||
};
|
||||
trace: {
|
||||
path(trace: LargeTrace): string;
|
||||
goto(trace: LargeTrace): Promise<void>;
|
||||
rootRow(trace: LargeTrace): Locator;
|
||||
back(): Promise<void>;
|
||||
};
|
||||
roles: {
|
||||
/** Fill the create form far enough to arm the navigation blocker. */
|
||||
openCreateForm(name: string): Promise<void>;
|
||||
nameInput(): Locator;
|
||||
cancel(): Promise<void>;
|
||||
save(): Promise<void>;
|
||||
discardDialog(): Locator;
|
||||
settings(): Locator;
|
||||
removeByName(names: string[]): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export function routingDriver(page: Page): Routing {
|
||||
const timePicker = (): Locator => page.getByTestId('dropDown');
|
||||
|
||||
const routing: Routing = {
|
||||
goto: async (path) => {
|
||||
await page.goto(path);
|
||||
},
|
||||
reload: async () => {
|
||||
await page.reload();
|
||||
},
|
||||
goBack: async () => {
|
||||
await page.goBack();
|
||||
},
|
||||
goForward: async () => {
|
||||
await page.goForward();
|
||||
},
|
||||
goBackTo: async (pathname) => {
|
||||
await page.goBack();
|
||||
await routing.waitForPath(pathname);
|
||||
},
|
||||
|
||||
url: () => new URL(page.url()),
|
||||
params: () => Object.fromEntries(routing.url().searchParams),
|
||||
waitForPath: (pathname) =>
|
||||
page.waitForURL((url) => url.pathname === pathname),
|
||||
waitForParam: (key, value) =>
|
||||
page.waitForURL((url) => url.searchParams.get(key) === value),
|
||||
historyDepth: () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(globalThis as unknown as { history: { length: number } }).history.length,
|
||||
),
|
||||
|
||||
login: async () => {
|
||||
await page.getByTestId('email').fill(ADMIN.email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(ADMIN.password);
|
||||
await page.getByTestId('password_authn_submit').click();
|
||||
},
|
||||
|
||||
sidebarItem: (label) =>
|
||||
page.locator('.nav-item').filter({ hasText: new RegExp(`^${label}$`) }),
|
||||
routeTab: (route) => page.locator(`.ant-tabs-tab[data-node-key="${route}"]`),
|
||||
activeRouteTab: (route) =>
|
||||
routing.routeTab(route).getByRole('tab', { selected: true }),
|
||||
|
||||
globalTimePicker: () =>
|
||||
page.locator('.top-nav-container').getByTestId('dropDown'),
|
||||
|
||||
// A click before the picker is interactive is dropped silently, so retry the
|
||||
// open. The popover unmounts on close, which is what proves the selection
|
||||
// committed: the url may legitimately stay the same.
|
||||
selectRelativeTime: async (value) => {
|
||||
const option = page.getByTestId(`time-option-${value}`);
|
||||
await expect(async () => {
|
||||
if (!(await option.isVisible())) {
|
||||
await timePicker().click();
|
||||
}
|
||||
await expect(option).toBeVisible({ timeout: 3_000 });
|
||||
}).toPass({ timeout: 30_000 });
|
||||
await option.click();
|
||||
await expect(option).toBeHidden();
|
||||
},
|
||||
|
||||
expectRelativeTime: async (value) => {
|
||||
await expect(timePicker()).toHaveAccessibleName(RELATIVE_TIME_LABELS[value]);
|
||||
},
|
||||
|
||||
inNewTab: async (target, body) => {
|
||||
const [tab] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
target.click({ modifiers: ['ControlOrMeta'] }),
|
||||
]);
|
||||
try {
|
||||
await tab.waitForLoadState();
|
||||
await body(routingDriver(tab));
|
||||
} finally {
|
||||
await tab.close();
|
||||
}
|
||||
},
|
||||
|
||||
// The handler stays installed after the release: `unroute` drops the
|
||||
// requests still parked in it.
|
||||
whileScriptsHeld: async (body) => {
|
||||
let release = (): void => {};
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route('**/*.js', async (route) => {
|
||||
await held;
|
||||
await route.continue();
|
||||
});
|
||||
try {
|
||||
await body();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
},
|
||||
|
||||
gotoLogsExplorer: () => gotoLogsExplorer(page),
|
||||
|
||||
dashboards: {
|
||||
gotoList: async (title) => {
|
||||
if (!title) {
|
||||
await page.goto(PATH.dashboards);
|
||||
await expect(routing.dashboards.landmark()).toBeVisible();
|
||||
return;
|
||||
}
|
||||
const search = new URLSearchParams(dashboardListParams(title));
|
||||
await page.goto(`${PATH.dashboards}?${search.toString()}`);
|
||||
await expect(routing.dashboards.row(title)).toBeVisible();
|
||||
},
|
||||
row: (title) =>
|
||||
page
|
||||
.locator('[data-testid^="dashboard-title-"]')
|
||||
.filter({ hasText: title }),
|
||||
// The views rail renders whether or not the list is empty.
|
||||
landmark: () => page.getByTestId('dashboards-view-search'),
|
||||
title: () => page.getByTestId('dashboard-title'),
|
||||
},
|
||||
|
||||
k8s: {
|
||||
goto: async (params) => {
|
||||
const search = new URLSearchParams({ category: 'pods', ...params });
|
||||
await page.goto(`${PATH.k8s}?${search.toString()}`);
|
||||
await page.waitForURL((url) => url.searchParams.has('compositeQuery'));
|
||||
await expect(routing.k8s.groupBySelect()).toBeVisible();
|
||||
},
|
||||
// The search input stays readonly until the dropdown opens, and `hasText`
|
||||
// would also match longer keys, so type after opening and match the title.
|
||||
groupBy: async (attribute) => {
|
||||
await routing.k8s.groupBySelect().click();
|
||||
await page.keyboard.type(attribute);
|
||||
await page.locator(`.ant-select-item-option[title="${attribute}"]`).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await routing.k8s.waitUntilGrouped(attribute);
|
||||
},
|
||||
waitUntilGrouped: (attribute, grouped = true) =>
|
||||
page.waitForURL((url) => groupByOf(url).includes(attribute) === grouped),
|
||||
groupBySelect: () => page.getByTestId('k8s-table-group-by-select'),
|
||||
},
|
||||
|
||||
trace: {
|
||||
path: (trace) => `/trace/${trace.traceId}`,
|
||||
goto: (trace) =>
|
||||
gotoTraceUntilLoaded(
|
||||
page,
|
||||
routing.trace.path(trace),
|
||||
`cell-0-${trace.landmarks.root}`,
|
||||
),
|
||||
rootRow: (trace) => page.getByTestId(`cell-0-${trace.landmarks.root}`),
|
||||
back: async () => {
|
||||
await page.getByRole('button', { name: 'Back' }).click();
|
||||
},
|
||||
},
|
||||
|
||||
roles: {
|
||||
openCreateForm: async (name) => {
|
||||
await page.goto(PATH.roleCreate);
|
||||
await routing.roles.nameInput().fill(name);
|
||||
await expect(page.getByText('Unsaved changes')).toBeVisible();
|
||||
},
|
||||
nameInput: () => page.getByTestId('role-name-input'),
|
||||
cancel: async () => {
|
||||
await page.getByTestId('cancel-button').click();
|
||||
},
|
||||
save: async () => {
|
||||
await page.getByTestId('save-button').click();
|
||||
},
|
||||
discardDialog: () => page.getByTestId('discard-changes-dialog'),
|
||||
settings: () => page.getByTestId('roles-settings'),
|
||||
removeByName: async (names) => {
|
||||
const headers = { Authorization: `Bearer ${await authToken(page)}` };
|
||||
const res = await page.request.get('/api/v1/roles', { headers });
|
||||
const { data } = (await res.json()) as {
|
||||
data: { id: string; name: string }[];
|
||||
};
|
||||
await Promise.all(
|
||||
data
|
||||
.filter((role) => names.includes(role.name))
|
||||
.map((role) =>
|
||||
page.request.delete(`/api/v1/roles/${role.id}`, { headers }),
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return routing;
|
||||
}
|
||||
|
||||
// The seeder serves inserts on one ClickHouse session, so parallel workers
|
||||
// collide with a transient `500 concurrent queries within the same session`.
|
||||
const SEED_RETRY_MS = 120_000;
|
||||
const SEED_TIMEOUT_MS = 180_000;
|
||||
|
||||
async function seedWithRetry(seed: () => Promise<void>): Promise<void> {
|
||||
await expect(seed).toPass({
|
||||
timeout: SEED_RETRY_MS,
|
||||
intervals: [1_000, 2_000, 5_000],
|
||||
});
|
||||
}
|
||||
|
||||
const withRouting = driver('routing', ({ page }) => routingDriver(page));
|
||||
|
||||
const withDashboard = seeded(
|
||||
'dashboard',
|
||||
async ({ page }) => {
|
||||
const title = `routing-${uniqueSuffix()}`;
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
return { id, title, path: `${PATH.dashboards}/${id}` };
|
||||
},
|
||||
async ({ page, value }) =>
|
||||
deleteDashboardViaApi(page.request, value.id, await authToken(page)),
|
||||
);
|
||||
|
||||
const withTrace = seeded(
|
||||
'largeTrace',
|
||||
async ({ page }) => {
|
||||
const trace = loadLargeTrace();
|
||||
await seedWithRetry(() => seedTracesViaSeeder(page.request, trace.spans));
|
||||
return trace;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: SEED_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
// Group-by options come from `/fields/keys` over the queried window, so the
|
||||
// select stays empty until pod metrics exist inside it.
|
||||
const withPods = seeded(
|
||||
'pods',
|
||||
async ({ page }) => {
|
||||
await seedWithRetry(() => seedPodMetricsViaSeeder(page));
|
||||
return { groupBy: NAMESPACE_ATTR };
|
||||
},
|
||||
undefined,
|
||||
{ timeout: SEED_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
export const test = withPods(
|
||||
withTrace(withDashboard(withRouting(kit))),
|
||||
).extend<{
|
||||
/** The routing driver on an unauthenticated page. */
|
||||
guest: Routing;
|
||||
}>({
|
||||
guest: async ({ browser }, use) => {
|
||||
const ctx = await browser.newContext();
|
||||
await use(routingDriver(await ctx.newPage()));
|
||||
await ctx.close();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect, onAdminPage } from './kit';
|
||||
@@ -1,27 +1,42 @@
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
|
||||
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
|
||||
import { installBasePathMode } from '../helpers/base-path';
|
||||
|
||||
// The login flow and the per-worker session cache live in `helpers/auth.ts` so
|
||||
// worker-scoped fixtures and suite hooks share one login with this fixture.
|
||||
export { ADMIN };
|
||||
export type { User };
|
||||
|
||||
export const test = base.extend<{
|
||||
/**
|
||||
* User identity for this test. Override with `test.use({ user: ... })` at
|
||||
* the describe or test level to run the suite as a different user.
|
||||
* Defaults to ADMIN (the pytest-bootstrap-seeded admin).
|
||||
*/
|
||||
user: User;
|
||||
export const test = base.extend<
|
||||
{
|
||||
/**
|
||||
* User identity for this test. Override with `test.use({ user: ... })` at
|
||||
* the describe or test level to run the suite as a different user.
|
||||
* Defaults to ADMIN (the pytest-bootstrap-seeded admin).
|
||||
*/
|
||||
user: User;
|
||||
|
||||
/**
|
||||
* A Page whose context is already authenticated as `user`. First request
|
||||
* for a given user triggers one login per worker; the resulting
|
||||
* storageState is held in memory and reused for all later requests.
|
||||
*/
|
||||
authedPage: Page;
|
||||
},
|
||||
{
|
||||
/** Applies SIGNOZ_E2E_BASE_PATH before any page or login in the worker. */
|
||||
basePathMode: void;
|
||||
}
|
||||
>({
|
||||
basePathMode: [
|
||||
async ({ browser }, use) => {
|
||||
await installBasePathMode(browser);
|
||||
await use();
|
||||
},
|
||||
{ scope: 'worker', auto: true },
|
||||
],
|
||||
|
||||
/**
|
||||
* A Page whose context is already authenticated as `user`. First request
|
||||
* for a given user triggers one login per worker; the resulting
|
||||
* storageState is held in memory and reused for all later requests.
|
||||
*/
|
||||
authedPage: Page;
|
||||
}>({
|
||||
user: [ADMIN, { option: true }],
|
||||
|
||||
authedPage: async ({ browser, user }, use) => {
|
||||
|
||||
@@ -72,6 +72,7 @@ export async function gotoAlertList(
|
||||
await page.goto(`${ALERTS_LIST_PATH}?${query.toString()}`);
|
||||
await expect(page.getByTestId('list-alerts-search-input')).toBeVisible();
|
||||
if (expectRows) {
|
||||
await expect(alertRuleRows(page).first()).toBeVisible();
|
||||
// Skeleton rows are empty and ignore clicks; wait for a loaded one.
|
||||
await expect(alertRuleRows(page).first()).toHaveText(/\S/);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||
|
||||
import { storageKey } from './base-path';
|
||||
|
||||
export type User = { email: string; password: string };
|
||||
|
||||
/** Default user — admin from the pytest bootstrap (.env.local) or staging .env. */
|
||||
@@ -57,7 +59,8 @@ async function login(page: Page, user: User): Promise<void> {
|
||||
async function pinSidenav(page: Page): Promise<void> {
|
||||
const token = await page.evaluate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
(key) => (globalThis as any).localStorage.getItem(key) || '',
|
||||
storageKey('AUTH_TOKEN'),
|
||||
);
|
||||
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
|
||||
data: { value: true },
|
||||
|
||||
105
tests/e2e/helpers/base-path.ts
Normal file
105
tests/e2e/helpers/base-path.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { APIRequestContext, Browser, Page } from '@playwright/test';
|
||||
|
||||
// Base path mode: with SIGNOZ_E2E_BASE_PATH set (`pnpm env:start:base-path`
|
||||
// writes `/signoz`), specs run unchanged against SigNoz served under that URL
|
||||
// prefix. `page.goto`, `page.waitForURL` string targets and `request.*` take
|
||||
// app paths and get the prefix; `page.url()` and `waitForURL` predicates see
|
||||
// the URL without it. `expect(page).toHaveURL` still sees the real URL.
|
||||
|
||||
export const BASE_PATH = (process.env.SIGNOZ_E2E_BASE_PATH ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
|
||||
/** The app scopes its localStorage keys to the base path (`utils/storage.ts`). */
|
||||
export function storageKey(key: string): string {
|
||||
return BASE_PATH ? `${BASE_PATH}/${key}` : key;
|
||||
}
|
||||
|
||||
function toServedPath(path: string): string {
|
||||
return path.startsWith('/') && !path.startsWith('//')
|
||||
? `${BASE_PATH}${path}`
|
||||
: path;
|
||||
}
|
||||
|
||||
function toAppUrl(href: string): string {
|
||||
const url = new URL(href);
|
||||
if (url.pathname === BASE_PATH || url.pathname.startsWith(`${BASE_PATH}/`)) {
|
||||
url.pathname = url.pathname.slice(BASE_PATH.length) || '/';
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function patchMethod<T extends object, K extends keyof T>(
|
||||
proto: T,
|
||||
name: K,
|
||||
wrap: (original: T[K]) => T[K],
|
||||
): void {
|
||||
proto[name] = wrap(proto[name]);
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/**
|
||||
* Patches the Page and APIRequestContext prototypes once per worker. Playwright
|
||||
* exports neither class, so the prototypes come from a throwaway page.
|
||||
*/
|
||||
export async function installBasePathMode(browser: Browser): Promise<void> {
|
||||
if (!BASE_PATH || installed) {
|
||||
return;
|
||||
}
|
||||
installed = true;
|
||||
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const pageProto: Page = Object.getPrototypeOf(page);
|
||||
const requestProto: APIRequestContext = Object.getPrototypeOf(page.request);
|
||||
await context.close();
|
||||
|
||||
patchMethod(
|
||||
pageProto,
|
||||
'goto',
|
||||
(goto) =>
|
||||
function (this: Page, target, options) {
|
||||
return goto.call(this, toServedPath(target), options);
|
||||
},
|
||||
);
|
||||
|
||||
patchMethod(
|
||||
pageProto,
|
||||
'url',
|
||||
(url) =>
|
||||
function (this: Page) {
|
||||
return toAppUrl(url.call(this));
|
||||
},
|
||||
);
|
||||
|
||||
patchMethod(
|
||||
pageProto,
|
||||
'waitForURL',
|
||||
(waitForURL) =>
|
||||
function (this: Page, matcher, options) {
|
||||
let appMatcher = matcher;
|
||||
if (typeof matcher === 'function') {
|
||||
appMatcher = (current: URL): boolean =>
|
||||
matcher(new URL(toAppUrl(current.href)));
|
||||
} else if (typeof matcher === 'string') {
|
||||
appMatcher = toServedPath(matcher);
|
||||
}
|
||||
return waitForURL.call(this, appMatcher, options);
|
||||
},
|
||||
);
|
||||
|
||||
patchMethod(
|
||||
requestProto,
|
||||
'fetch',
|
||||
(fetch) =>
|
||||
function (this: APIRequestContext, target, options) {
|
||||
return fetch.call(
|
||||
this,
|
||||
typeof target === 'string' ? toServedPath(target) : target,
|
||||
options,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Page, Request } from '@playwright/test';
|
||||
|
||||
import { storageKey } from './base-path';
|
||||
|
||||
// Shared helpers used across feature-specific helper modules (dashboards,
|
||||
// trace-details, …). Keep this to genuinely cross-feature utilities.
|
||||
|
||||
@@ -45,6 +47,8 @@ const HARNESS_CONSOLE_NOISE = [
|
||||
'Request failed with status code 404',
|
||||
'client never received a response, or request never left',
|
||||
'ErrorResponseHandler: unclassified error',
|
||||
// Google Fonts, which the chromium project fails at DNS (playwright.config.ts).
|
||||
'Failed to load resource: net::ERR_NAME_NOT_RESOLVED',
|
||||
];
|
||||
|
||||
export interface ConsoleWatch {
|
||||
@@ -130,7 +134,9 @@ export function requestUrl(request: Request): URL {
|
||||
export async function authToken(page: Page): Promise<string> {
|
||||
const state = await page.context().storageState();
|
||||
for (const origin of state.origins) {
|
||||
const entry = origin.localStorage.find((e) => e.name === 'AUTH_TOKEN');
|
||||
const entry = origin.localStorage.find(
|
||||
(e) => e.name === storageKey('AUTH_TOKEN'),
|
||||
);
|
||||
if (entry) {
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import apmMetricsTemplate from '../testdata/apm-metrics.json';
|
||||
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
|
||||
import variablesTemplate from '../testdata/variables-dashboard.json';
|
||||
|
||||
import { storageKey } from './base-path';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// UI strings and well-known values referenced both within this file and by
|
||||
@@ -45,7 +47,8 @@ export async function authToken(page: Page): Promise<string> {
|
||||
}
|
||||
return page.evaluate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
(key) => (globalThis as any).localStorage.getItem(key) || '',
|
||||
storageKey('AUTH_TOKEN'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,12 +85,39 @@ async function postDashboard(
|
||||
return json.data.id;
|
||||
}
|
||||
|
||||
/** Seed a minimally-named dashboard via API. Returns the new ID. */
|
||||
/**
|
||||
* Seed a minimally-named dashboard via API. Returns the new ID.
|
||||
*
|
||||
* v2 (Perses spec) only: `POST /api/v1/dashboards` now answers 501
|
||||
* `dashboard_deprecated`. Payload mirrors what the New-dashboard modal sends
|
||||
* (`BlankDashboardPanel.tsx`), `schemaVersion` included.
|
||||
*/
|
||||
export async function createDashboardViaApi(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
return postDashboard(page, { title, uploadedGrafana: false });
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v2/dashboards', {
|
||||
data: {
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: null,
|
||||
spec: {
|
||||
display: { name: title },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`POST /api/v2/dashboards ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return json.data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,7 +372,7 @@ export async function deleteDashboardViaApi(
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
await request
|
||||
.delete(`/api/v1/dashboards/${id}`, {
|
||||
.delete(`/api/v2/dashboards/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
110
tests/e2e/helpers/infra-monitoring.ts
Normal file
110
tests/e2e/helpers/infra-monitoring.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { seederUrl } from './common';
|
||||
|
||||
// Seeding for the Kubernetes lists under /infrastructure-monitoring.
|
||||
|
||||
/**
|
||||
* The metric the pods list queries. Its rows are what put the pod resource
|
||||
* attributes into `/fields/keys`, which is where the group-by select gets its
|
||||
* options, so this is the metric to seed, not an arbitrary one.
|
||||
*
|
||||
* Dotted, not `k8s_pod_cpu_usage`: the list passes this name straight through as
|
||||
* `metricNamespace` and the backend matches it literally, so the underscored
|
||||
* spelling returns an empty key set and the group-by select renders "No data".
|
||||
*/
|
||||
const POD_CPU_METRIC = 'k8s.pod.cpu.usage';
|
||||
const POD_MEMORY_METRIC = 'k8s.pod.memory.usage';
|
||||
|
||||
const NAMESPACES = ['default', 'kube-system', 'signoz'];
|
||||
const PODS_PER_NAMESPACE = 2;
|
||||
const POINTS_PER_POD = 4;
|
||||
const POINT_INTERVAL_MS = 60_000;
|
||||
|
||||
type SeededMetric = {
|
||||
metric_name: string;
|
||||
timestamp: string;
|
||||
value: number;
|
||||
type_: string;
|
||||
temporality: string;
|
||||
is_monotonic: boolean;
|
||||
unit: string;
|
||||
resource_attrs: Record<string, string>;
|
||||
labels: Record<string, string>;
|
||||
};
|
||||
|
||||
function podResourceAttrs(
|
||||
namespace: string,
|
||||
pod: string,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
'k8s.cluster.name': 'e2e-cluster',
|
||||
'k8s.namespace.name': namespace,
|
||||
'k8s.node.name': `node-${namespace}`,
|
||||
'k8s.pod.name': pod,
|
||||
'k8s.pod.uid': `${namespace}-${pod}`,
|
||||
};
|
||||
}
|
||||
|
||||
function podPoints(now: number): SeededMetric[] {
|
||||
const points: SeededMetric[] = [];
|
||||
for (const namespace of NAMESPACES) {
|
||||
for (let p = 0; p < PODS_PER_NAMESPACE; p += 1) {
|
||||
const pod = `${namespace}-pod-${p}`;
|
||||
const resourceAttrs = podResourceAttrs(namespace, pod);
|
||||
for (let i = 0; i < POINTS_PER_POD; i += 1) {
|
||||
const timestamp = new Date(
|
||||
now - (POINTS_PER_POD - i) * POINT_INTERVAL_MS,
|
||||
).toISOString();
|
||||
points.push({
|
||||
metric_name: POD_CPU_METRIC,
|
||||
timestamp,
|
||||
value: 0.1 + i * 0.05,
|
||||
type_: 'Gauge',
|
||||
temporality: 'Unspecified',
|
||||
is_monotonic: false,
|
||||
unit: '1',
|
||||
resource_attrs: resourceAttrs,
|
||||
labels: resourceAttrs,
|
||||
});
|
||||
points.push({
|
||||
metric_name: POD_MEMORY_METRIC,
|
||||
timestamp,
|
||||
value: 100_000_000 + i * 1_000_000,
|
||||
type_: 'Gauge',
|
||||
temporality: 'Unspecified',
|
||||
is_monotonic: false,
|
||||
unit: 'By',
|
||||
resource_attrs: resourceAttrs,
|
||||
labels: resourceAttrs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed pod metrics so the k8s list has rows and its group-by select has
|
||||
* options. Both matter: with no rows inside the queried window the select
|
||||
* renders empty and every `setK8sGroupBy` call times out.
|
||||
*
|
||||
* The pod keys go in `labels` as well as `resource_attrs`: the list's queries
|
||||
* reference some of them as `tag` and others as `resource`, and the series
|
||||
* fingerprint is computed off labels alone. With an empty label map all six
|
||||
* pods collapse into one series and the list comes back empty.
|
||||
*
|
||||
* Wrap the call in `seedWithRetry`: the seeder serves inserts on one
|
||||
* ClickHouse session, so parallel workers collide with a transient 500.
|
||||
*/
|
||||
export async function seedPodMetricsViaSeeder(page: Page): Promise<void> {
|
||||
const res = await page.request.post(`${seederUrl()}/telemetry/metrics`, {
|
||||
data: podPoints(Date.now()),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`seeder POST /telemetry/metrics ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"env:start": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/setup.py::test_setup",
|
||||
"env:stop": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/setup.py::test_teardown",
|
||||
"env:start:base-path": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/basepath/setup.py::test_setup",
|
||||
"env:stop:base-path": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/basepath/setup.py::test_teardown",
|
||||
"env:clean": "rm -rf ../tmp ../.pytest_cache .env.local artifacts && echo 'Cleaned. Run docker container prune if needed.'",
|
||||
"test": "playwright test",
|
||||
"test:local": "pnpm env:start && pnpm test",
|
||||
|
||||
@@ -108,7 +108,15 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
use: devices['Desktop Chrome'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
launchOptions: {
|
||||
// Google Fonts CSS blocks the app script and `load` until it settles,
|
||||
// so a stalled request blanks the page. Failing DNS settles it at once;
|
||||
// a `route` would too, but routing turns off the HTTP cache.
|
||||
args: ['--host-resolver-rules=MAP fonts.googleapis.com ~NOTFOUND'],
|
||||
},
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
v2SaveTooltip,
|
||||
v2TestButton,
|
||||
} from '../../../helpers/alert-forms/v2';
|
||||
import { BASE_PATH } from '../../../helpers/base-path';
|
||||
|
||||
// CV2-* — the v2 create builder.
|
||||
//
|
||||
@@ -446,7 +447,7 @@ test.describe('Alert create — v2 builder', () => {
|
||||
await ownedRules.register(response);
|
||||
|
||||
expect(response.status()).toBe(201);
|
||||
expect(new URL(response.url()).pathname).toBe('/api/v2/rules');
|
||||
expect(new URL(response.url()).pathname).toBe(`${BASE_PATH}/api/v2/rules`);
|
||||
|
||||
const body = response.request().postDataJSON();
|
||||
expect(body.schemaVersion).toBe('v2alpha1');
|
||||
|
||||
@@ -15,7 +15,8 @@ test.describe('Alert details — page chrome', () => {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const expected = page.url();
|
||||
// The served URL: base path mode strips the prefix from `page.url()`.
|
||||
const expected = page.mainFrame().url();
|
||||
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
await expect(page.getByText('Copied')).toBeVisible();
|
||||
|
||||
@@ -27,7 +27,8 @@ test.describe('Alerts page shell', () => {
|
||||
await page.goto(ALERTS_LIST_PATH);
|
||||
|
||||
// No `tab` param at all — `getActiveKey()` falls back to AlertRules.
|
||||
await expect(page).toHaveURL(ALERTS_LIST_PATH);
|
||||
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}(\\?|$)`));
|
||||
await expect(page).not.toHaveURL(/[?&]tab=/);
|
||||
await expect(
|
||||
page.getByRole('tab', { name: TAB_NAMES.rules }),
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
@@ -33,6 +33,8 @@ test.describe('LLM Observability — Model Pricing', () => {
|
||||
await expect(page.getByTestId('drawer-model-id-input')).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-model-id-input').fill(MODEL_NAME);
|
||||
await page.getByTestId('drawer-pattern-input').fill(MODEL_NAME);
|
||||
await page.getByTestId('drawer-pattern-add-btn').click();
|
||||
await page.getByTestId('drawer-input-cost').fill(INPUT_COST);
|
||||
await page.getByTestId('drawer-output-cost').fill(OUTPUT_COST);
|
||||
|
||||
|
||||
45
tests/e2e/tests/routing/guards.spec.ts
Normal file
45
tests/e2e/tests/routing/guards.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
// `<Redirect>` replaces the history entry and v7's `<Navigate>` pushes unless
|
||||
// told otherwise, which only shows when someone presses Back. Runs as `guest`:
|
||||
// an authenticated page would skip the chain under test.
|
||||
|
||||
test.describe('Routing — auth guards', () => {
|
||||
test('TC-14 a deep-linked private route survives the login round trip', async ({
|
||||
guest,
|
||||
}) => {
|
||||
await guest.goto(PATH.services);
|
||||
await guest.waitForPath(PATH.login);
|
||||
|
||||
await guest.login();
|
||||
|
||||
// Private.tsx stashed the requested path and redirects back to it.
|
||||
await guest.waitForPath(PATH.services);
|
||||
expect(guest.url().pathname).toBe(PATH.services);
|
||||
});
|
||||
|
||||
test('TC-15 the login redirects replace, so Back does not re-run the chain', async ({
|
||||
guest,
|
||||
}) => {
|
||||
const depthBeforeGoto = await guest.historyDepth();
|
||||
|
||||
await guest.goto(PATH.services);
|
||||
await guest.waitForPath(PATH.login);
|
||||
|
||||
// A pushing redirect would read one higher.
|
||||
const depthAtLogin = await guest.historyDepth();
|
||||
expect(depthAtLogin).toBe(depthBeforeGoto + 1);
|
||||
|
||||
await guest.login();
|
||||
await guest.waitForPath(PATH.services);
|
||||
expect(await guest.historyDepth()).toBe(depthAtLogin + 1);
|
||||
|
||||
// Back reaches the replaced /login entry, where being logged in redirects
|
||||
// straight out again.
|
||||
await guest.goBack();
|
||||
await expect
|
||||
.poll(() => guest.url().pathname, { timeout: 15_000 })
|
||||
.not.toBe(PATH.login);
|
||||
expect(guest.url().pathname).toBe(PATH.services);
|
||||
});
|
||||
});
|
||||
49
tests/e2e/tests/routing/mount.spec.ts
Normal file
49
tests/e2e/tests/routing/mount.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
// The shape of the router mount, which no other spec observes.
|
||||
|
||||
test.describe('Routing: router mount', () => {
|
||||
// Under a React transition the previous screen stays up instead of the
|
||||
// Suspense fallback, so an uncached route renders no loader at all.
|
||||
test('TC-18 an in-app navigation to an uncached route commits the Suspense fallback', async ({
|
||||
authedPage: page,
|
||||
routing,
|
||||
}) => {
|
||||
await routing.dashboards.gotoList();
|
||||
|
||||
await routing.whileScriptsHeld(async () => {
|
||||
await routing.sidebarItem('Alerts').click();
|
||||
await routing.waitForPath(PATH.alerts);
|
||||
|
||||
await expect(routing.dashboards.landmark()).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('img', { name: 'loading' }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('heading', { name: 'Alert Rules' })
|
||||
.or(page.getByTestId('list-alerts-search-input')),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// nuqs' react-router adapter needs a router above it; the wrong order throws
|
||||
// on the first page that reads a nuqs param.
|
||||
test('TC-19 a page with nuqs params mounts with no router-context error', async ({
|
||||
authedPage: page,
|
||||
routing,
|
||||
}) => {
|
||||
const reported: string[] = [];
|
||||
page.on('pageerror', (error) => reported.push(error.message));
|
||||
page.on('console', (message) => reported.push(message.text()));
|
||||
|
||||
await routing.k8s.goto({ relativeTime: '30m' });
|
||||
|
||||
expect(
|
||||
reported.filter((text) =>
|
||||
/useNavigate|useSearchParams|context of a <Router>/i.test(text),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
62
tests/e2e/tests/routing/nav-blocker.spec.ts
Normal file
62
tests/e2e/tests/routing/nav-blocker.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
expect,
|
||||
onAdminPage,
|
||||
PATH,
|
||||
routingDriver,
|
||||
test,
|
||||
uniqueAlphaSuffix,
|
||||
} from '../../drivers/routing';
|
||||
|
||||
// `/settings/roles/new` is the only `useNavigationBlocker` consumer, so this is
|
||||
// the whole observable surface of the blocker protocol.
|
||||
|
||||
// Both tests drive the create form; keep them off each other's role names.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const BLOCKED_ROLE_NAME = `routing-blocker-${uniqueAlphaSuffix()}`;
|
||||
const SAVED_ROLE_NAME = `routing-bypass-${uniqueAlphaSuffix()}`;
|
||||
|
||||
test.describe('Routing — unsaved-changes navigation blocker', () => {
|
||||
test.afterAll(async ({ browser }) => {
|
||||
await onAdminPage(browser, (page) =>
|
||||
routingDriver(page).roles.removeByName([BLOCKED_ROLE_NAME, SAVED_ROLE_NAME]),
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-16 a blocked navigation can be cancelled and then confirmed', async ({
|
||||
routing,
|
||||
}) => {
|
||||
const { roles } = routing;
|
||||
await roles.openCreateForm(BLOCKED_ROLE_NAME);
|
||||
|
||||
await roles.cancel();
|
||||
await expect(roles.discardDialog()).toBeVisible();
|
||||
|
||||
// Cancel drops the blocked transition rather than retrying it.
|
||||
await roles
|
||||
.discardDialog()
|
||||
.getByRole('button', { name: 'Keep editing' })
|
||||
.click();
|
||||
await expect(roles.discardDialog()).toBeHidden();
|
||||
expect(routing.url().pathname).toBe(PATH.roleCreate);
|
||||
await expect(roles.nameInput()).toHaveValue(BLOCKED_ROLE_NAME);
|
||||
|
||||
await roles.cancel();
|
||||
await roles.discardDialog().getByRole('button', { name: 'Discard' }).click();
|
||||
await routing.waitForPath(PATH.roles);
|
||||
});
|
||||
|
||||
test('TC-17 saving bypasses the blocker for the next navigation', async ({
|
||||
routing,
|
||||
}) => {
|
||||
const { roles } = routing;
|
||||
await roles.openCreateForm(SAVED_ROLE_NAME);
|
||||
|
||||
// `allowNextNavigation()` unarms the blocker for the push after a save.
|
||||
await roles.save();
|
||||
await routing.waitForPath(PATH.roles);
|
||||
|
||||
await expect(roles.discardDialog()).toHaveCount(0);
|
||||
await expect(roles.settings()).toBeVisible();
|
||||
});
|
||||
});
|
||||
87
tests/e2e/tests/routing/navigation.spec.ts
Normal file
87
tests/e2e/tests/routing/navigation.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { dashboardListParams, expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
// Imperative navigation: the `history.listen` counter behind `hasInAppHistory()`,
|
||||
// `useSafeNavigate`'s same-url suppression, and its `newTab` branch.
|
||||
|
||||
test.describe('Routing — imperative navigation', () => {
|
||||
test('TC-05 Back from a detail page restores the list with its params', async ({
|
||||
routing,
|
||||
dashboard,
|
||||
}) => {
|
||||
await routing.dashboards.gotoList(dashboard.title);
|
||||
await routing.dashboards.row(dashboard.title).click();
|
||||
await routing.waitForPath(dashboard.path);
|
||||
|
||||
await routing.goBackTo(PATH.dashboards);
|
||||
|
||||
expect(routing.params()).toMatchObject(dashboardListParams(dashboard.title));
|
||||
});
|
||||
|
||||
// Both branches of the trace header's Back button in one document:
|
||||
// `hasInAppHistory() ? goBack() : push(TRACES_EXPLORER)`.
|
||||
test('TC-06 the trace-details previous button pushes when deep-linked and pops once in-app', async ({
|
||||
routing,
|
||||
largeTrace: trace,
|
||||
}) => {
|
||||
await routing.goto(PATH.services);
|
||||
await routing.trace.goto(trace);
|
||||
|
||||
// The document load reset the counter, so Back pushes the explorer.
|
||||
const depthBeforePush = await routing.historyDepth();
|
||||
await routing.trace.back();
|
||||
await routing.waitForPath(PATH.tracesExplorer);
|
||||
expect(routing.url().searchParams.has('selectedExplorerView')).toBe(false);
|
||||
expect(await routing.historyDepth()).toBe(depthBeforePush + 1);
|
||||
|
||||
// That push is in-app history, so after a POP back Back pops too.
|
||||
await routing.goBackTo(routing.trace.path(trace));
|
||||
await expect(routing.trace.rootRow(trace)).toBeVisible();
|
||||
|
||||
const depthBeforePop = await routing.historyDepth();
|
||||
await routing.trace.back();
|
||||
await routing.waitForPath(PATH.services);
|
||||
expect(await routing.historyDepth()).toBe(depthBeforePop);
|
||||
});
|
||||
|
||||
test('TC-07 re-selecting the current relative time adds no history entry', async ({
|
||||
routing,
|
||||
}) => {
|
||||
test.slow();
|
||||
|
||||
await routing.k8s.goto({ relativeTime: '30m' });
|
||||
await routing.expectRelativeTime('30m');
|
||||
const before = routing.params();
|
||||
const depthBefore = await routing.historyDepth();
|
||||
|
||||
await routing.selectRelativeTime('30m');
|
||||
|
||||
// `areUrlsEffectivelySame` ignores the regenerated compositeQuery `id`. Not
|
||||
// followed by "one Back leaves the page": the k8s list pushes its own
|
||||
// compositeQuery rewrite on mount.
|
||||
expect(await routing.historyDepth()).toBe(depthBefore);
|
||||
expect(routing.url().pathname).toBe(PATH.k8s);
|
||||
expect(routing.params()).toMatchObject({
|
||||
relativeTime: '30m',
|
||||
category: before.category,
|
||||
});
|
||||
await routing.expectRelativeTime('30m');
|
||||
});
|
||||
|
||||
test('TC-08 modifier-clicking an in-app link opens the same url in a new tab', async ({
|
||||
routing,
|
||||
dashboard,
|
||||
}) => {
|
||||
await routing.dashboards.gotoList(dashboard.title);
|
||||
|
||||
await routing.inNewTab(
|
||||
routing.dashboards.row(dashboard.title),
|
||||
async (tab) => {
|
||||
expect(tab.url().pathname).toBe(dashboard.path);
|
||||
await expect(tab.dashboards.title()).toHaveText(dashboard.title);
|
||||
},
|
||||
);
|
||||
|
||||
expect(routing.url().pathname).toBe(PATH.dashboards);
|
||||
expect(routing.params()).toMatchObject(dashboardListParams(dashboard.title));
|
||||
});
|
||||
});
|
||||
84
tests/e2e/tests/routing/route-matching.spec.ts
Normal file
84
tests/e2e/tests/routing/route-matching.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
// Every place a `matchPath` / `generatePath` call decides what renders:
|
||||
// `RouteTab`, `TopNav`'s per-route checks, and `SETTINGS`, a prefix route that
|
||||
// needs an explicit `/*` under v7.
|
||||
|
||||
test.describe('Routing — route matching', () => {
|
||||
test('TC-09 RouteTab switches the url and survives a reload', async ({
|
||||
routing,
|
||||
}) => {
|
||||
await routing.goto(PATH.metricsSummary);
|
||||
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
|
||||
|
||||
await routing.routeTab(PATH.metricsViews).click();
|
||||
await routing.waitForPath(PATH.metricsViews);
|
||||
await expect(routing.activeRouteTab(PATH.metricsViews)).toBeVisible();
|
||||
|
||||
await routing.reload();
|
||||
expect(routing.url().pathname).toBe(PATH.metricsViews);
|
||||
await expect(routing.activeRouteTab(PATH.metricsViews)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-10 a prefix-route deep link loads with the right tab selected', async ({
|
||||
authedPage: page,
|
||||
routing,
|
||||
}) => {
|
||||
await routing.goto(PATH.roles);
|
||||
|
||||
expect(routing.url().pathname).toBe(PATH.roles);
|
||||
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
|
||||
await expect(page.getByTestId('roles')).toHaveClass(/active/);
|
||||
await expect(routing.roles.settings()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-11 a seeded dashboard id round-trips byte-identical', async ({
|
||||
routing,
|
||||
dashboard,
|
||||
}) => {
|
||||
await routing.dashboards.gotoList(dashboard.title);
|
||||
await routing.dashboards.row(dashboard.title).click();
|
||||
|
||||
// Exact match: any re-encoding of the uuid fails here.
|
||||
await routing.waitForPath(dashboard.path);
|
||||
await expect(routing.dashboards.title()).toHaveText(dashboard.title);
|
||||
|
||||
await routing.reload();
|
||||
expect(routing.url().pathname).toBe(dashboard.path);
|
||||
await expect(routing.dashboards.title()).toHaveText(dashboard.title);
|
||||
});
|
||||
|
||||
test('TC-12 the global time picker renders only where matchPath allows it', async ({
|
||||
routing,
|
||||
}) => {
|
||||
await routing.goto(`${PATH.services}?relativeTime=30m`);
|
||||
await expect(routing.globalTimePicker()).toBeVisible();
|
||||
|
||||
// LOGS_EXPLORER is in `routesToDisable`.
|
||||
await routing.gotoLogsExplorer();
|
||||
await expect(routing.globalTimePicker()).toHaveCount(0);
|
||||
|
||||
// ROLES_SETTINGS is in `routesToSkip`.
|
||||
await routing.goto(PATH.roles);
|
||||
await expect(routing.roles.settings()).toBeVisible();
|
||||
await expect(routing.globalTimePicker()).toHaveCount(0);
|
||||
});
|
||||
|
||||
// `PrivateRoute` redirects a logged-in user on an unknown path to HOME before
|
||||
// the catch-all runs, so `NotFound` is unreachable while authenticated.
|
||||
test('TC-13 an unknown path redirects home and replaces its history entry', async ({
|
||||
authedPage: page,
|
||||
routing,
|
||||
}) => {
|
||||
await routing.dashboards.gotoList();
|
||||
const depthBefore = await routing.historyDepth();
|
||||
|
||||
await routing.goto(PATH.unknown);
|
||||
await routing.waitForPath(PATH.home);
|
||||
await expect(page.getByTestId('not-found')).toHaveCount(0);
|
||||
|
||||
// The redirect replaced the entry, so one Back reaches the list.
|
||||
expect(await routing.historyDepth()).toBe(depthBefore + 1);
|
||||
await routing.goBackTo(PATH.dashboards);
|
||||
});
|
||||
});
|
||||
83
tests/e2e/tests/routing/url-state.spec.ts
Normal file
83
tests/e2e/tests/routing/url-state.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
// The nuqs adapter swap. On the k8s list `groupBy` is written by nuqs straight
|
||||
// to the History API with `history: 'push'`, while `relativeTime` is written by
|
||||
// the router through `useSafeNavigate`.
|
||||
|
||||
test.describe('Routing — nuqs and router param coexistence', () => {
|
||||
test('TC-01 a router write keeps the nuqs params it did not author', async ({
|
||||
routing,
|
||||
pods,
|
||||
}) => {
|
||||
await routing.k8s.goto({ relativeTime: '30m' });
|
||||
|
||||
await routing.k8s.groupBy(pods.groupBy);
|
||||
await routing.selectRelativeTime('1h');
|
||||
await routing.waitForParam('relativeTime', '1h');
|
||||
|
||||
expect(routing.url().pathname).toBe(PATH.k8s);
|
||||
expect(routing.params()).toMatchObject({
|
||||
relativeTime: '1h',
|
||||
category: 'pods',
|
||||
});
|
||||
expect(routing.params().groupBy).toContain(pods.groupBy);
|
||||
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
|
||||
});
|
||||
|
||||
test('TC-02 a nuqs write keeps the relativeTime the router authored', async ({
|
||||
routing,
|
||||
pods,
|
||||
}) => {
|
||||
await routing.k8s.goto({ relativeTime: '30m' });
|
||||
|
||||
await routing.selectRelativeTime('6h');
|
||||
await routing.waitForParam('relativeTime', '6h');
|
||||
|
||||
await routing.k8s.groupBy(pods.groupBy);
|
||||
|
||||
expect(routing.url().pathname).toBe(PATH.k8s);
|
||||
expect(routing.params().relativeTime).toBe('6h');
|
||||
expect(routing.params().groupBy).toContain(pods.groupBy);
|
||||
await routing.expectRelativeTime('6h');
|
||||
});
|
||||
|
||||
test('TC-03 POP across a nuqs push reverts both url and list', async ({
|
||||
routing,
|
||||
pods,
|
||||
}) => {
|
||||
await routing.k8s.goto({ relativeTime: '30m' });
|
||||
|
||||
// One flush, one entry: otherwise one Back lands on an intermediate url.
|
||||
const depthBeforeGroupBy = await routing.historyDepth();
|
||||
await routing.k8s.groupBy(pods.groupBy);
|
||||
expect(await routing.historyDepth()).toBe(depthBeforeGroupBy + 1);
|
||||
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
|
||||
|
||||
await routing.goBack();
|
||||
await routing.k8s.waitUntilGrouped(pods.groupBy, false);
|
||||
expect(routing.url().pathname).toBe(PATH.k8s);
|
||||
await expect(routing.k8s.groupBySelect()).not.toContainText(pods.groupBy);
|
||||
|
||||
await routing.goForward();
|
||||
await routing.k8s.waitUntilGrouped(pods.groupBy);
|
||||
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
|
||||
});
|
||||
|
||||
test('TC-04 a deep link carrying both families applies both on first paint', async ({
|
||||
routing,
|
||||
pods,
|
||||
}) => {
|
||||
await routing.k8s.goto({
|
||||
relativeTime: '6h',
|
||||
groupBy: JSON.stringify([pods.groupBy]),
|
||||
});
|
||||
|
||||
// `k8s.goto` waited for the mount-time republish, so anything it dropped is
|
||||
// missing by now.
|
||||
expect(routing.url().pathname).toBe(PATH.k8s);
|
||||
expect(routing.params().relativeTime).toBe('6h');
|
||||
expect(routing.params().groupBy).toContain(pods.groupBy);
|
||||
await routing.expectRelativeTime('6h');
|
||||
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,6 @@
|
||||
"outDir": "./dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["tests/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "bootstrap/**/*.ts", "playwright.config.ts"],
|
||||
"include": ["tests/**/*.ts", "drivers/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "bootstrap/**/*.ts", "playwright.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
53
tests/fixtures/auth.py
vendored
53
tests/fixtures/auth.py
vendored
@@ -36,28 +36,6 @@ USERS_BASE = "/api/v2/users"
|
||||
USER_ROLES_BASE = "/api/v2/user_roles"
|
||||
|
||||
|
||||
def _login(signoz: types.SigNoz, email: str, password: str) -> str:
|
||||
"""Complete GET /sessions/context + POST /sessions/email_password; return accessToken."""
|
||||
ctx = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
|
||||
params={
|
||||
"email": email,
|
||||
"ref": f"{signoz.self.host_configs['8080'].base()}",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert ctx.status_code == HTTPStatus.OK
|
||||
org_id = ctx.json()["data"]["orgs"][0]["id"]
|
||||
|
||||
login = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
|
||||
json={"email": email, "password": password, "orgId": org_id},
|
||||
timeout=5,
|
||||
)
|
||||
assert login.status_code == HTTPStatus.OK
|
||||
return login.json()["data"]["accessToken"]
|
||||
|
||||
|
||||
def register_admin(
|
||||
signoz: types.SigNoz,
|
||||
request: pytest.FixtureRequest,
|
||||
@@ -182,16 +160,14 @@ def get_tokens(signoz: types.SigNoz) -> Callable[[str, str], tuple[str, str]]:
|
||||
return tokens_getter(signoz)
|
||||
|
||||
|
||||
@pytest.fixture(name="apply_license", scope="package")
|
||||
def apply_license(
|
||||
def license_applier(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument,redefined-outer-name
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
cache_key: str = "apply_license",
|
||||
base_path: str = "",
|
||||
) -> types.Operation:
|
||||
"""Stub Zeus license-lookup, then POST /api/v4/licenses so the BE flips
|
||||
to ENTERPRISE. Package-scoped so an e2e bootstrap can pull it in and
|
||||
every spec inherits the licensed state."""
|
||||
"""Apply the ENTERPRISE license and complete org onboarding, under base_path. Reuse-wrapped."""
|
||||
|
||||
def create() -> types.Operation:
|
||||
Config.base_url = signoz.zeus.host_configs["8080"].get("/__admin")
|
||||
@@ -224,12 +200,12 @@ def apply_license(
|
||||
)
|
||||
)
|
||||
|
||||
access_token = _login(signoz, USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
access_token = token_getter(signoz, base_path)(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# 201 = applied, 409 = already applied. Retry transient failures —
|
||||
# the BE occasionally 5xxs right after startup before the license
|
||||
# sync goroutine is ready.
|
||||
license_url = signoz.self.host_configs["8080"].get("/api/v4/licenses")
|
||||
license_url = signoz.self.host_configs["8080"].get(f"{base_path}/api/v4/licenses")
|
||||
auth_header = {"Authorization": f"Bearer {access_token}"}
|
||||
for attempt in range(10):
|
||||
resp = requests.post(
|
||||
@@ -248,7 +224,7 @@ def apply_license(
|
||||
# redirects first-time admins to a questionnaire. Mark the preference
|
||||
# complete so specs can navigate directly to the feature under test.
|
||||
pref_resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/org/preferences/org_onboarding"),
|
||||
signoz.self.host_configs["8080"].get(f"{base_path}/api/v1/org/preferences/org_onboarding"),
|
||||
json={"value": True},
|
||||
headers=auth_header,
|
||||
timeout=5,
|
||||
@@ -265,7 +241,7 @@ def apply_license(
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
"apply_license",
|
||||
cache_key,
|
||||
lambda: types.Operation(name=""),
|
||||
create,
|
||||
delete,
|
||||
@@ -273,6 +249,19 @@ def apply_license(
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="apply_license", scope="package")
|
||||
def apply_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument,redefined-outer-name
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.Operation:
|
||||
"""Stub Zeus license-lookup, then POST /api/v4/licenses so the BE flips
|
||||
to ENTERPRISE. Package-scoped so an e2e bootstrap can pull it in and
|
||||
every spec inherits the licensed state."""
|
||||
return license_applier(signoz, request, pytestconfig)
|
||||
|
||||
|
||||
# This is not a fixture purposefully, we just want to add a license to the signoz instance.
|
||||
# This is also idempotent in nature.
|
||||
def add_license(
|
||||
|
||||
1
tests/fixtures/traces.py
vendored
1
tests/fixtures/traces.py
vendored
@@ -895,7 +895,6 @@ _TRACES_TABLES_TO_TRUNCATE = [
|
||||
"span_attributes_keys",
|
||||
"signoz_error_index_v2",
|
||||
"top_level_operations",
|
||||
"trace_summary",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querierai import root_span
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
WATERFALL_FIELDS = (
|
||||
"startTimestampMillis",
|
||||
"endTimestampMillis",
|
||||
"rootServiceName",
|
||||
"rootServiceEntryPoint",
|
||||
"totalSpansCount",
|
||||
"totalErrorSpansCount",
|
||||
"hasMissingSpans",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attribute_backend", ["map", "json"])
|
||||
def test_summary_ai_trace(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
) -> None:
|
||||
"""The summary carries the waterfall's trace-level fields and, for a trace with gen_ai
|
||||
spans, token totals over every LLM span and the cost summed over the spans that carry it.
|
||||
Spans are written to one layout only, so a read from the wrong column sums to zero."""
|
||||
use_attribute_backend(attribute_backend)
|
||||
write_mode = "json_only" if attribute_backend == "json" else "legacy_only"
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = f"td-summary-{attribute_backend}"
|
||||
resources = {"service.name": service}
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=4),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={
|
||||
"gen_ai.request.model": "gpt-4o-mini",
|
||||
"gen_ai.usage.input_tokens": 100,
|
||||
"gen_ai.usage.output_tokens": 20,
|
||||
"gen_ai.usage.cache_read.input_tokens": 7,
|
||||
"signoz.gen_ai.usage.tokens.cost": 0.01,
|
||||
},
|
||||
attribute_write_mode=write_mode,
|
||||
),
|
||||
# a failed LLM call: counted in tokens and errors, but priced by nobody
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=3),
|
||||
duration=timedelta(seconds=0.5),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_ERROR,
|
||||
resources=resources,
|
||||
attributes={
|
||||
"gen_ai.request.model": "gpt-4o-mini",
|
||||
"gen_ai.usage.input_tokens": 50,
|
||||
"gen_ai.usage.output_tokens": 5,
|
||||
},
|
||||
attribute_write_mode=write_mode,
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
duration=timedelta(seconds=0.5),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="execute_tool",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.tool.name": "get_weather"},
|
||||
attribute_write_mode=write_mode,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
|
||||
|
||||
summary = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/summary"), timeout=10, headers=headers)
|
||||
assert summary.status_code == HTTPStatus.OK, summary.text
|
||||
summary = summary.json()["data"]
|
||||
|
||||
waterfall = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v4/traces/{trace_id}/waterfall"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"selectedSpanId": "", "uncollapsedSpans": []},
|
||||
)
|
||||
assert waterfall.status_code == HTTPStatus.OK, waterfall.text
|
||||
waterfall = waterfall.json()["data"]
|
||||
|
||||
assert {k: summary[k] for k in WATERFALL_FIELDS} == {k: waterfall[k] for k in WATERFALL_FIELDS}
|
||||
assert summary["rootServiceName"] == service
|
||||
assert summary["rootServiceEntryPoint"] == "POST /api/chat"
|
||||
assert summary["totalSpansCount"] == 4
|
||||
assert summary["totalErrorSpansCount"] == 1
|
||||
assert summary["hasMissingSpans"] is False
|
||||
|
||||
assert summary["ai"]["tokens"] == {"input": 150, "output": 25, "cacheRead": 7, "cacheWrite": 0, "reasoning": 0}
|
||||
assert summary["ai"]["totalCost"] == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_summary_ai_trace_across_json_rollout(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
"""A trace that straddles the attribute JSON rollout has LLM spans written only to the legacy
|
||||
maps before it and to the JSON column after it. The summary window covers both, so the gen_ai
|
||||
reads must fall back across columns and sum every span."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
rollout = now - timedelta(minutes=30)
|
||||
seed_attribute_evolution("traces", rollout)
|
||||
|
||||
service = "td-summary-rollout"
|
||||
resources = {"service.name": service}
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=rollout - timedelta(minutes=10),
|
||||
duration=timedelta(minutes=15),
|
||||
trace_id=trace_id,
|
||||
span_id=root_id,
|
||||
parent_span_id="",
|
||||
name="long agent run",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"http.response.status_code": 200},
|
||||
attribute_write_mode="legacy_only",
|
||||
),
|
||||
Traces(
|
||||
timestamp=rollout - timedelta(minutes=5),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.input_tokens": 100, "gen_ai.usage.output_tokens": 20, "signoz.gen_ai.usage.tokens.cost": 0.01},
|
||||
attribute_write_mode="legacy_only",
|
||||
),
|
||||
Traces(
|
||||
timestamp=rollout + timedelta(minutes=4),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.input_tokens": 50, "gen_ai.usage.output_tokens": 5, "signoz.gen_ai.usage.tokens.cost": 0.02},
|
||||
attribute_write_mode="json_only",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
summary = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/summary"),
|
||||
timeout=10,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert summary.status_code == HTTPStatus.OK, summary.text
|
||||
summary = summary.json()["data"]
|
||||
|
||||
assert summary["totalSpansCount"] == 3
|
||||
assert summary["rootServiceEntryPoint"] == "long agent run"
|
||||
assert summary["rootSpanStatusCode"] == "200"
|
||||
assert summary["ai"]["tokens"] == {"input": 150, "output": 25, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}
|
||||
assert summary["ai"]["totalCost"] == pytest.approx(0.03)
|
||||
|
||||
|
||||
def test_summary_non_ai_trace_with_missing_root(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""A trace whose recorded spans all hang off an unrecorded parent reports the synthetic
|
||||
"Missing Span" root exactly as the waterfall does, and a trace without gen_ai spans has
|
||||
no `ai` block."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
resources = {"service.name": "td-summary-orphan"}
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
missing_parent_id = TraceIdGenerator.span_id()
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=5),
|
||||
duration=timedelta(seconds=2),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=missing_parent_id,
|
||||
name="SELECT users",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=missing_parent_id,
|
||||
name="publish event",
|
||||
kind=TracesKind.SPAN_KIND_PRODUCER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
|
||||
|
||||
summary = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/summary"), timeout=10, headers=headers)
|
||||
assert summary.status_code == HTTPStatus.OK, summary.text
|
||||
summary = summary.json()["data"]
|
||||
|
||||
waterfall = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v4/traces/{trace_id}/waterfall"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"selectedSpanId": "", "uncollapsedSpans": []},
|
||||
)
|
||||
assert waterfall.status_code == HTTPStatus.OK, waterfall.text
|
||||
waterfall = waterfall.json()["data"]
|
||||
|
||||
assert {k: summary[k] for k in WATERFALL_FIELDS} == {k: waterfall[k] for k in WATERFALL_FIELDS}
|
||||
assert summary["hasMissingSpans"] is True
|
||||
assert summary["rootServiceName"] == ""
|
||||
assert summary["rootServiceEntryPoint"] == "Missing Span"
|
||||
assert summary["rootSpanStatusCode"] == ""
|
||||
assert summary["totalSpansCount"] == 2
|
||||
assert "ai" not in summary
|
||||
|
||||
|
||||
def test_summary_unknown_trace(
|
||||
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 = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/traces/{TraceIdGenerator.trace_id()}/summary"),
|
||||
timeout=10,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
|
||||
@@ -1,327 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.traces import ATTRIBUTE_JSON_ROLLOUT_TIME, TraceIdGenerator, Traces, TracesKind
|
||||
|
||||
|
||||
def test_thread_returns_message_spans_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],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id, first_llm_id, tool_id, second_llm_id, third_llm_id = (TraceIdGenerator.span_id() for _ in range(5))
|
||||
resources = {"service.name": "tracedetail-thread"}
|
||||
first_input = json.dumps([{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}])
|
||||
first_output = json.dumps([{"role": "assistant", "parts": [{"type": "tool_call", "id": "call_1", "name": "get_weather", "arguments": {"city": "Bangalore"}}], "finish_reason": "tool_call"}])
|
||||
second_input = json.dumps([{"role": "tool", "content": "sunny", "tool_call_id": "call_1"}])
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=9), trace_id=trace_id, span_id=root_id, name="POST /chat", kind=TracesKind.SPAN_KIND_SERVER, resources=resources, attribute_write_mode="json_only"),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=8), trace_id=trace_id, span_id=first_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": first_input, "gen_ai.output.messages": first_output}, attribute_write_mode="json_only"
|
||||
),
|
||||
Traces(timestamp=now - timedelta(seconds=6), trace_id=trace_id, span_id=tool_id, parent_span_id=root_id, name="execute_tool get_weather", resources=resources, attributes={"gen_ai.tool.name": "get_weather"}, attribute_write_mode="json_only"),
|
||||
Traces(timestamp=now - timedelta(seconds=4), trace_id=trace_id, span_id=second_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": second_input}, attribute_write_mode="json_only"),
|
||||
Traces(timestamp=now - timedelta(seconds=2), trace_id=trace_id, span_id=third_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": "It is sunny in Bangalore."}, attribute_write_mode="json_only"),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread"), headers={"Authorization": f"Bearer {token}"}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
thread = response.json()["data"]
|
||||
assert [span["span_id"] for span in thread["spans"]] == [first_llm_id, second_llm_id, third_llm_id]
|
||||
assert "nextCursor" not in thread
|
||||
|
||||
first, input_only, output_only = thread["spans"]
|
||||
assert first["time_unix"] == int((now - timedelta(seconds=8)).timestamp() * 1000)
|
||||
assert first["attributes"]["gen_ai.input.messages"] == first_input
|
||||
assert first["attributes"]["gen_ai.request.model"] == "gpt-4o"
|
||||
assert first["attributes"]["gen_ai.output.messages"] == first_output
|
||||
assert input_only["attributes"]["gen_ai.input.messages"] == second_input
|
||||
assert "gen_ai.output.messages" not in input_only["attributes"]
|
||||
assert "gen_ai.input.messages" not in output_only["attributes"]
|
||||
assert output_only["attributes"]["gen_ai.output.messages"] == "It is sunny in Bangalore."
|
||||
|
||||
|
||||
def test_thread_paginates_with_cursors(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
span_ids = [TraceIdGenerator.span_id() for _ in range(3)]
|
||||
# identical timestamps on the last two exercise the span_id tie-break
|
||||
timestamps = [now - timedelta(seconds=6), now - timedelta(seconds=3), now - timedelta(seconds=3)]
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-pages"}, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
|
||||
for span_id, timestamp in zip(span_ids, timestamps, strict=True)
|
||||
]
|
||||
)
|
||||
expected_order = [span_ids[0], *sorted(span_ids[1:])]
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
url = signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def get_page(params: dict) -> dict:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, f"{params}: {response.text}"
|
||||
return response.json()["data"]
|
||||
|
||||
first = get_page({"limit": 2})
|
||||
assert [span["span_id"] for span in first["spans"]] == expected_order[:2]
|
||||
assert "prevCursor" not in first
|
||||
assert first["nextCursor"]
|
||||
|
||||
last = get_page({"limit": 2, "after": first["nextCursor"]})
|
||||
assert [span["span_id"] for span in last["spans"]] == expected_order[2:]
|
||||
assert last["prevCursor"]
|
||||
assert "nextCursor" not in last
|
||||
|
||||
previous = get_page({"limit": 2, "before": last["prevCursor"]})
|
||||
assert [span["span_id"] for span in previous["spans"]] == expected_order[:2]
|
||||
assert "prevCursor" not in previous
|
||||
assert previous["nextCursor"] == first["nextCursor"]
|
||||
|
||||
middle = get_page({"limit": 1, "before": last["prevCursor"]})
|
||||
assert [span["span_id"] for span in middle["spans"]] == expected_order[1:2]
|
||||
assert middle["prevCursor"]
|
||||
assert middle["nextCursor"]
|
||||
|
||||
start = get_page({"limit": 2, "before": middle["prevCursor"]})
|
||||
assert [span["span_id"] for span in start["spans"]] == expected_order[:1]
|
||||
assert "prevCursor" not in start
|
||||
|
||||
|
||||
def test_thread_paginates_across_buckets(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
bucket = now.replace(minute=0 if now.minute < 30 else 30, second=0)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
# neighbours within one 30-minute ts_bucket_start and across bucket boundaries
|
||||
timestamps = [
|
||||
bucket - timedelta(minutes=59, seconds=59),
|
||||
bucket - timedelta(minutes=30, seconds=1),
|
||||
bucket - timedelta(minutes=30),
|
||||
bucket - timedelta(seconds=1),
|
||||
bucket,
|
||||
]
|
||||
span_ids = [TraceIdGenerator.span_id() for _ in timestamps]
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-buckets"}, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
|
||||
for span_id, timestamp in zip(span_ids, timestamps, strict=True)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
url = signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def get_page(params: dict) -> dict:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, f"{params}: {response.text}"
|
||||
return response.json()["data"]
|
||||
|
||||
page = get_page({"limit": 1})
|
||||
forward = [span["span_id"] for span in page["spans"]]
|
||||
while "nextCursor" in page:
|
||||
page = get_page({"limit": 1, "after": page["nextCursor"]})
|
||||
forward += [span["span_id"] for span in page["spans"]]
|
||||
assert forward == span_ids
|
||||
|
||||
backward = [span["span_id"] for span in page["spans"]]
|
||||
while "prevCursor" in page:
|
||||
page = get_page({"limit": 1, "before": page["prevCursor"]})
|
||||
backward = [span["span_id"] for span in page["spans"]] + backward
|
||||
assert backward == span_ids
|
||||
|
||||
for index in range(1, len(span_ids)):
|
||||
around = get_page({"limit": 2, "spanId": span_ids[index]})
|
||||
assert [span["span_id"] for span in around["spans"]] == span_ids[index - 1 : index + 1], index
|
||||
|
||||
|
||||
def test_thread_opens_around_span(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
resources = {"service.name": "tracedetail-thread-anchor"}
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
llm_ids = [TraceIdGenerator.span_id() for _ in range(5)]
|
||||
tool_id = TraceIdGenerator.span_id()
|
||||
# tool span sits between the third and fourth llm spans
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=now - timedelta(seconds=20), duration=timedelta(seconds=19), trace_id=trace_id, span_id=root_id, name="POST /chat", kind=TracesKind.SPAN_KIND_SERVER, resources=resources, attribute_write_mode="json_only"),
|
||||
*(
|
||||
Traces(timestamp=now - timedelta(seconds=18 - 3 * i), trace_id=trace_id, span_id=span_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
|
||||
for i, span_id in enumerate(llm_ids)
|
||||
),
|
||||
Traces(timestamp=now - timedelta(seconds=11), trace_id=trace_id, span_id=tool_id, parent_span_id=root_id, name="execute_tool get_weather", resources=resources, attributes={"gen_ai.tool.name": "get_weather"}, attribute_write_mode="json_only"),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
url = signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def get_page(params: dict) -> dict:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, f"{params}: {response.text}"
|
||||
return response.json()["data"]
|
||||
|
||||
# span with messages: included with its neighbours
|
||||
around = get_page({"limit": 3, "spanId": llm_ids[2]})
|
||||
assert [span["span_id"] for span in around["spans"]] == llm_ids[1:4]
|
||||
assert around["prevCursor"]
|
||||
assert around["nextCursor"]
|
||||
assert [span["span_id"] for span in get_page({"limit": 3, "before": around["prevCursor"]})["spans"]] == llm_ids[:1]
|
||||
assert [span["span_id"] for span in get_page({"limit": 3, "after": around["nextCursor"]})["spans"]] == llm_ids[4:]
|
||||
|
||||
# span without messages: only its neighbours
|
||||
around_tool = get_page({"limit": 2, "spanId": tool_id})
|
||||
assert [span["span_id"] for span in around_tool["spans"]] == llm_ids[2:4]
|
||||
assert around_tool["prevCursor"]
|
||||
assert around_tool["nextCursor"]
|
||||
|
||||
# near the start, the short side gives its room to the other
|
||||
at_start = get_page({"limit": 3, "spanId": llm_ids[0]})
|
||||
assert [span["span_id"] for span in at_start["spans"]] == llm_ids[:3]
|
||||
assert "prevCursor" not in at_start
|
||||
assert at_start["nextCursor"]
|
||||
|
||||
# near the end
|
||||
at_end = get_page({"limit": 3, "spanId": llm_ids[4]})
|
||||
assert [span["span_id"] for span in at_end["spans"]] == llm_ids[2:]
|
||||
assert at_end["prevCursor"]
|
||||
assert "nextCursor" not in at_end
|
||||
|
||||
# page covers the whole thread
|
||||
whole = get_page({"limit": 10, "spanId": root_id})
|
||||
assert [span["span_id"] for span in whole["spans"]] == llm_ids
|
||||
assert "prevCursor" not in whole
|
||||
assert "nextCursor" not in whole
|
||||
|
||||
missing = requests.get(url, params={"spanId": TraceIdGenerator.span_id()}, headers=headers, timeout=10)
|
||||
assert missing.status_code == HTTPStatus.NOT_FOUND, missing.text
|
||||
|
||||
|
||||
def test_thread_reads_spans_across_json_rollout(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
rollout = now - timedelta(minutes=30)
|
||||
seed_attribute_evolution("traces", rollout)
|
||||
resources = {"service.name": "tracedetail-thread-rollout"}
|
||||
|
||||
# trace entirely before the rollout: messages live only in the legacy maps
|
||||
before_trace_id = TraceIdGenerator.trace_id()
|
||||
before_ids = [TraceIdGenerator.span_id() for _ in range(2)]
|
||||
# trace straddling the rollout: one span in the maps, one in the JSON column
|
||||
straddle_trace_id = TraceIdGenerator.trace_id()
|
||||
legacy_id, json_id = TraceIdGenerator.span_id(), TraceIdGenerator.span_id()
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=rollout - timedelta(minutes=10), trace_id=before_trace_id, span_id=before_ids[0], name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "first"}])}, attribute_write_mode="legacy_only"),
|
||||
Traces(timestamp=rollout - timedelta(minutes=8), trace_id=before_trace_id, span_id=TraceIdGenerator.span_id(), name="execute_tool get_weather", resources=resources, attributes={"gen_ai.tool.name": "get_weather"}, attribute_write_mode="legacy_only"),
|
||||
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=before_trace_id, span_id=before_ids[1], name="chat gpt-4o", resources=resources, attributes={"gen_ai.output.messages": json.dumps([{"role": "assistant", "content": "second"}])}, attribute_write_mode="legacy_only"),
|
||||
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=straddle_trace_id, span_id=legacy_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "legacy"}])}, attribute_write_mode="legacy_only"),
|
||||
Traces(timestamp=rollout + timedelta(minutes=5), trace_id=straddle_trace_id, span_id=json_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "json"}])}, attribute_write_mode="json_only"),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
before = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{before_trace_id}/thread"), headers=headers, timeout=10)
|
||||
assert before.status_code == HTTPStatus.OK, before.text
|
||||
before_spans = before.json()["data"]["spans"]
|
||||
assert [span["span_id"] for span in before_spans] == before_ids
|
||||
assert before_spans[0]["attributes"]["gen_ai.input.messages"] == json.dumps([{"role": "user", "content": "first"}])
|
||||
assert before_spans[1]["attributes"]["gen_ai.output.messages"] == json.dumps([{"role": "assistant", "content": "second"}])
|
||||
|
||||
straddle = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{straddle_trace_id}/thread"), headers=headers, timeout=10)
|
||||
assert straddle.status_code == HTTPStatus.OK, straddle.text
|
||||
straddle_spans = straddle.json()["data"]["spans"]
|
||||
assert [span["span_id"] for span in straddle_spans] == [legacy_id, json_id]
|
||||
assert straddle_spans[0]["attributes"]["gen_ai.input.messages"] == json.dumps([{"role": "user", "content": "legacy"}])
|
||||
assert straddle_spans[1]["attributes"]["gen_ai.input.messages"] == json.dumps([{"role": "user", "content": "json"}])
|
||||
|
||||
|
||||
def test_thread_without_messages_is_empty(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
insert_traces([Traces(timestamp=datetime.now(tz=UTC) - timedelta(seconds=5), trace_id=trace_id, span_id=TraceIdGenerator.span_id(), name="GET /health", resources={"service.name": "tracedetail-thread-empty"}, attribute_write_mode="json_only")])
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread"), headers={"Authorization": f"Bearer {token}"}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"] == {"spans": []}
|
||||
|
||||
|
||||
def test_thread_rejects_invalid_requests(
|
||||
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)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
url = signoz.self.host_configs["8080"].get(f"/api/v1/traces/{TraceIdGenerator.trace_id()}/thread")
|
||||
|
||||
cursor = base64.urlsafe_b64encode(json.dumps({"timeUnixNano": 1, "spanId": "f1fa1bc863e94dd0"}).encode()).decode().rstrip("=")
|
||||
for params in (
|
||||
{"limit": -1},
|
||||
{"limit": 101},
|
||||
{"after": "not-a-cursor"},
|
||||
{"before": "not-a-cursor"},
|
||||
{"after": cursor, "before": cursor},
|
||||
{"after": cursor, "spanId": "f1fa1bc863e94dd0"},
|
||||
{"before": cursor, "spanId": "f1fa1bc863e94dd0"},
|
||||
):
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, f"{params}: {response.text}"
|
||||
|
||||
missing = requests.get(url, headers=headers, timeout=10)
|
||||
assert missing.status_code == HTTPStatus.NOT_FOUND, missing.text
|
||||
@@ -1,30 +0,0 @@
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz_trace_attributes_json(
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz-trace-attributes-json",
|
||||
env_overrides={
|
||||
"SIGNOZ_FLAGGER_CONFIG_BOOLEAN_USE__TRACE__ATTRIBUTES__JSON": True,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user