mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-02 23:20:34 +01:00
Compare commits
11 Commits
nv/4325
...
ns/flamegr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed87794dee | ||
|
|
055a6cbd9b | ||
|
|
099510a497 | ||
|
|
f3e6534d35 | ||
|
|
09fe9fa7cb | ||
|
|
8b64247efa | ||
|
|
7948b3091f | ||
|
|
9bef79f664 | ||
|
|
aea4a011f9 | ||
|
|
b07335d969 | ||
|
|
c5f002110a |
19
.github/CODEOWNERS
vendored
19
.github/CODEOWNERS
vendored
@@ -169,22 +169,3 @@ go.mod @therealpandey
|
||||
## Dashboard V2
|
||||
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
|
||||
|
||||
## Infrastructure Monitoring
|
||||
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringHosts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringK8s/ @SigNoz/pulse-frontend
|
||||
|
||||
## Alerts
|
||||
/frontend/src/pages/AlertList/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/AlertDetails/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/CreateAlert/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/EditRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AlertHistory/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertRule/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/FormAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
|
||||
|
||||
@@ -440,6 +440,17 @@ tracedetail:
|
||||
max_depth_to_auto_expand: 5
|
||||
# Threshold below which all spans are returned without windowing.
|
||||
max_limit_to_select_all_spans: 10000
|
||||
flamegraph:
|
||||
# Maximum number of BFS depth levels included in a windowed response.
|
||||
max_selected_levels: 50
|
||||
# Maximum spans per level before sampling is applied.
|
||||
max_spans_per_level: 100
|
||||
# Number of highest-latency spans always included when sampling a level.
|
||||
sampling_top_latency_count: 5
|
||||
# Number of timestamp buckets used for uniform sampling within a level.
|
||||
sampling_bucket_count: 50
|
||||
# Threshold below which all spans are returned without windowing or sampling.
|
||||
select_all_spans_limit: 100000
|
||||
|
||||
##################### Authz #################################
|
||||
authz:
|
||||
|
||||
@@ -6516,6 +6516,58 @@ components:
|
||||
- attribute
|
||||
- resource
|
||||
type: string
|
||||
SpantypesFlamegraphSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
durationNano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
event:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
nullable: true
|
||||
type: array
|
||||
hasError:
|
||||
type: boolean
|
||||
level:
|
||||
format: int64
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
parentSpanId:
|
||||
type: string
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
serviceName:
|
||||
type: string
|
||||
spanId:
|
||||
type: string
|
||||
timestamp:
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
SpantypesGettableFlamegraphTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
format: int64
|
||||
type: integer
|
||||
hasMore:
|
||||
type: boolean
|
||||
spans:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesFlamegraphSpan'
|
||||
type: array
|
||||
nullable: true
|
||||
type: array
|
||||
startTimestampMillis:
|
||||
format: int64
|
||||
type: integer
|
||||
type: object
|
||||
SpantypesGettableSpanMapperGroups:
|
||||
properties:
|
||||
items:
|
||||
@@ -6581,6 +6633,15 @@ components:
|
||||
traceId:
|
||||
type: string
|
||||
type: object
|
||||
SpantypesPostableFlamegraph:
|
||||
properties:
|
||||
selectFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
type: array
|
||||
selectedSpanId:
|
||||
type: string
|
||||
type: object
|
||||
SpantypesPostableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -20027,6 +20088,75 @@ paths:
|
||||
summary: Put profile in Zeus for a deployment.
|
||||
tags:
|
||||
- zeus
|
||||
/api/v3/traces/{traceID}/flamegraph:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns the flamegraph view of spans for a given trace ID.
|
||||
operationId: GetFlamegraph
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableFlamegraph'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableFlamegraphTrace'
|
||||
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 flamegraph view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v3/traces/{traceID}/waterfall:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -7675,6 +7675,81 @@ export enum SpantypesFieldContextDTO {
|
||||
attribute = 'attribute',
|
||||
resource = 'resource',
|
||||
}
|
||||
export type SpantypesFlamegraphSpanDTOAttributes = { [key: string]: unknown };
|
||||
|
||||
export type SpantypesFlamegraphSpanDTOResource = { [key: string]: string };
|
||||
|
||||
export interface SpantypesFlamegraphSpanDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
attributes?: SpantypesFlamegraphSpanDTOAttributes;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
durationNano?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
event?: SpantypesEventDTO[] | null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasError?: boolean;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
level?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parentSpanId?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
resource?: SpantypesFlamegraphSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
serviceName?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
spanId?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableFlamegraphTraceDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
endTimestampMillis?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans?: SpantypesFlamegraphSpanDTO[][] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
startTimestampMillis?: number;
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperGroupConditionDTOAnyOf = {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -7976,6 +8051,17 @@ export interface SpantypesGettableWaterfallTraceDTO {
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableFlamegraphDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
selectedSpanId?: string;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
@@ -10277,6 +10363,17 @@ export type GetHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFlamegraphPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetFlamegraph200 = {
|
||||
data: SpantypesGettableFlamegraphTraceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetWaterfallPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetFlamegraph200,
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetWaterfall200,
|
||||
@@ -19,6 +21,7 @@ import type {
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableFlamegraphDTO,
|
||||
SpantypesPostableTraceAggregationsDTO,
|
||||
SpantypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -126,6 +129,105 @@ export const useGetTraceAggregations = <
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
*/
|
||||
export const getFlamegraph = (
|
||||
{ traceID }: GetFlamegraphPathParameters,
|
||||
spantypesPostableFlamegraphDTO?: BodyType<SpantypesPostableFlamegraphDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetFlamegraph200>({
|
||||
url: `/api/v3/traces/${traceID}/flamegraph`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableFlamegraphDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetFlamegraphMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['getFlamegraph'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return getFlamegraph(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type GetFlamegraphMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>
|
||||
>;
|
||||
export type GetFlamegraphMutationBody =
|
||||
| BodyType<SpantypesPostableFlamegraphDTO>
|
||||
| undefined;
|
||||
export type GetFlamegraphMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get flamegraph view for a trace
|
||||
*/
|
||||
export const useGetFlamegraph = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getGetFlamegraphMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
|
||||
* @summary Get waterfall view for a trace
|
||||
|
||||
@@ -120,8 +120,7 @@ export const interceptorRejected = async (
|
||||
!(
|
||||
response.config.url === '/sessions' && response.config.method === 'delete'
|
||||
) &&
|
||||
response.config.url !== '/authz/check' &&
|
||||
response.config.url !== '/api/v2/reset_password_tokens/verify'
|
||||
response.config.url !== '/authz/check'
|
||||
) {
|
||||
try {
|
||||
const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN);
|
||||
|
||||
1
frontend/src/assets/Icons/solid-x-circle.svg
Normal file
1
frontend/src/assets/Icons/solid-x-circle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#prefix__clip0_4062_7291)" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="M7 12.833A5.833 5.833 0 107 1.167a5.833 5.833 0 000 11.666z" fill="#E5484D" stroke="#E5484D"/><path d="M8.75 5.25l-3.5 3.5M5.25 5.25l3.5 3.5" stroke="#121317"/></g><defs><clipPath id="prefix__clip0_4062_7291"><path fill="#fff" d="M0 0h14v14H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -196,11 +196,7 @@ function Footer(): JSX.Element {
|
||||
</Button>
|
||||
);
|
||||
if (alertValidationMessage) {
|
||||
button = (
|
||||
<Tooltip title={alertValidationMessage}>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
}
|
||||
return button;
|
||||
}, [
|
||||
@@ -228,11 +224,7 @@ function Footer(): JSX.Element {
|
||||
</Button>
|
||||
);
|
||||
if (alertValidationMessage) {
|
||||
button = (
|
||||
<Tooltip title={alertValidationMessage}>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
}
|
||||
return button;
|
||||
}, [
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import QueryStatus from './QueryStatus';
|
||||
|
||||
function LogsActionsContainer({
|
||||
listQuery,
|
||||
selectedPanelType,
|
||||
@@ -16,6 +18,10 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
isFetching,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
}: {
|
||||
listQuery: any;
|
||||
selectedPanelType: PANEL_TYPES;
|
||||
@@ -23,6 +29,10 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart: () => void;
|
||||
orderBy: string;
|
||||
setOrderBy: (value: string) => void;
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isSuccess: boolean;
|
||||
}): JSX.Element {
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
@@ -96,6 +106,17 @@ function LogsActionsContainer({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedPanelType === PANEL_TYPES.TIME_SERIES ||
|
||||
selectedPanelType === PANEL_TYPES.TABLE) && (
|
||||
<div className="query-stats">
|
||||
<QueryStatus
|
||||
loading={isLoading || isFetching}
|
||||
error={isError}
|
||||
success={isSuccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,6 +155,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
.query-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
align-self: flex-end;
|
||||
|
||||
.rows {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.query-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
49
frontend/src/container/LogsExplorerViews/QueryStatus.tsx
Normal file
49
frontend/src/container/LogsExplorerViews/QueryStatus.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { LoaderCircle, CircleCheck } from '@signozhq/icons';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
import solidXCircleUrl from '@/assets/Icons/solid-x-circle.svg';
|
||||
|
||||
import './QueryStatus.styles.scss';
|
||||
|
||||
interface IQueryStatusProps {
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export default function QueryStatus(
|
||||
props: IQueryStatusProps,
|
||||
): React.ReactElement {
|
||||
const { loading, error, success } = props;
|
||||
|
||||
const content = useMemo((): React.ReactElement => {
|
||||
if (loading) {
|
||||
return (
|
||||
<Spin
|
||||
spinning
|
||||
size="small"
|
||||
indicator={<LoaderCircle className="animate-spin" size="md" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<img
|
||||
src={solidXCircleUrl}
|
||||
alt="header"
|
||||
className="error"
|
||||
style={{ height: '14px', width: '14px' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (success) {
|
||||
return (
|
||||
<CircleCheck className="success" size={14} fill={Color.BG_ROBIN_500} />
|
||||
);
|
||||
}
|
||||
return <div />;
|
||||
}, [error, loading, success]);
|
||||
return <div className="query-status">{content}</div>;
|
||||
}
|
||||
@@ -160,7 +160,7 @@ function LogsExplorerViewsContainer({
|
||||
'custom',
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } =
|
||||
const { data, isLoading, isFetching, isError, isSuccess, error } =
|
||||
useGetExplorerQueryRange(
|
||||
requestData,
|
||||
selectedPanelType,
|
||||
@@ -437,6 +437,10 @@ function LogsExplorerViewsContainer({
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
orderBy={orderBy}
|
||||
setOrderBy={setOrderBy}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isSuccess={isSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ function AllAttributes({
|
||||
);
|
||||
|
||||
const attributes = useMemo(
|
||||
() => attributesData?.data?.attributes ?? [],
|
||||
() => attributesData?.data.attributes ?? [],
|
||||
[attributesData],
|
||||
);
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ function MetricDetails({
|
||||
);
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (!metricMetadataResponse?.data) {
|
||||
if (!metricMetadataResponse) {
|
||||
return null;
|
||||
}
|
||||
const { type, description, unit, temporality, isMonotonic } =
|
||||
|
||||
@@ -21,10 +21,6 @@
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
color: var(--semantic-primary-foreground);
|
||||
|
||||
&--error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.reset-password-header-title {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { CircleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './ResetPassword.styles.scss';
|
||||
|
||||
interface TokenErrorContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function getErrorContent(error?: APIError): TokenErrorContent {
|
||||
const code = error?.getErrorCode();
|
||||
|
||||
if (code === 'reset_password_token_expired') {
|
||||
return {
|
||||
title: 'Reset Password token is expired',
|
||||
subtitle:
|
||||
'Password reset links are single-use and expire after a set period. Please request a new password reset link.',
|
||||
};
|
||||
}
|
||||
|
||||
if (code === 'reset_password_token_not_found') {
|
||||
return {
|
||||
title: 'Invalid Reset Link',
|
||||
subtitle:
|
||||
'This reset password link is invalid or has already been used. Please request a new password reset link.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Reset Link Unavailable',
|
||||
subtitle:
|
||||
'We could not validate your reset password link. Please request a new one.',
|
||||
};
|
||||
}
|
||||
|
||||
interface TokenErrorProps {
|
||||
error?: APIError;
|
||||
}
|
||||
|
||||
function TokenError({ error }: TokenErrorProps): JSX.Element {
|
||||
const { title, subtitle } = getErrorContent(error);
|
||||
|
||||
return (
|
||||
<AuthPageContainer>
|
||||
<div className="reset-password-card reset-password-card--centered">
|
||||
<div className="reset-password-header">
|
||||
<div className="reset-password-header-icon reset-password-header-icon--error">
|
||||
<CircleAlert size={32} />
|
||||
</div>
|
||||
<Typography.Title level={4} className="reset-password-header-title">
|
||||
{title}
|
||||
</Typography.Title>
|
||||
<Typography.Text className="reset-password-header-subtitle">
|
||||
{subtitle}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{error && <AuthError error={error} />}
|
||||
</div>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenError;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Logout } from 'api/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
@@ -16,6 +17,10 @@ jest.mock('lib/history', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('api/utils', () => ({
|
||||
Logout: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSuccessNotification = jest.fn();
|
||||
const mockErrorNotification = jest.fn();
|
||||
|
||||
@@ -65,6 +70,17 @@ describe('ResetPassword Component', () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/signoz 1\.0\.0/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to login when token is missing', () => {
|
||||
window.history.pushState({}, '', '/password-reset');
|
||||
|
||||
render(<ResetPassword version="1.0.0" />, undefined, {
|
||||
initialRoute: '/password-reset',
|
||||
});
|
||||
|
||||
expect(Logout).toHaveBeenCalled();
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-use';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Form, Input as AntdInput } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Logout } from 'api/utils';
|
||||
import resetPasswordApi from 'api/v1/factor_password/resetPassword';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
@@ -37,6 +38,13 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
Logout();
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const handleFormSubmit: () => Promise<void> = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { Logout } from 'api/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { createErrorResponse, rest, server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ResetPassword from '../index';
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: { search: '' },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('api/utils', () => ({
|
||||
Logout: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const VERIFY_TOKEN_ENDPOINT = '*/api/v2/reset_password_tokens/verify';
|
||||
const VERSION_ENDPOINT = '*/version';
|
||||
|
||||
const mockHistoryPush = history.push as jest.MockedFunction<
|
||||
typeof history.push
|
||||
>;
|
||||
|
||||
const successVerifyResponse = {
|
||||
data: { id: 'token-id', token: 'valid-token' },
|
||||
};
|
||||
|
||||
const successVersionResponse = {
|
||||
version: '0.0.1',
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
};
|
||||
|
||||
describe('ResetPassword Page', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(successVersionResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Token validation on page load', () => {
|
||||
it('shows spinner then form when token is valid', async () => {
|
||||
server.use(
|
||||
rest.post(VERIFY_TOKEN_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.delay(50), ctx.status(200), ctx.json(successVerifyResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
window.history.pushState({}, '', '/password-reset?token=valid-token');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset?token=valid-token',
|
||||
});
|
||||
|
||||
// Loading state: spinner visible, form and error absent
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('password')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/reset password token is expired/i),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// After verification resolves: form is shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('password')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('confirmPassword')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Invalid Reset Link" when token is not found (404)', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
VERIFY_TOKEN_ENDPOINT,
|
||||
createErrorResponse(
|
||||
404,
|
||||
'reset_password_token_not_found',
|
||||
'reset password token does not exist',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
window.history.pushState({}, '', '/password-reset?token=invalid-token');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset?token=invalid-token',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid reset link/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/invalid or has already been used/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/reset password token does not exist/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "token is expired" when token is expired (401) without redirecting to login', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
VERIFY_TOKEN_ENDPOINT,
|
||||
createErrorResponse(
|
||||
401,
|
||||
'reset_password_token_expired',
|
||||
'reset password token has expired',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
window.history.pushState({}, '', '/password-reset?token=expired-token');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset?token=expired-token',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/reset password token is expired/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/single-use and expire after a set period/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/reset password token has expired/i),
|
||||
).toBeInTheDocument();
|
||||
// 401 from this endpoint must NOT trigger logout/redirect
|
||||
expect(mockHistoryPush).not.toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
expect(Logout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects to login when no token is in the URL', async () => {
|
||||
window.history.pushState({}, '', '/password-reset');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
|
||||
expect(Logout).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useLocation } from 'react-use';
|
||||
import { AxiosError } from 'axios';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import getUserVersion from 'api/v1/version/get';
|
||||
import { verifyResetPasswordToken } from 'api/generated/services/users';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Logout } from 'api/utils';
|
||||
import Spinner from 'components/Spinner';
|
||||
import ResetPasswordContainer from 'container/ResetPassword';
|
||||
import TokenError from 'container/ResetPassword/TokenError';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -19,65 +10,24 @@ import APIError from 'types/api/error';
|
||||
function ResetPassword(): JSX.Element {
|
||||
const { user, isLoggedIn } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { search } = useLocation();
|
||||
const params = new URLSearchParams(search || '');
|
||||
const token = params.get('token') || '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
void Logout();
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const {
|
||||
data: versionData,
|
||||
isLoading: isVersionLoading,
|
||||
error: versionError,
|
||||
} = useQuery({
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryFn: getUserVersion,
|
||||
queryKey: ['getUserVersion', user?.accessJwt],
|
||||
enabled: !isLoggedIn,
|
||||
});
|
||||
|
||||
const {
|
||||
isLoading: isVerifying,
|
||||
isError: isTokenError,
|
||||
error: tokenError,
|
||||
} = useQuery<
|
||||
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
|
||||
AxiosError<RenderErrorResponseDTO>
|
||||
>({
|
||||
queryFn: () => verifyResetPasswordToken({ token }),
|
||||
queryKey: ['verifyResetPasswordToken', token],
|
||||
enabled: !!token,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const tokenApiError = useMemo(
|
||||
() => convertToApiError(tokenError),
|
||||
[tokenError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (versionError) {
|
||||
showErrorModal(versionError as APIError);
|
||||
if (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [versionError, showErrorModal]);
|
||||
}, [error, showErrorModal]);
|
||||
|
||||
if (!token) {
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading..." />;
|
||||
}
|
||||
|
||||
if (isVersionLoading || isVerifying) {
|
||||
return <Spinner tip="Validating your reset password token..." />;
|
||||
}
|
||||
|
||||
if (isTokenError) {
|
||||
return <TokenError error={tokenApiError} />;
|
||||
}
|
||||
|
||||
return <ResetPasswordContainer version={versionData?.data.version || ''} />;
|
||||
return <ResetPasswordContainer version={data?.data.version || ''} />;
|
||||
}
|
||||
|
||||
export default ResetPassword;
|
||||
|
||||
@@ -67,5 +67,24 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v3/traces/{traceID}/flamegraph", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetFlamegraph),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetFlamegraph",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get flamegraph view for a trace",
|
||||
Description: "Returns the flamegraph view of spans for a given trace ID.",
|
||||
Request: new(spantypes.PostableFlamegraph),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spantypes.GettableFlamegraphTrace),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,16 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Waterfall WaterfallConfig `mapstructure:"waterfall"`
|
||||
Waterfall WaterfallConfig `mapstructure:"waterfall"`
|
||||
Flamegraph FlamegraphConfig `mapstructure:"flamegraph"`
|
||||
}
|
||||
|
||||
type FlamegraphConfig struct {
|
||||
MaxSelectedLevels int `mapstructure:"max_selected_levels"`
|
||||
MaxSpansPerLevel int `mapstructure:"max_spans_per_level"`
|
||||
SamplingTopLatencySpansCount int `mapstructure:"sampling_top_latency_count"`
|
||||
SamplingBucketCount int `mapstructure:"sampling_bucket_count"`
|
||||
SelectAllSpansLimit uint `mapstructure:"select_all_spans_limit"`
|
||||
}
|
||||
|
||||
type WaterfallConfig struct {
|
||||
@@ -29,6 +38,13 @@ func newConfig() factory.Config {
|
||||
MaxDepthToAutoExpand: 5,
|
||||
MaxLimitToSelectAllSpans: 10_000,
|
||||
},
|
||||
Flamegraph: FlamegraphConfig{
|
||||
MaxSelectedLevels: 50,
|
||||
MaxSpansPerLevel: 100,
|
||||
SamplingTopLatencySpansCount: 5,
|
||||
SamplingBucketCount: 50,
|
||||
SelectAllSpansLimit: 100_000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,5 +61,25 @@ func (c Config) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"tracedetail.waterfall.max_limit_to_select_all_spans must be positive")
|
||||
}
|
||||
if c.Flamegraph.MaxSelectedLevels <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"tracedetail.flamegraph.level_limit must be positive, got %d", c.Flamegraph.MaxSelectedLevels)
|
||||
}
|
||||
if c.Flamegraph.MaxSpansPerLevel <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"tracedetail.flamegraph.spans_per_level must be positive, got %d", c.Flamegraph.MaxSpansPerLevel)
|
||||
}
|
||||
if c.Flamegraph.SamplingTopLatencySpansCount < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"tracedetail.flamegraph.top_latency_count cannot be negative, got %d", c.Flamegraph.SamplingTopLatencySpansCount)
|
||||
}
|
||||
if c.Flamegraph.SamplingBucketCount <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"tracedetail.flamegraph.bucket_count must be positive, got %d", c.Flamegraph.SamplingBucketCount)
|
||||
}
|
||||
if c.Flamegraph.SelectAllSpansLimit == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"tracedetail.flamegraph.max_limit_to_select_all_spans must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -80,3 +80,18 @@ func (h *handler) GetTraceAggregations(rw http.ResponseWriter, r *http.Request)
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(spantypes.PostableFlamegraph)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
result, err := h.module.GetFlamegraph(r.Context(), mux.Vars(r)["traceID"], req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -46,29 +46,6 @@ func (m *module) GetWaterfall(ctx context.Context, traceID string, req *spantype
|
||||
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans, aggregationResults), nil
|
||||
}
|
||||
|
||||
// getTraceData fetches all spans for a trace and builds the WaterfallTrace.
|
||||
func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.WaterfallTrace, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(spanItems) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
nodes := make([]*spantypes.WaterfallSpan, len(spanItems))
|
||||
for i := range spanItems {
|
||||
nodes[i] = spanItems[i].ToWaterfallSpan(traceID)
|
||||
}
|
||||
return spantypes.NewWaterfallTraceFromSpans(nodes), 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
|
||||
@@ -85,32 +62,11 @@ func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpa
|
||||
return m.getFullWaterfall(ctx, traceID, summary)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if len(spanItems) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
nodes := make([]*spantypes.WaterfallSpan, len(spanItems))
|
||||
for i := range spanItems {
|
||||
nodes[i] = spanItems[i].ToWaterfallSpan(traceID)
|
||||
}
|
||||
waterfallTrace := spantypes.NewWaterfallTraceFromSpans(nodes)
|
||||
selectedSpans := waterfallTrace.GetAllSpans()
|
||||
|
||||
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, nil, true, nil), nil
|
||||
}
|
||||
|
||||
func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
traceDurationNs := uint64(summary.End.UnixNano()) - uint64(summary.Start.UnixNano())
|
||||
|
||||
results := make([]spantypes.SpanAggregationResult, 0, len(req.Aggregations))
|
||||
@@ -148,6 +104,60 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
|
||||
return &spantypes.GettableTraceAggregations{Aggregations: results}, nil
|
||||
}
|
||||
|
||||
func (m *module) GetFlamegraph(ctx context.Context, traceID string, req *spantypes.PostableFlamegraph) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if summary.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
|
||||
return m.getFullFlamegraph(ctx, traceID, summary)
|
||||
}
|
||||
return m.getWindowedFlamegraph(ctx, traceID, req.SelectedSpanID, summary)
|
||||
}
|
||||
|
||||
// getTraceData fetches all spans for a trace and builds the WaterfallTrace.
|
||||
func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.WaterfallTrace, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(spanItems) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
nodes := make([]*spantypes.WaterfallSpan, len(spanItems))
|
||||
for i := range spanItems {
|
||||
nodes[i] = spanItems[i].ToWaterfallSpan(traceID)
|
||||
}
|
||||
return spantypes.NewWaterfallTraceFromSpans(nodes), nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if len(spanItems) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
nodes := make([]*spantypes.WaterfallSpan, len(spanItems))
|
||||
for i := range spanItems {
|
||||
nodes[i] = spanItems[i].ToWaterfallSpan(traceID)
|
||||
}
|
||||
waterfallTrace := spantypes.NewWaterfallTraceFromSpans(nodes)
|
||||
selectedSpans := waterfallTrace.GetAllSpans()
|
||||
|
||||
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, nil, true, nil), nil
|
||||
}
|
||||
|
||||
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
|
||||
func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
|
||||
// Step 1: minimal fetch → build full tree → select visible window
|
||||
@@ -188,3 +198,50 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
|
||||
waterfallTrace, selectedSpans, uncollapsedSpans, false, nil,
|
||||
), nil
|
||||
}
|
||||
|
||||
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
fullSpans, err := m.store.GetTraceSpans(ctx, traceID, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fullSpans) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans)
|
||||
return spantypes.NewGettableFlamegraphTrace(
|
||||
flamegraphTrace.GetAllLevels(),
|
||||
summary.Start.UnixMilli(), summary.End.UnixMilli(), false,
|
||||
), nil
|
||||
}
|
||||
|
||||
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
|
||||
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, summary *spantypes.TraceSummary) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary.Start, summary.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(minimalSpans) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
flamegraphTrace := spantypes.NewFlamegraphTraceFromMinimal(minimalSpans)
|
||||
minimalSpans = nil //nolint:ineffassign,wastedassign // release backing array before further db calls
|
||||
|
||||
cfg := m.config.Flamegraph
|
||||
selectedSpans := flamegraphTrace.GetSelectedLevels(selectedSpanID,
|
||||
cfg.MaxSelectedLevels, cfg.MaxSpansPerLevel, cfg.SamplingTopLatencySpansCount, cfg.SamplingBucketCount)
|
||||
if len(selectedSpans) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, traceID, summary.Start, summary.End,
|
||||
spantypes.FlamegraphWindowSpanIDs(selectedSpans))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spantypes.NewGettableFlamegraphTrace(
|
||||
flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans),
|
||||
summary.Start.UnixMilli(), summary.End.UnixMilli(), true,
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type Handler interface {
|
||||
GetWaterfall(http.ResponseWriter, *http.Request)
|
||||
GetWaterfallV4(http.ResponseWriter, *http.Request)
|
||||
GetTraceAggregations(http.ResponseWriter, *http.Request)
|
||||
GetFlamegraph(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
@@ -19,4 +20,5 @@ type Module interface {
|
||||
GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
|
||||
GetFlamegraph(ctx context.Context, traceID string, req *spantypes.PostableFlamegraph) (*spantypes.GettableFlamegraphTrace, error)
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
}
|
||||
}
|
||||
preseededResults := make(map[string]any)
|
||||
for _, name := range missingMetricQueries {
|
||||
for _, name := range missingMetricQueries { // at this point missing metrics will not have any non existent metrics, only normal ones
|
||||
switch req.RequestType {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
|
||||
@@ -375,24 +375,11 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
return missingMetricQueries, "", nil
|
||||
}
|
||||
|
||||
isInternalMetric := func(n string) bool { return strings.HasPrefix(n, "signoz.") || strings.HasPrefix(n, "signoz_") }
|
||||
externalMissingMetrics := make([]string, 0, len(missingMetrics))
|
||||
for _, m := range missingMetrics {
|
||||
if !isInternalMetric(m) {
|
||||
externalMissingMetrics = append(externalMissingMetrics, m)
|
||||
}
|
||||
}
|
||||
if len(externalMissingMetrics) == 0 {
|
||||
// this means all missing metrics are internal, and since internal metrics
|
||||
// aren't user-controlled, skip errors/warnings for them since users can't act on them
|
||||
return missingMetricQueries, "", nil
|
||||
}
|
||||
|
||||
// Classify each missing metric: never-seen → NotFound error; seen-but-no-
|
||||
// data-in-window → dormant warning.
|
||||
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, externalMissingMetrics...)
|
||||
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, missingMetrics...)
|
||||
nonExistentMetrics := []string{}
|
||||
for _, name := range externalMissingMetrics {
|
||||
for _, name := range missingMetrics {
|
||||
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
|
||||
continue
|
||||
}
|
||||
@@ -413,11 +400,11 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
return name
|
||||
}
|
||||
if len(externalMissingMetrics) == 1 {
|
||||
if len(missingMetrics) == 1 {
|
||||
dormantWarning = fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(missingMetrics[0]))
|
||||
} else {
|
||||
parts := make([]string, len(externalMissingMetrics))
|
||||
for i, m := range externalMissingMetrics {
|
||||
parts := make([]string, len(missingMetrics))
|
||||
for i, m := range missingMetrics {
|
||||
parts[i] = lastSeenStr(m)
|
||||
}
|
||||
dormantWarning = fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", "))
|
||||
|
||||
@@ -124,10 +124,8 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
-------------------------------- End of tech debt ----------------------------
|
||||
*/
|
||||
|
||||
for _, action := range adjustTraceKeys(keys, &query, requestType) {
|
||||
// TODO: change to debug level once we are confident about the behavior
|
||||
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
query = b.adjustKeys(ctx, keys, query, requestType)
|
||||
|
||||
// Create SQL builder
|
||||
q := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
@@ -195,30 +193,24 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
|
||||
return keySelectors
|
||||
}
|
||||
|
||||
// mergeDeprecatedTraceKeys prepends deprecated intrinsic/calculated trace field
|
||||
// definitions to the keys map. We do this during statement building, not at
|
||||
// metadata fetch time, because:
|
||||
// 1. Filter expressions that reference deprecated columns must continue to
|
||||
// resolve — otherwise they fail with "key not found".
|
||||
// 2. Doing it at metadata fetch time would also surface deprecated keys in
|
||||
// autocomplete suggestions, which we don't want.
|
||||
// 3. We prepend (not append) so the intrinsic/calculated entry wins ordering
|
||||
// in the multi_if SQL expression.
|
||||
func mergeDeprecatedTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey) {
|
||||
func (b *traceQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], requestType qbtypes.RequestType) qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation] {
|
||||
|
||||
// add deprecated fields only during statement building
|
||||
// why?
|
||||
// 1. to not fail filter expression that use deprecated cols
|
||||
// 2. this could have been moved to metadata fetching itself, however, that
|
||||
// would mean, they also show up in suggestions we we don't want to do
|
||||
// 3. reason for not doing a simple append is to keep intrinsic/calculated field first so that it gets
|
||||
// priority in multi_if sql expression
|
||||
for fieldKeyName, fieldKey := range IntrinsicFieldsDeprecated {
|
||||
keys[fieldKeyName] = append([]*telemetrytypes.TelemetryFieldKey{&fieldKey}, keys[fieldKeyName]...)
|
||||
}
|
||||
for fieldKeyName, fieldKey := range CalculatedFieldsDeprecated {
|
||||
keys[fieldKeyName] = append([]*telemetrytypes.TelemetryFieldKey{&fieldKey}, keys[fieldKeyName]...)
|
||||
}
|
||||
}
|
||||
|
||||
func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query *qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], requestType qbtypes.RequestType) []string {
|
||||
|
||||
mergeDeprecatedTraceKeys(keys)
|
||||
|
||||
// Adjust keys for alias expressions in aggregations
|
||||
actions := querybuilder.AdjustKeysForAliasExpressions(query, requestType)
|
||||
actions := querybuilder.AdjustKeysForAliasExpressions(&query, requestType)
|
||||
|
||||
/*
|
||||
Check if user is using multiple contexts or data types for same field name
|
||||
@@ -236,7 +228,7 @@ func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query
|
||||
and make it just http.status_code and remove the duplicate entry.
|
||||
*/
|
||||
|
||||
actions = append(actions, querybuilder.AdjustDuplicateKeys(query)...)
|
||||
actions = append(actions, querybuilder.AdjustDuplicateKeys(&query)...)
|
||||
|
||||
/*
|
||||
Now adjust each key to have correct context and data type
|
||||
@@ -244,20 +236,24 @@ func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query
|
||||
Reason for doing this is to not create an unexpected behavior for users
|
||||
*/
|
||||
for idx := range query.SelectFields {
|
||||
actions = append(actions, adjustTraceKey(&query.SelectFields[idx], keys)...)
|
||||
actions = append(actions, b.adjustKey(&query.SelectFields[idx], keys)...)
|
||||
}
|
||||
for idx := range query.GroupBy {
|
||||
actions = append(actions, adjustTraceKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
|
||||
actions = append(actions, b.adjustKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
|
||||
}
|
||||
for idx := range query.Order {
|
||||
actions = append(actions, adjustTraceKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
|
||||
actions = append(actions, b.adjustKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
|
||||
}
|
||||
|
||||
return actions
|
||||
for _, action := range actions {
|
||||
// TODO: change to debug level once we are confident about the behavior
|
||||
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// adjustTraceKey resolves a single TelemetryFieldKey against the keys map.
|
||||
func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
|
||||
func (b *traceQueryStatementBuilder) adjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
|
||||
|
||||
// for recording actions taken
|
||||
actions := []string{}
|
||||
|
||||
@@ -1125,13 +1125,28 @@ func TestAdjustKey(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
// Create a copy of the input key to avoid modifying the original
|
||||
key := c.inputKey
|
||||
|
||||
// Call adjustKey
|
||||
adjustTraceKey(&key, c.keysMap)
|
||||
statementBuilder.adjustKey(&key, c.keysMap)
|
||||
|
||||
// Verify the key was adjusted as expected
|
||||
require.Equal(t, c.expectedKey.Name, key.Name, "key name should match")
|
||||
@@ -1384,6 +1399,21 @@ func TestAdjustKeys(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
// Create a deep copy of the keys map to avoid modifying the original
|
||||
@@ -1394,7 +1424,7 @@ func TestAdjustKeys(t *testing.T) {
|
||||
}
|
||||
|
||||
// Call adjustKeys
|
||||
adjustTraceKeys(keysMapCopy, &c.query, qbtypes.RequestTypeScalar)
|
||||
c.query = statementBuilder.adjustKeys(context.Background(), keysMapCopy, c.query, qbtypes.RequestTypeScalar)
|
||||
|
||||
// Verify select fields were adjusted
|
||||
if c.expectedSelectFields != nil {
|
||||
|
||||
@@ -216,13 +216,6 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
|
||||
}
|
||||
b.stmtBuilder.logger.DebugContext(ctx, "Retrieved keys for query", slog.String("query_name", queryName), slog.Int("keys_count", len(keys)))
|
||||
|
||||
// The CTE only selects spans matching the filter. Aggregations, group by
|
||||
// and order by run later in buildFinalQuery, so RequestTypeRaw is fine here.
|
||||
for _, action := range adjustTraceKeys(keys, query, qbtypes.RequestTypeRaw) {
|
||||
// TODO: change to debug level once we are confident about the behavior
|
||||
b.stmtBuilder.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
|
||||
// Build resource filter CTE for this specific query
|
||||
resourceFilterCTEName := fmt.Sprintf("__resource_filter_%s", cteName)
|
||||
resourceStmt, err := b.buildResourceFilterCTE(ctx, *query)
|
||||
@@ -424,28 +417,21 @@ func (b *traceOperatorCTEBuilder) buildNotCTE(leftCTE, rightCTE string) (string,
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFromCTE string, requestType qbtypes.RequestType) (*qbtypes.Statement, error) {
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.adjustOperatorKeys(ctx, keys, requestType)
|
||||
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildListQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildListQuery(ctx, selectFromCTE)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
return b.buildTimeSeriesQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildTimeSeriesQuery(ctx, selectFromCTE)
|
||||
case qbtypes.RequestTypeTrace:
|
||||
return b.buildTraceQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildTraceQuery(ctx, selectFromCTE)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
return b.buildScalarQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildScalarQuery(ctx, selectFromCTE)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
// Select core fields
|
||||
@@ -467,6 +453,22 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
|
||||
"parent_span_id": true,
|
||||
}
|
||||
|
||||
// Get keys for selectFields
|
||||
keySelectors := b.getKeySelectors()
|
||||
for _, field := range b.operator.SelectFields {
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: field.FieldContext,
|
||||
FieldDataType: field.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add selectFields using ColumnExpressionFor since we now have all base table columns
|
||||
for _, field := range b.operator.SelectFields {
|
||||
if selectedFields[field.Name] {
|
||||
@@ -516,44 +518,6 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
|
||||
}, nil
|
||||
}
|
||||
|
||||
// adjustOperatorKeys runs the same key adjustments as adjustTraceKeys, but on
|
||||
// the operator's own fields. The operator has a different struct shape than
|
||||
// QueryBuilderQuery, so we copy the relevant fields into a temp query, run
|
||||
// the shared helpers, and copy the results back.
|
||||
func (b *traceOperatorCTEBuilder) adjustOperatorKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, requestType qbtypes.RequestType) {
|
||||
mergeDeprecatedTraceKeys(keys)
|
||||
|
||||
tmp := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Aggregations: b.operator.Aggregations,
|
||||
SelectFields: b.operator.SelectFields,
|
||||
GroupBy: b.operator.GroupBy,
|
||||
Order: b.operator.Order,
|
||||
}
|
||||
|
||||
actions := querybuilder.AdjustKeysForAliasExpressions(&tmp, requestType)
|
||||
actions = append(actions, querybuilder.AdjustDuplicateKeys(&tmp)...)
|
||||
|
||||
for idx := range tmp.SelectFields {
|
||||
actions = append(actions, adjustTraceKey(&tmp.SelectFields[idx], keys)...)
|
||||
}
|
||||
for idx := range tmp.GroupBy {
|
||||
actions = append(actions, adjustTraceKey(&tmp.GroupBy[idx].TelemetryFieldKey, keys)...)
|
||||
}
|
||||
for idx := range tmp.Order {
|
||||
actions = append(actions, adjustTraceKey(&tmp.Order[idx].Key.TelemetryFieldKey, keys)...)
|
||||
}
|
||||
|
||||
// Copy back the slices the helpers can rewrite.
|
||||
b.operator.Aggregations = tmp.Aggregations
|
||||
b.operator.SelectFields = tmp.SelectFields
|
||||
b.operator.GroupBy = tmp.GroupBy
|
||||
b.operator.Order = tmp.Order
|
||||
|
||||
for _, action := range actions {
|
||||
b.stmtBuilder.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySelector {
|
||||
var keySelectors []*telemetrytypes.FieldKeySelector
|
||||
|
||||
@@ -581,15 +545,6 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
|
||||
})
|
||||
}
|
||||
|
||||
for _, sf := range b.operator.SelectFields {
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: sf.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: sf.FieldContext,
|
||||
FieldDataType: sf.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
for i := range keySelectors {
|
||||
keySelectors[i].Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
@@ -597,7 +552,7 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
|
||||
return keySelectors
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
sb.Select(fmt.Sprintf(
|
||||
@@ -605,6 +560,12 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
int64(b.operator.StepInterval.Seconds()),
|
||||
))
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allGroupByArgs []any
|
||||
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
@@ -683,7 +644,8 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||
|
||||
// Add HAVING clause if specified
|
||||
if err := b.addHavingClause(sb); err != nil {
|
||||
err = b.addHavingClause(sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -710,11 +672,17 @@ func (b *traceOperatorCTEBuilder) buildTraceSummaryCTE(selectFromCTE string) {
|
||||
b.addCTE("trace_summary", sql, args, []string{"all_spans", selectFromCTE})
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
b.buildTraceSummaryCTE(selectFromCTE)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allGroupByArgs []any
|
||||
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
@@ -796,7 +764,8 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
sb.GroupBy(groupByKeys...)
|
||||
}
|
||||
|
||||
if err := b.addHavingClause(sb); err != nil {
|
||||
err = b.addHavingClause(sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -852,9 +821,15 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allGroupByArgs []any
|
||||
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
@@ -936,7 +911,8 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
|
||||
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||
|
||||
// Add HAVING clause if specified
|
||||
if err := b.addHavingClause(sb); err != nil {
|
||||
err = b.addHavingClause(sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -14,24 +14,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestTraceOperatorStatementBuilder(t *testing.T) *traceOperatorStatementBuilder {
|
||||
t.Helper()
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
traceStmtBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, fm, cb, aggExprRewriter, nil, fl,
|
||||
)
|
||||
return NewTraceOperatorStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, fm, cb, traceStmtBuilder, aggExprRewriter, fl,
|
||||
)
|
||||
}
|
||||
|
||||
func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -481,7 +463,32 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
statementBuilder := newTestTraceOperatorStatementBuilder(t)
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
|
||||
traceStmtBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
statementBuilder := NewTraceOperatorStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
traceStmtBuilder,
|
||||
aggExprRewriter,
|
||||
fl,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -572,7 +579,32 @@ func TestTraceOperatorStatementBuilderErrors(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
statementBuilder := newTestTraceOperatorStatementBuilder(t)
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
|
||||
traceStmtBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
statementBuilder := NewTraceOperatorStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
traceStmtBuilder,
|
||||
aggExprRewriter,
|
||||
fl,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -594,142 +626,3 @@ func TestTraceOperatorStatementBuilderErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceOperatorStatementBuilderAdjustsKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
operator qbtypes.QueryBuilderTraceOperator
|
||||
builderFilter string
|
||||
wantSQL string
|
||||
wantArgs []any
|
||||
}{
|
||||
{
|
||||
name: "deprecated duration filter in referenced builder query",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
operator: qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "A",
|
||||
Limit: 10,
|
||||
},
|
||||
builderFilter: "durationNano = '3s'",
|
||||
wantSQL: "duration_nano = ?",
|
||||
wantArgs: []any{int64(3000000000)},
|
||||
},
|
||||
{
|
||||
name: "context-prefixed aggregation alias in order by",
|
||||
requestType: qbtypes.RequestTypeScalar,
|
||||
operator: qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "A",
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
Alias: "span.count_",
|
||||
},
|
||||
},
|
||||
Order: []qbtypes.OrderBy{
|
||||
{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "count_",
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantSQL: "ORDER BY __result_0 desc",
|
||||
},
|
||||
}
|
||||
|
||||
statementBuilder := newTestTraceOperatorStatementBuilder(t)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.operator.ParseExpression()
|
||||
require.NoError(t, err)
|
||||
|
||||
filter := c.builderFilter
|
||||
if filter == "" {
|
||||
filter = "service.name = 'frontend'"
|
||||
}
|
||||
|
||||
q, err := statementBuilder.Build(
|
||||
context.Background(),
|
||||
1747947419000,
|
||||
1747983448000,
|
||||
c.requestType,
|
||||
c.operator,
|
||||
&qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: filter},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, q.Query, c.wantSQL)
|
||||
for _, arg := range c.wantArgs {
|
||||
require.Contains(t, q.Args, arg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTraceOperatorStatementBuilderDeduplicatesKeys checks that a trace
|
||||
// operator with the same field name listed twice in GroupBy (once with a
|
||||
// context, once without) ends up with a single column in the outer SELECT
|
||||
// and a single entry in GROUP BY.
|
||||
func TestTraceOperatorStatementBuilderDeduplicatesKeys(t *testing.T) {
|
||||
statementBuilder := newTestTraceOperatorStatementBuilder(t)
|
||||
|
||||
operator := qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "A",
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()"},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "http.method",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
}},
|
||||
// Same name, no context — should be merged with the entry above.
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "http.method",
|
||||
}},
|
||||
},
|
||||
}
|
||||
require.NoError(t, operator.ParseExpression())
|
||||
|
||||
q, err := statementBuilder.Build(
|
||||
context.Background(),
|
||||
1747947419000,
|
||||
1747983448000,
|
||||
qbtypes.RequestTypeScalar,
|
||||
operator,
|
||||
&qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'frontend'"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, q.Query,
|
||||
"SELECT toString(multiIf(mapContains(attributes_string, 'http.method') = ?, attributes_string['http.method'], NULL)) AS `http.method`, count() AS __result_0 FROM A GROUP BY `http.method` ORDER BY __result_0 DESC")
|
||||
}
|
||||
|
||||
81
pkg/types/spantypes/flamegraph_span.go
Normal file
81
pkg/types/spantypes/flamegraph_span.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"maps"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type FlamegraphSpan struct {
|
||||
SpanID string `json:"spanId"`
|
||||
ParentSpanID string `json:"parentSpanId"`
|
||||
Timestamp uint64 `json:"timestamp"`
|
||||
DurationNano uint64 `json:"durationNano"`
|
||||
HasError bool `json:"hasError"`
|
||||
ServiceName string `json:"serviceName"`
|
||||
Name string `json:"name"`
|
||||
Level int64 `json:"level"`
|
||||
Events []Event `json:"event"`
|
||||
Attributes map[string]any `json:"attributes,omitempty"`
|
||||
Resource map[string]string `json:"resource,omitempty"`
|
||||
Children []*FlamegraphSpan `json:"-"` // internal tree use only
|
||||
}
|
||||
|
||||
// FlamegraphLevel groups span IDs at a single level within the selected window.
|
||||
type FlamegraphLevel struct {
|
||||
Level int64
|
||||
SpanIDs []string
|
||||
}
|
||||
|
||||
type PostableFlamegraph struct {
|
||||
SelectedSpanID string `json:"selectedSpanId"`
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitempty"`
|
||||
}
|
||||
|
||||
// GettableFlamegraphTrace is the response for the v3 flamegraph API.
|
||||
type GettableFlamegraphTrace struct {
|
||||
Spans [][]*FlamegraphSpan `json:"spans"`
|
||||
StartTimestampMillis int64 `json:"startTimestampMillis"`
|
||||
EndTimestampMillis int64 `json:"endTimestampMillis"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
func NewGettableFlamegraphTrace(spans [][]*FlamegraphSpan, startMs, endMs int64, hasMore bool) *GettableFlamegraphTrace {
|
||||
return &GettableFlamegraphTrace{
|
||||
Spans: spans,
|
||||
StartTimestampMillis: startMs,
|
||||
EndTimestampMillis: endMs,
|
||||
HasMore: hasMore,
|
||||
}
|
||||
}
|
||||
|
||||
func NewFlamegraphSpanFromStorable(s *StorableSpan, level int64) *FlamegraphSpan {
|
||||
resources := make(map[string]string, len(s.ResourcesString))
|
||||
maps.Copy(resources, s.ResourcesString)
|
||||
return &FlamegraphSpan{
|
||||
SpanID: s.SpanID,
|
||||
ParentSpanID: s.ParentSpanID,
|
||||
Timestamp: uint64(s.StartTime.UnixNano()),
|
||||
DurationNano: s.DurationNano,
|
||||
HasError: s.HasError,
|
||||
ServiceName: s.ServiceName,
|
||||
Name: s.Name,
|
||||
Level: level,
|
||||
Events: s.UnmarshalledEvents(),
|
||||
Attributes: s.Attributes(),
|
||||
Resource: resources,
|
||||
}
|
||||
}
|
||||
|
||||
// FlamegraphWindowSpanIDs collects all span IDs from a level window into a flat slice.
|
||||
func FlamegraphWindowSpanIDs(window []FlamegraphLevel) []string {
|
||||
total := 0
|
||||
for _, lvl := range window {
|
||||
total += len(lvl.SpanIDs)
|
||||
}
|
||||
ids := make([]string, 0, total)
|
||||
for _, lvl := range window {
|
||||
ids = append(ids, lvl.SpanIDs...)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
118
pkg/types/spantypes/flamegraph_trace.go
Normal file
118
pkg/types/spantypes/flamegraph_trace.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// FlamegraphTrace holds the level wise tree built from minimal spans.
|
||||
type FlamegraphTrace struct {
|
||||
roots []*FlamegraphSpan
|
||||
nodeByID map[string]*FlamegraphSpan
|
||||
startTime uint64
|
||||
endTime uint64
|
||||
}
|
||||
|
||||
func NewFlamegraphTraceFromMinimal(spans []MinimalSpan) *FlamegraphTrace {
|
||||
t := &FlamegraphTrace{
|
||||
nodeByID: make(map[string]*FlamegraphSpan, len(spans)),
|
||||
}
|
||||
for i := range spans {
|
||||
node := spans[i].ToFlamegraphSpan()
|
||||
t.updateTimeRange(node.Timestamp, node.DurationNano)
|
||||
t.nodeByID[node.SpanID] = node
|
||||
}
|
||||
t.buildSpanTree()
|
||||
return t
|
||||
}
|
||||
|
||||
func NewFlamegraphTraceFromStorable(spans []StorableSpan) *FlamegraphTrace {
|
||||
t := &FlamegraphTrace{
|
||||
nodeByID: make(map[string]*FlamegraphSpan, len(spans)),
|
||||
}
|
||||
for i := range spans {
|
||||
node := NewFlamegraphSpanFromStorable(&spans[i], 0) // level is set later by BFS
|
||||
t.updateTimeRange(node.Timestamp, node.DurationNano)
|
||||
t.nodeByID[node.SpanID] = node
|
||||
}
|
||||
t.buildSpanTree()
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *FlamegraphTrace) GetAllLevels() [][]*FlamegraphSpan {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSelectedLevels returns the window of levels around selectedSpanID with sampling applied to dense levels.
|
||||
func (t *FlamegraphTrace) GetSelectedLevels(
|
||||
selectedSpanID string,
|
||||
levelLimit, spansPerLevel, topLatencyCount, bucketCount int,
|
||||
) []FlamegraphLevel {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *FlamegraphTrace) EnrichSelectedSpans(selectedSpans []FlamegraphLevel, fullSpans []StorableSpan) [][]*FlamegraphSpan {
|
||||
fullByID := make(map[string]*StorableSpan, len(fullSpans))
|
||||
for i := range fullSpans {
|
||||
fullByID[fullSpans[i].SpanID] = &fullSpans[i]
|
||||
}
|
||||
|
||||
result := make([][]*FlamegraphSpan, len(selectedSpans))
|
||||
for i, lvl := range selectedSpans {
|
||||
result[i] = make([]*FlamegraphSpan, 0, len(lvl.SpanIDs))
|
||||
for _, spanID := range lvl.SpanIDs {
|
||||
if full, ok := fullByID[spanID]; ok {
|
||||
result[i] = append(result[i], NewFlamegraphSpanFromStorable(full, lvl.Level))
|
||||
} else if lean, ok := t.nodeByID[spanID]; ok {
|
||||
result[i] = append(result[i], lean)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (t *FlamegraphTrace) updateTimeRange(timestamp, durationNano uint64) {
|
||||
if t.startTime == 0 || timestamp < t.startTime {
|
||||
t.startTime = timestamp
|
||||
}
|
||||
if end := timestamp + durationNano; end > t.endTime {
|
||||
t.endTime = end
|
||||
}
|
||||
}
|
||||
|
||||
func (t *FlamegraphTrace) buildSpanTree() {
|
||||
for _, node := range t.nodeByID {
|
||||
if node.ParentSpanID != "" {
|
||||
if parent, ok := t.nodeByID[node.ParentSpanID]; ok {
|
||||
parent.Children = append(parent.Children, node)
|
||||
} else {
|
||||
missing := &FlamegraphSpan{
|
||||
SpanID: node.ParentSpanID,
|
||||
Name: "Missing Span",
|
||||
Timestamp: node.Timestamp,
|
||||
DurationNano: node.DurationNano,
|
||||
Children: []*FlamegraphSpan{node},
|
||||
}
|
||||
t.nodeByID[missing.SpanID] = missing
|
||||
t.roots = append(t.roots, missing)
|
||||
}
|
||||
} else if flamegraphSpanIndex(t.roots, node.SpanID) == -1 {
|
||||
t.roots = append(t.roots, node)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(t.roots, func(i, j int) bool {
|
||||
if t.roots[i].Timestamp == t.roots[j].Timestamp {
|
||||
return t.roots[i].SpanID < t.roots[j].SpanID
|
||||
}
|
||||
return t.roots[i].Timestamp < t.roots[j].Timestamp
|
||||
})
|
||||
}
|
||||
|
||||
func flamegraphSpanIndex(spans []*FlamegraphSpan, spanID string) int {
|
||||
for i, s := range spans {
|
||||
if s != nil && s.SpanID == spanID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -164,6 +164,18 @@ func (item *MinimalSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
}
|
||||
}
|
||||
|
||||
func (item *MinimalSpan) ToFlamegraphSpan() *FlamegraphSpan {
|
||||
return &FlamegraphSpan{
|
||||
SpanID: item.SpanID,
|
||||
ParentSpanID: item.ParentSpanID,
|
||||
Timestamp: uint64(item.StartTime.UnixNano()),
|
||||
DurationNano: item.DurationNano,
|
||||
HasError: item.HasError,
|
||||
ServiceName: item.ServiceName,
|
||||
Children: make([]*FlamegraphSpan, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// NewMissingWaterfallSpan creates a synthetic placeholder span for a parent that has no recorded data.
|
||||
func NewMissingWaterfallSpan(spanID, traceID string, timeUnixNano, durationNano uint64) *WaterfallSpan {
|
||||
return &WaterfallSpan{
|
||||
|
||||
@@ -24,7 +24,7 @@ type SettingsConfig struct {
|
||||
Posthog PosthogConfig `mapstructure:"posthog"`
|
||||
Appcues AppcuesConfig `mapstructure:"appcues"`
|
||||
Sentry SentryConfig `mapstructure:"sentry"`
|
||||
Pylon PylonConfig `mapstructure:"pylon"`
|
||||
Pylon PylonConfig `mapstructure:"sentry"`
|
||||
}
|
||||
|
||||
type PosthogConfig struct {
|
||||
|
||||
@@ -43,42 +43,3 @@ func TestNewWithEnvProvider(t *testing.T) {
|
||||
|
||||
assert.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestSettingsConfigWithEnvProvider(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
env string
|
||||
expected SettingsConfig
|
||||
}{
|
||||
{name: "posthog", env: "SIGNOZ_WEB_SETTINGS_POSTHOG_ENABLED", expected: SettingsConfig{Posthog: PosthogConfig{Enabled: true}}},
|
||||
{name: "appcues", env: "SIGNOZ_WEB_SETTINGS_APPCUES_ENABLED", expected: SettingsConfig{Appcues: AppcuesConfig{Enabled: true}}},
|
||||
{name: "sentry", env: "SIGNOZ_WEB_SETTINGS_SENTRY_ENABLED", expected: SettingsConfig{Sentry: SentryConfig{Enabled: true}}},
|
||||
{name: "pylon", env: "SIGNOZ_WEB_SETTINGS_PYLON_ENABLED", expected: SettingsConfig{Pylon: PylonConfig{Enabled: true}}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Setenv(testCase.env, "true")
|
||||
|
||||
conf, err := config.New(
|
||||
context.Background(),
|
||||
config.ResolverConfig{
|
||||
Uris: []string{"env:"},
|
||||
ProviderFactories: []config.ProviderFactory{
|
||||
envprovider.NewFactory(),
|
||||
},
|
||||
},
|
||||
[]factory.ConfigFactory{
|
||||
NewConfigFactory(),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
actual := &Config{}
|
||||
err = conf.Unmarshal("web", actual)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, testCase.expected, actual.Settings)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
51
tests/fixtures/querier.py
vendored
51
tests/fixtures/querier.py
vendored
@@ -459,57 +459,6 @@ def find_named_result(
|
||||
)
|
||||
|
||||
|
||||
def assert_scalar_value(
|
||||
response: requests.Response,
|
||||
name: str,
|
||||
expected: Any,
|
||||
*,
|
||||
row: int = 0,
|
||||
col: int = 0,
|
||||
) -> None:
|
||||
"""Assert that the named scalar result has `expected` at data[row][col]."""
|
||||
result = find_named_result(response.json()["data"]["data"]["results"], name)
|
||||
assert result is not None, f"no result for query {name}"
|
||||
assert result["data"][row][col] == expected, f"expected {expected} at [{row}][{col}], got {result['data'][row][col]}"
|
||||
|
||||
|
||||
def assert_grouped_scalar(
|
||||
response: requests.Response,
|
||||
name: str,
|
||||
*,
|
||||
expected_groups: int,
|
||||
expected_columns: int,
|
||||
last_col_value: Any | None = None,
|
||||
) -> None:
|
||||
"""Assert grouped scalar result has the expected column count and group count.
|
||||
If `last_col_value` is set and there is exactly one group, also assert the
|
||||
last column of that single row equals it (a common aggregation-value check)."""
|
||||
result = find_named_result(response.json()["data"]["data"]["results"], name)
|
||||
assert result is not None, f"no result for query {name}"
|
||||
columns = result["columns"]
|
||||
rows = result["data"]
|
||||
assert len(columns) == expected_columns, f"expected {expected_columns} columns, got {len(columns)}: {columns}"
|
||||
assert len(rows) == expected_groups, f"expected {expected_groups} groups, got {len(rows)}: {rows}"
|
||||
if last_col_value is not None and expected_groups == 1:
|
||||
assert rows[0][-1] == last_col_value, f"expected last col {last_col_value}, got row {rows[0]}"
|
||||
|
||||
|
||||
def assert_raw_row_subset(
|
||||
response: requests.Response,
|
||||
name: str,
|
||||
expected: dict[str, Any],
|
||||
*,
|
||||
row: int = 0,
|
||||
) -> None:
|
||||
"""Assert that the named raw result's rows[row]['data'] is a superset of `expected`."""
|
||||
result = find_named_result(response.json()["data"]["data"]["results"], name)
|
||||
assert result is not None, f"no result for query {name}"
|
||||
rows = result["rows"]
|
||||
assert rows is not None, f"no rows for query {name}"
|
||||
data = rows[row]["data"]
|
||||
assert expected.items() <= data.items(), f"expected subset {expected}, got data {data}"
|
||||
|
||||
|
||||
def build_scalar_query(
|
||||
name: str,
|
||||
signal: str,
|
||||
|
||||
@@ -640,32 +640,6 @@ def test_non_existent_metrics_returns_404(
|
||||
assert get_error_message(response.json()) == "could not find the metric whatevergoennnsgoeshere"
|
||||
|
||||
|
||||
def test_non_existent_internal_metrics_returns_no_warning(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
metric_name = "signoz_calls_total"
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_builder_query(
|
||||
"A",
|
||||
metric_name,
|
||||
"doesnotreallymatter",
|
||||
"sum",
|
||||
)
|
||||
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
|
||||
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
|
||||
response = make_query_request(signoz, token, start_2h, end_ms, [query])
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()
|
||||
assert get_all_warnings(data) == []
|
||||
|
||||
|
||||
# Verify /api/v1/fields/values filters label values by metricNamespace prefix.
|
||||
# Inserts metrics under ns.a and ns.b, then asserts a specific prefix returns
|
||||
# only matching values while a common prefix returns both.
|
||||
|
||||
@@ -25,22 +25,13 @@ returnSpansFrom="A"
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
assert_grouped_scalar,
|
||||
assert_raw_row_subset,
|
||||
assert_scalar_value,
|
||||
format_timestamp,
|
||||
generate_traces_with_corrupt_metadata,
|
||||
get_rows,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.querier import get_rows
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
@@ -443,173 +434,3 @@ def test_trace_operator(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"HTTP {response.status_code}: {response.text}"
|
||||
assert case["validate"](response), f"validation failed: {response.json()}"
|
||||
|
||||
|
||||
def _expected_trace_subset(trace: Traces) -> dict[str, Any]:
|
||||
return {
|
||||
"duration_nano": trace.duration_nano,
|
||||
"name": trace.name,
|
||||
"parent_span_id": trace.parent_span_id,
|
||||
"span_id": trace.span_id,
|
||||
"timestamp": format_timestamp(trace.timestamp),
|
||||
"trace_id": trace.trace_id,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload_factory,request_type,assert_result",
|
||||
[
|
||||
# Case 1: CTE filter uses the deprecated intrinsic field `durationNano`.
|
||||
pytest.param(
|
||||
lambda traces: [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": 'durationNano = "3s"'},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": 'durationNano = "5s"'},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A => B",
|
||||
"limit": 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
"raw",
|
||||
lambda response, traces: assert_raw_row_subset(response, "C", _expected_trace_subset(traces[0])),
|
||||
id="deprecated-intrinsic-filter",
|
||||
),
|
||||
# Case 2: CTE filter uses the deprecated calculated field `responseStatusCode`.
|
||||
pytest.param(
|
||||
lambda traces: [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": 'responseStatusCode = "200"'},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": 'durationNano = "5s"'},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A => B",
|
||||
"limit": 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
"raw",
|
||||
lambda response, traces: assert_raw_row_subset(response, "C", _expected_trace_subset(traces[0])),
|
||||
id="deprecated-calculated-filter",
|
||||
),
|
||||
# Case 3: order by uses `count_` with fieldContext `span`, which has
|
||||
# to be rewritten to the aggregation alias `span.count_`.
|
||||
pytest.param(
|
||||
lambda traces: [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A",
|
||||
"aggregations": [{"expression": "count()", "alias": "span.count_"}],
|
||||
"order": [{"key": {"name": "count_", "fieldContext": "span"}, "direction": "desc"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
"scalar",
|
||||
lambda response, traces: assert_scalar_value(response, "C", len(traces)),
|
||||
id="context-prefixed-aggregation-alias-order",
|
||||
),
|
||||
# Case 4: group by lists `cloud.provider` twice (once with a resource
|
||||
# context, once without).
|
||||
pytest.param(
|
||||
lambda traces: [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"disabled": True,
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A",
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
"groupBy": [
|
||||
{"name": "cloud.provider", "fieldContext": "resource"},
|
||||
{"name": "cloud.provider"},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"scalar",
|
||||
lambda response, traces: assert_grouped_scalar(response, "C", expected_groups=1, expected_columns=2, last_col_value=len(traces)),
|
||||
id="duplicate-group-by-deduplicated",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_trace_operator_with_adjusted_keys(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
payload_factory: Callable[[list[Traces]], list[dict[str, Any]]],
|
||||
request_type: str,
|
||||
assert_result: Callable[[requests.Response, list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Trace operators build a CTE per referenced builder query and an outer
|
||||
query on top. Both layers need the same key adjustment as regular trace
|
||||
queries, otherwise deprecated keys and context-prefixed aliases don't
|
||||
resolve.
|
||||
"""
|
||||
traces = generate_traces_with_corrupt_metadata()
|
||||
insert_traces(traces)
|
||||
payload = payload_factory(traces)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type=request_type,
|
||||
queries=payload,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert_result(response, traces)
|
||||
|
||||
Reference in New Issue
Block a user