mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-27 04:10:28 +01:00
Compare commits
310 Commits
fix/tansta
...
feat/color
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6401283a5d | ||
|
|
ec30f3e206 | ||
|
|
78fe454860 | ||
|
|
8243109934 | ||
|
|
fb6d24be88 | ||
|
|
37987ac63b | ||
|
|
e071f5ef28 | ||
|
|
879f8a2e16 | ||
|
|
1802480f1f | ||
|
|
80a78a5426 | ||
|
|
e7911999d7 | ||
|
|
9efe2aacab | ||
|
|
f85da6d8d4 | ||
|
|
0e9d7bf537 | ||
|
|
9c50930f00 | ||
|
|
3eab3e1556 | ||
|
|
b1a81c09ce | ||
|
|
227b098067 | ||
|
|
3882c06054 | ||
|
|
c8548ea27d | ||
|
|
ede67c877a | ||
|
|
074d3b8c85 | ||
|
|
07b0f8e6cb | ||
|
|
24660642cb | ||
|
|
f379a01095 | ||
|
|
d5a07d10bb | ||
|
|
537d183d34 | ||
|
|
10ad886981 | ||
|
|
6cf8ccbfb8 | ||
|
|
0162d3c7e2 | ||
|
|
7124896d37 | ||
|
|
9edd1e88bd | ||
|
|
c2ba08fd31 | ||
|
|
2d26411705 | ||
|
|
181c81ec71 | ||
|
|
192cbb15f8 | ||
|
|
c0b6ef28cd | ||
|
|
ad6813fbe7 | ||
|
|
8be39cb4c5 | ||
|
|
c1905361d2 | ||
|
|
d27d8443fc | ||
|
|
90d09a7a37 | ||
|
|
6a784088f2 | ||
|
|
08b08b85ca | ||
|
|
d1298b7b91 | ||
|
|
2ae7ff394c | ||
|
|
9b79d86436 | ||
|
|
8d4df49bb4 | ||
|
|
4ba3c32ca4 | ||
|
|
593997caa2 | ||
|
|
0ce20e963c | ||
|
|
5c58e8c2a4 | ||
|
|
7336557e79 | ||
|
|
ec392e6e4a | ||
|
|
b5e3ac7179 | ||
|
|
77a4eaeebb | ||
|
|
8f3ed3b725 | ||
|
|
43ccc88440 | ||
|
|
f9b23dbe29 | ||
|
|
1e07156cd0 | ||
|
|
74e5e4d2ac | ||
|
|
0a35a09f1e | ||
|
|
b88e9e52be | ||
|
|
cf58d49de4 | ||
|
|
8e95128414 | ||
|
|
aaff6d8bdd | ||
|
|
4b22ac05b2 | ||
|
|
8e0ecb2666 | ||
|
|
83ed560236 | ||
|
|
e46510fa02 | ||
|
|
07f0bf3e8b | ||
|
|
3b6fd6a5e8 | ||
|
|
c312a54e63 | ||
|
|
35ecfc5e37 | ||
|
|
7b9caf14b8 | ||
|
|
e3db42b7ce | ||
|
|
401f253090 | ||
|
|
2d930c0e4b | ||
|
|
ce8d1837ef | ||
|
|
c38bfa1027 | ||
|
|
473b91f41f | ||
|
|
4f32c23f63 | ||
|
|
8708fa0627 | ||
|
|
cabd7b6641 | ||
|
|
bc91476bce | ||
|
|
b5789e1e36 | ||
|
|
52f2b40e18 | ||
|
|
9fe4ca02da | ||
|
|
41fd155fd3 | ||
|
|
88575f3ea1 | ||
|
|
48f1a4cbf3 | ||
|
|
bb499973bf | ||
|
|
13c087d34d | ||
|
|
f5e772f8a0 | ||
|
|
feb9031bcd | ||
|
|
bc4a6b7ded | ||
|
|
9d83c9b43d | ||
|
|
0144bb78df | ||
|
|
9216bb5f34 | ||
|
|
18d2806f95 | ||
|
|
8d666471e1 | ||
|
|
9d022772b7 | ||
|
|
648a48cbaa | ||
|
|
6e35cee1e7 | ||
|
|
1298074cb2 | ||
|
|
16c8db5fd9 | ||
|
|
6855be7859 | ||
|
|
2c398396dd | ||
|
|
a58539e25c | ||
|
|
eeb7fa3aa5 | ||
|
|
d11234531d | ||
|
|
eb22c57a67 | ||
|
|
896379b680 | ||
|
|
f041b16e4b | ||
|
|
0bd591458d | ||
|
|
9ca3a7fd3e | ||
|
|
43e122367c | ||
|
|
33520c41c8 | ||
|
|
b994d6dd8e | ||
|
|
5e231e799e | ||
|
|
5f4a79c201 | ||
|
|
8edf375019 | ||
|
|
0d1fd6d0bd | ||
|
|
fefd0effef | ||
|
|
36a137be4d | ||
|
|
68dc7e426a | ||
|
|
603077c575 | ||
|
|
7e5c4476f7 | ||
|
|
da648ed3f3 | ||
|
|
9fa56aacd1 | ||
|
|
5acd79419c | ||
|
|
9b7b0f8862 | ||
|
|
c29e8a0136 | ||
|
|
ebac945ac2 | ||
|
|
e787497695 | ||
|
|
eba6bd5f5b | ||
|
|
1aeab2718d | ||
|
|
d879af4fb3 | ||
|
|
ac10be2eb2 | ||
|
|
113d1544ba | ||
|
|
df02da664c | ||
|
|
d0a491ed8e | ||
|
|
77c39a9f05 | ||
|
|
309a76e5fd | ||
|
|
43e80caf09 | ||
|
|
a2d853daf5 | ||
|
|
3970619afa | ||
|
|
9dc87761c1 | ||
|
|
86a44fad42 | ||
|
|
91f74144cb | ||
|
|
0863c5170b | ||
|
|
837cd2a463 | ||
|
|
c88a2d5d90 | ||
|
|
c9abc2cb30 | ||
|
|
01824b0b62 | ||
|
|
d1b378992d | ||
|
|
52ca921d2a | ||
|
|
42f12dfef3 | ||
|
|
f2a694447e | ||
|
|
2e7dfa739f | ||
|
|
0aa73580a3 | ||
|
|
2ff1a43bf8 | ||
|
|
c1477c78be | ||
|
|
9807dd5295 | ||
|
|
2c59eeff26 | ||
|
|
8ccfb4efef | ||
|
|
87d18160e8 | ||
|
|
bfa7ee96da | ||
|
|
5e3eb66d3a | ||
|
|
3d8cdf18bd | ||
|
|
cb4e501047 | ||
|
|
cb8b2137ba | ||
|
|
998315a255 | ||
|
|
250657e46b | ||
|
|
795ae9ab18 | ||
|
|
6a9ea8d9f8 | ||
|
|
2723e18023 | ||
|
|
6e89d5f6eb | ||
|
|
4c2a815236 | ||
|
|
b1d66b2e5f | ||
|
|
ae88edbb5e | ||
|
|
7c9484d47b | ||
|
|
24128bd394 | ||
|
|
2118916a23 | ||
|
|
52220412a1 | ||
|
|
85abee8476 | ||
|
|
650a29d184 | ||
|
|
d9c7101d22 | ||
|
|
b1e7c25189 | ||
|
|
e9904a0558 | ||
|
|
5cd199f535 | ||
|
|
f6f48ca0bc | ||
|
|
847f91e22e | ||
|
|
29d0abe5a8 | ||
|
|
c08840a827 | ||
|
|
a3e7bb90b0 | ||
|
|
8515d2f37c | ||
|
|
07c05ac3a6 | ||
|
|
6289f59ba3 | ||
|
|
76371c9fa2 | ||
|
|
f082e396eb | ||
|
|
840eb8f228 | ||
|
|
2911baf6bb | ||
|
|
fc5be4eeb5 | ||
|
|
a1b92c79a4 | ||
|
|
7a0acd5c8b | ||
|
|
069cbe2c6f | ||
|
|
4c821f9721 | ||
|
|
4eccea92db | ||
|
|
c8d8966a5d | ||
|
|
1e52a5603e | ||
|
|
780ba1a359 | ||
|
|
3b71abe820 | ||
|
|
70b9d0ff02 | ||
|
|
f4657861e1 | ||
|
|
66fe5b5240 | ||
|
|
c333cecf43 | ||
|
|
276e09853e | ||
|
|
4defd41504 | ||
|
|
ab53b29a14 | ||
|
|
b58e82efbf | ||
|
|
0a1a676877 | ||
|
|
bb2aa9f77c | ||
|
|
04bef4ac06 | ||
|
|
3bcb2c2c41 | ||
|
|
9e77b76122 | ||
|
|
ff4a41d842 | ||
|
|
387deb779d | ||
|
|
1ec2663d51 | ||
|
|
1b17370da0 | ||
|
|
c6484a79e2 | ||
|
|
16a2c7a1af | ||
|
|
3c4ac0e85e | ||
|
|
87ba729a00 | ||
|
|
f1ed7145e4 | ||
|
|
bc15495e17 | ||
|
|
f7d3012daf | ||
|
|
6ec9a2ec41 | ||
|
|
9c056f809a | ||
|
|
c1d4273416 | ||
|
|
618fe891d5 | ||
|
|
549c7e7034 | ||
|
|
dd65f83c3d | ||
|
|
8463a131fc | ||
|
|
2d42518440 | ||
|
|
43d75a3853 | ||
|
|
c5bb34e385 | ||
|
|
6fd129991d | ||
|
|
9c5cca426a | ||
|
|
a467efb97d | ||
|
|
58e2718090 | ||
|
|
65fee725c9 | ||
|
|
ea87174088 | ||
|
|
627c483d86 | ||
|
|
2533137db4 | ||
|
|
a774f8a4fe | ||
|
|
8487f6cf66 | ||
|
|
6ebe51126e | ||
|
|
ed64d5cd9f | ||
|
|
c04076e664 | ||
|
|
3c129e2c7d | ||
|
|
0ba51e2058 | ||
|
|
cdc2ab134c | ||
|
|
fb0c05b553 | ||
|
|
68e9707e3b | ||
|
|
17ffaf9ccf | ||
|
|
efec669b76 | ||
|
|
17b9e14d34 | ||
|
|
2db9f969c3 | ||
|
|
9fa466b124 | ||
|
|
0c7768ebff | ||
|
|
58dd51e92f | ||
|
|
870c9bf6dc | ||
|
|
7604956bf0 | ||
|
|
66510e4919 | ||
|
|
a1bf0e67db | ||
|
|
a06046612a | ||
|
|
31c9d4309b | ||
|
|
7bef8b86c4 | ||
|
|
d26acd36a3 | ||
|
|
1cee595135 | ||
|
|
dd1868fcbc | ||
|
|
a20beb8ba2 | ||
|
|
998d652feb | ||
|
|
3695d3c180 | ||
|
|
da175bafbc | ||
|
|
021b187cbc | ||
|
|
f42b468597 | ||
|
|
7e2cf57819 | ||
|
|
dc9ebc5b26 | ||
|
|
398ab6e9d9 | ||
|
|
fec60671d8 | ||
|
|
99259cc4e8 | ||
|
|
ca311717c2 | ||
|
|
a614da2c65 | ||
|
|
ce18709002 | ||
|
|
2b6977e891 | ||
|
|
3e6eedbcab | ||
|
|
fd9e3f0411 | ||
|
|
e99465e030 | ||
|
|
9ad2db4b99 | ||
|
|
07fd5f70ef | ||
|
|
ba79121795 | ||
|
|
6e4e419b5e | ||
|
|
2f06afaf27 | ||
|
|
f77c3cb23c | ||
|
|
9e3a8efcfc | ||
|
|
8e325ba8b3 | ||
|
|
884f516766 | ||
|
|
4bcbb4ffc3 |
@@ -18948,77 +18948,6 @@ paths:
|
||||
summary: Get waterfall view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v4/traces/{traceID}/waterfall:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns the waterfall view of spans including all spans if total
|
||||
spans are under a limit, a max count otherwise. Aggregations are dropped compared
|
||||
to v3
|
||||
operationId: GetWaterfallV4
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableWaterfall'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableWaterfallTrace'
|
||||
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 waterfall view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v5/query_range:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -9232,17 +9232,6 @@ export type GetWaterfall200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetWaterfallV4PathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetWaterfallV4200 = {
|
||||
data: SpantypesGettableWaterfallTraceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type QueryRangeV5200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -14,8 +14,6 @@ import type {
|
||||
import type {
|
||||
GetWaterfall200,
|
||||
GetWaterfallPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -122,102 +120,3 @@ export const useGetWaterfall = <
|
||||
> => {
|
||||
return useMutation(getGetWaterfallMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3
|
||||
* @summary Get waterfall view for a trace
|
||||
*/
|
||||
export const getWaterfallV4 = (
|
||||
{ traceID }: GetWaterfallV4PathParameters,
|
||||
spantypesPostableWaterfallDTO?: BodyType<SpantypesPostableWaterfallDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetWaterfallV4200>({
|
||||
url: `/api/v4/traces/${traceID}/waterfall`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableWaterfallDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetWaterfallV4MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['getWaterfallV4'];
|
||||
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 getWaterfallV4>>,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return getWaterfallV4(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type GetWaterfallV4MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>
|
||||
>;
|
||||
export type GetWaterfallV4MutationBody =
|
||||
| BodyType<SpantypesPostableWaterfallDTO>
|
||||
| undefined;
|
||||
export type GetWaterfallV4MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get waterfall view for a trace
|
||||
*/
|
||||
export const useGetWaterfallV4 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getGetWaterfallV4MutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
@@ -22,19 +22,21 @@ type WithDangerousHtml = BaseProps & {
|
||||
|
||||
export type TanStackTableTextProps = WithChildren | WithDangerousHtml;
|
||||
|
||||
const TanStackTableText = forwardRef<HTMLSpanElement, TanStackTableTextProps>(
|
||||
({ children, className, dangerouslySetInnerHTML, ...rest }, ref) => (
|
||||
function TanStackTableText({
|
||||
children,
|
||||
className,
|
||||
dangerouslySetInnerHTML,
|
||||
...rest
|
||||
}: TanStackTableTextProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cx(tableStyles.tableCellText, className)}
|
||||
dangerouslySetInnerHTML={dangerouslySetInnerHTML}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
);
|
||||
|
||||
TanStackTableText.displayName = 'TanStackTableText';
|
||||
);
|
||||
}
|
||||
|
||||
export default TanStackTableText;
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
} from '@signozhq/ui/tabs';
|
||||
import cx from 'classnames';
|
||||
import { DetailsHeader } from 'components/DetailsPanel';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { generateColorPair } from 'pages/TraceDetailsV3/utils/generateColorPair';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
|
||||
import { useTraceStore } from '../../stores/traceStore';
|
||||
@@ -35,6 +35,7 @@ function AnalyticsPanel({
|
||||
}: AnalyticsPanelProps): JSX.Element | null {
|
||||
const aggregations = useTraceStore((s) => s.aggregations);
|
||||
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const execTimePct = useMemo(
|
||||
() =>
|
||||
@@ -57,13 +58,16 @@ function AnalyticsPanel({
|
||||
return [];
|
||||
}
|
||||
return Object.entries(execTimePct)
|
||||
.map(([group, percentage]) => ({
|
||||
group,
|
||||
percentage,
|
||||
color: generateColor(group, themeColors.traceDetailColorsV3),
|
||||
}))
|
||||
.map(([group, percentage]) => {
|
||||
const pair = generateColorPair(group);
|
||||
return {
|
||||
group,
|
||||
percentage,
|
||||
color: isDarkMode ? pair.color : pair.colorDark,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.percentage - a.percentage);
|
||||
}, [execTimePct]);
|
||||
}, [execTimePct, isDarkMode]);
|
||||
|
||||
const spanCountRows = useMemo(() => {
|
||||
if (!spanCounts) {
|
||||
@@ -71,14 +75,17 @@ function AnalyticsPanel({
|
||||
}
|
||||
const max = Math.max(...Object.values(spanCounts), 1);
|
||||
return Object.entries(spanCounts)
|
||||
.map(([group, count]) => ({
|
||||
group,
|
||||
count,
|
||||
max,
|
||||
color: generateColor(group, themeColors.traceDetailColorsV3),
|
||||
}))
|
||||
.map(([group, count]) => {
|
||||
const pair = generateColorPair(group);
|
||||
return {
|
||||
group,
|
||||
count,
|
||||
max,
|
||||
color: isDarkMode ? pair.color : pair.colorDark,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [spanCounts]);
|
||||
}, [spanCounts, isDarkMode]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
import { getSpanAttribute, resolveSpanColor } from 'pages/TraceDetailsV3/utils';
|
||||
import { useMemo } from 'react';
|
||||
@@ -101,6 +102,7 @@ export function SpanHoverCard({
|
||||
}: SpanHoverCardProps): JSX.Element {
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const hoverCardData = useMemo(() => {
|
||||
if (!hoveredSpanId) {
|
||||
@@ -121,11 +123,12 @@ export function SpanHoverCard({
|
||||
})
|
||||
.filter((r): r is SpanPreviewRow => r !== null);
|
||||
|
||||
const pair = resolveSpanColor(span, colorByFieldName);
|
||||
return {
|
||||
anchorTop: idx * rowHeight,
|
||||
tooltip: {
|
||||
spanName: span.name,
|
||||
color: resolveSpanColor(span, colorByFieldName),
|
||||
color: isDarkMode ? pair.color : pair.colorDark,
|
||||
hasError: span.has_error,
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
@@ -139,6 +142,7 @@ export function SpanHoverCard({
|
||||
colorByFieldName,
|
||||
rowHeight,
|
||||
traceStartTime,
|
||||
isDarkMode,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
gap: 8px;
|
||||
min-height: 52px;
|
||||
|
||||
// KeyValueLabel renders with a global `.key-value-label` root; keep it from
|
||||
// shrinking on the trace details header.
|
||||
@@ -20,6 +21,28 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.traceIdSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -29,15 +52,6 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.oldViewBtn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.analyticsBtn {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.subHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
ChartPie,
|
||||
CornerUpLeft,
|
||||
Server,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
@@ -117,7 +118,7 @@ function TraceDetailsHeader({
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
{!isFilterExpanded && (
|
||||
<>
|
||||
<div className={styles.traceIdSection}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -133,20 +134,39 @@ function TraceDetailsHeader({
|
||||
badgeValue={traceID || ''}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{isDataLoaded && (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
styles.filterSection,
|
||||
isFilterExpanded && styles.isExpanded,
|
||||
)}
|
||||
>
|
||||
{!isFilterExpanded && (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<div className={styles.headerActions}>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className={styles.analyticsBtn}
|
||||
aria-label="Switch to legacy trace view"
|
||||
onClick={handleSwitchToOldView}
|
||||
>
|
||||
<CornerUpLeft size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Switch to legacy trace view</TooltipContent>
|
||||
</TooltipRoot>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
aria-label="Analytics"
|
||||
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
|
||||
>
|
||||
<ChartPie size={14} />
|
||||
@@ -154,15 +174,18 @@ function TraceDetailsHeader({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Analytics</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
<TraceOptionsMenu
|
||||
showTraceDetails={showTraceDetails}
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
/>
|
||||
</>
|
||||
<TraceOptionsMenu
|
||||
showTraceDetails={showTraceDetails}
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<div className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}>
|
||||
<div
|
||||
key="filter"
|
||||
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
|
||||
>
|
||||
<Filters
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
@@ -173,18 +196,7 @@ function TraceDetailsHeader({
|
||||
onCollapse={(): void => setIsFilterExpanded(false)}
|
||||
/>
|
||||
</div>
|
||||
{!isFilterExpanded && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={styles.oldViewBtn}
|
||||
onClick={handleSwitchToOldView}
|
||||
>
|
||||
Legacy View
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Settings2 } from '@signozhq/icons';
|
||||
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
|
||||
@@ -93,7 +93,8 @@ function TraceOptionsMenu({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
prefix={<Ellipsis size={14} />}
|
||||
aria-label="Trace options"
|
||||
prefix={<Settings2 size={14} />}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import TraceDetailsHeader from '../TraceDetailsHeader';
|
||||
|
||||
const mockGoBack = jest.fn();
|
||||
const mockPush = jest.fn();
|
||||
const mockReplace = jest.fn();
|
||||
const mockHasInAppHistory = jest.fn();
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
@@ -13,13 +14,47 @@ jest.mock('lib/history', () => ({
|
||||
default: {
|
||||
goBack: (): void => mockGoBack(),
|
||||
push: (path: string): void => mockPush(path),
|
||||
replace: jest.fn(),
|
||||
replace: (path: string): void => mockReplace(path),
|
||||
location: { pathname: '/', search: '' },
|
||||
listen: (): (() => void) => (): void => undefined,
|
||||
},
|
||||
hasInAppHistory: (): boolean => mockHasInAppHistory(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: (): { id: string } => ({ id: 'trace-123' }),
|
||||
}));
|
||||
|
||||
const mockSetLocalStorageKey = jest.fn();
|
||||
jest.mock('api/browser/localstorage/set', () => ({
|
||||
__esModule: true,
|
||||
default: (key: string, value: string): void =>
|
||||
mockSetLocalStorageKey(key, value),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'../../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="filters-stub" />,
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('../../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel', () => ({
|
||||
__esModule: true,
|
||||
default: ({ isOpen }: { isOpen: boolean }): JSX.Element => (
|
||||
<div data-testid="analytics-panel" data-open={isOpen ? 'true' : 'false'} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('components/FieldsSelector', () => ({
|
||||
__esModule: true,
|
||||
default: ({ isOpen }: { isOpen: boolean }): JSX.Element => (
|
||||
<div data-testid="fields-selector" data-open={isOpen ? 'true' : 'false'} />
|
||||
),
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
filterMetadata: {
|
||||
startTime: 0,
|
||||
@@ -58,3 +93,70 @@ describe('TraceDetailsHeader – back button', () => {
|
||||
expect(mockGoBack).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TraceDetailsHeader – action cluster', () => {
|
||||
beforeEach(() => {
|
||||
mockReplace.mockClear();
|
||||
mockSetLocalStorageKey.mockClear();
|
||||
});
|
||||
|
||||
it('does not render the action buttons while data is still loading', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /switch to legacy trace view/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /trace options/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Legacy View, Analytics, and Settings action buttons once data is loaded', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /switch to legacy trace view/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^analytics$/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /trace options/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('routes to the legacy trace view and persists the preference on click', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /switch to legacy trace view/i }),
|
||||
);
|
||||
|
||||
expect(mockSetLocalStorageKey).toHaveBeenCalledWith(
|
||||
'TRACE_DETAILS_PREFER_OLD_VIEW',
|
||||
'true',
|
||||
);
|
||||
expect(mockReplace).toHaveBeenCalledTimes(1);
|
||||
expect(mockReplace).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/trace-old/trace-123'),
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
|
||||
const panel = screen.getByTestId('analytics-panel');
|
||||
expect(panel).toHaveAttribute('data-open', 'false');
|
||||
|
||||
const analyticsBtn = screen.getByRole('button', { name: /^analytics$/i });
|
||||
|
||||
fireEvent.click(analyticsBtn);
|
||||
expect(panel).toHaveAttribute('data-open', 'true');
|
||||
|
||||
fireEvent.click(analyticsBtn);
|
||||
expect(panel).toHaveAttribute('data-open', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@ describe('Canvas Draw Utils', () => {
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#1890ff',
|
||||
colorDark: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
@@ -94,7 +95,9 @@ describe('Canvas Draw Utils', () => {
|
||||
expect(ctx.beginPath).toHaveBeenCalled();
|
||||
expect(ctx.roundRect).toHaveBeenCalledWith(10, 1, 100, 22, 2);
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.stroke).not.toHaveBeenCalled();
|
||||
// Rest state draws a subtle 1px rgba(0,0,0,0.3) outline to match spec
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
expect(ctx.strokeStyle).toBe('rgba(0, 0, 0, 0.3)');
|
||||
expect(spanRectsArray).toHaveLength(1);
|
||||
expect(spanRectsArray[0]).toMatchObject({
|
||||
x: 10,
|
||||
@@ -126,15 +129,17 @@ describe('Canvas Draw Utils', () => {
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#2F80ED',
|
||||
colorDark: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
selectedSpanId: 'sel',
|
||||
});
|
||||
|
||||
// Selected spans get solid l2-background fill + dashed border
|
||||
// Selected spans get solid l2-background fill + dashed border.
|
||||
// Light mode uses colorDark for the stroke for contrast against l2-background.
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).toHaveBeenCalledWith(DASHED_BORDER_LINE_DASH);
|
||||
expect(ctx.strokeStyle).toBe('#2F80ED');
|
||||
expect(ctx.strokeStyle).toBe('#000');
|
||||
expect(ctx.lineWidth).toBe(2);
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).toHaveBeenLastCalledWith([]);
|
||||
@@ -161,6 +166,7 @@ describe('Canvas Draw Utils', () => {
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#2F80ED',
|
||||
colorDark: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
hoveredSpanId: 'hov',
|
||||
@@ -193,6 +199,7 @@ describe('Canvas Draw Utils', () => {
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
colorDark: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
@@ -230,6 +237,7 @@ describe('Canvas Draw Utils', () => {
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
colorDark: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
@@ -254,6 +262,7 @@ describe('Canvas Draw Utils', () => {
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
colorDark: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
@@ -279,6 +288,7 @@ describe('Canvas Draw Utils', () => {
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
colorDark: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
@@ -314,6 +324,7 @@ describe('Canvas Draw Utils', () => {
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
colorDark: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
@@ -344,6 +355,7 @@ describe('Canvas Draw Utils', () => {
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
colorDark: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
@@ -371,8 +383,8 @@ describe('Canvas Draw Utils', () => {
|
||||
expect(ctx.save).toHaveBeenCalled();
|
||||
expect(ctx.translate).toHaveBeenCalledWith(50, 11);
|
||||
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
|
||||
expect(ctx.fillStyle).toBe('rgb(220, 38, 38)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(153, 27, 27)');
|
||||
expect(ctx.fillStyle).toBe('#FC4E4E');
|
||||
expect(ctx.strokeStyle).toBe('#fb0707');
|
||||
expect(ctx.fillRect).toHaveBeenCalledWith(-3, -3, 6, 6);
|
||||
expect(ctx.strokeRect).toHaveBeenCalledWith(-3, -3, 6, 6);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
@@ -408,8 +420,8 @@ describe('Canvas Draw Utils', () => {
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(239, 68, 68)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(185, 28, 28)');
|
||||
expect(ctx.fillStyle).toBe('#FC4E4E');
|
||||
expect(ctx.strokeStyle).toBe('#fb0707');
|
||||
});
|
||||
|
||||
it('falls back to cyan/blue for unparseable span colors', () => {
|
||||
@@ -461,6 +473,7 @@ describe('Canvas Draw Utils', () => {
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
colorDark: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
hoveredSpanId: 'p',
|
||||
@@ -483,6 +496,7 @@ describe('Canvas Draw Utils', () => {
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
colorDark: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
selectedSpanId: 'p',
|
||||
@@ -524,6 +538,7 @@ describe('Canvas Draw Utils', () => {
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
colorDark: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
@@ -3,11 +3,13 @@ import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { getFlamegraphSpanGroupValue, getSpanColor } from '../utils';
|
||||
import { MOCK_SPAN } from './testUtils';
|
||||
|
||||
const mockGenerateColor = jest.fn();
|
||||
const mockGenerateColorPair = jest.fn();
|
||||
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: (key: string, colorMap: Record<string, string>): string =>
|
||||
mockGenerateColor(key, colorMap),
|
||||
jest.mock('pages/TraceDetailsV3/utils/generateColorPair', () => ({
|
||||
generateColorPair: (name: string): { color: string; colorDark: string } =>
|
||||
mockGenerateColorPair(name),
|
||||
RESERVED_ERROR: '#FC4E4E',
|
||||
darkenHex: (hex: string): string => hex,
|
||||
}));
|
||||
|
||||
const SERVICE_FIELD: TelemetryFieldKey = {
|
||||
@@ -24,48 +26,39 @@ const HOST_FIELD: TelemetryFieldKey = {
|
||||
describe('Presentation / Styling Utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGenerateColor.mockReturnValue('#2F80ED');
|
||||
mockGenerateColorPair.mockReturnValue({
|
||||
color: '#2F80ED',
|
||||
colorDark: '#1a4d99',
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSpanColor', () => {
|
||||
it('uses generated colour from groupValue for normal span', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
mockGenerateColorPair.mockReturnValue({
|
||||
color: '#1890ff',
|
||||
colorDark: '#0d5599',
|
||||
});
|
||||
|
||||
const color = getSpanColor({
|
||||
const result = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: false },
|
||||
isDarkMode: false,
|
||||
groupValue: 'my-bucket',
|
||||
});
|
||||
|
||||
expect(mockGenerateColor).toHaveBeenCalledWith(
|
||||
'my-bucket',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(color).toBe('#1890ff');
|
||||
expect(mockGenerateColorPair).toHaveBeenCalledWith('my-bucket');
|
||||
expect(result.color).toBe('#1890ff');
|
||||
expect(result.colorDark).toBe('#0d5599');
|
||||
});
|
||||
|
||||
it('overrides with error color in light mode when span has error', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
it('overrides with reserved error color when span has error', () => {
|
||||
const result = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: true },
|
||||
isDarkMode: false,
|
||||
groupValue: 'my-bucket',
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(220, 38, 38)');
|
||||
});
|
||||
|
||||
it('overrides with error color in dark mode when span has error', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: true },
|
||||
isDarkMode: true,
|
||||
groupValue: 'my-bucket',
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(239, 68, 68)');
|
||||
expect(result.color).toBe('#FC4E4E');
|
||||
expect(result.colorDark).toBe('#FC4E4E');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const EVENT_DOT_SIZE_RATIO = EVENT_DOT_SIZE / SPAN_BAR_HEIGHT;
|
||||
export const MIN_EVENT_DOT_SIZE = 4;
|
||||
export const MAX_EVENT_DOT_SIZE = EVENT_DOT_SIZE;
|
||||
|
||||
export const LABEL_FONT = '11px Inter, sans-serif';
|
||||
export const LABEL_FONT = '500 11px Inter, sans-serif';
|
||||
export const LABEL_PADDING_X = 8;
|
||||
export const MIN_WIDTH_FOR_NAME = 30;
|
||||
export const MIN_WIDTH_FOR_NAME_AND_DURATION = 80;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { RefObject, useCallback, useMemo, useRef } from 'react';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { generateColorPair } from 'pages/TraceDetailsV3/utils/generateColorPair';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
@@ -118,7 +117,11 @@ function drawLevel(args: DrawLevelArgs): void {
|
||||
width = clamp(width, 1, Infinity);
|
||||
|
||||
const groupValue = getFlamegraphSpanGroupValue(span, colorByField);
|
||||
const color = getSpanColor({ span, isDarkMode, groupValue });
|
||||
const { color, colorDark } = getSpanColor({
|
||||
span,
|
||||
isDarkMode,
|
||||
groupValue,
|
||||
});
|
||||
|
||||
const isDimmedByFilter =
|
||||
!!isFilterActiveInLevel &&
|
||||
@@ -135,6 +138,7 @@ function drawLevel(args: DrawLevelArgs): void {
|
||||
spanRectsArray,
|
||||
eventRectsArray,
|
||||
color,
|
||||
colorDark,
|
||||
isDarkMode,
|
||||
metrics,
|
||||
selectedSpanId,
|
||||
@@ -155,6 +159,7 @@ interface DrawConnectorLinesArgs {
|
||||
viewportHeight: number;
|
||||
metrics: FlamegraphRowMetrics;
|
||||
colorByField: TelemetryFieldKey;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
function drawConnectorLines(args: DrawConnectorLinesArgs): void {
|
||||
@@ -168,6 +173,7 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
|
||||
viewportHeight,
|
||||
metrics,
|
||||
colorByField,
|
||||
isDarkMode,
|
||||
} = args;
|
||||
|
||||
ctx.save();
|
||||
@@ -197,8 +203,8 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
|
||||
{ serviceName: conn.serviceName, resource: conn.resource },
|
||||
colorByField,
|
||||
);
|
||||
const color = generateColor(groupValue, themeColors.traceDetailColorsV3);
|
||||
ctx.strokeStyle = color;
|
||||
const pair = generateColorPair(groupValue);
|
||||
ctx.strokeStyle = isDarkMode ? pair.color : pair.colorDark;
|
||||
|
||||
const x = clamp(xFrac * cssWidth, 0, cssWidth);
|
||||
ctx.beginPath();
|
||||
@@ -294,6 +300,7 @@ export function useFlamegraphDraw(
|
||||
viewportHeight,
|
||||
metrics,
|
||||
colorByField,
|
||||
isDarkMode,
|
||||
});
|
||||
|
||||
const spanRectsArray: SpanRect[] = [];
|
||||
|
||||
@@ -211,11 +211,14 @@ export function useFlamegraphHover(
|
||||
durationMs: span.durationNano / 1e6,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
spanColor: getSpanColor({
|
||||
span,
|
||||
isDarkMode,
|
||||
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
|
||||
}),
|
||||
spanColor: ((): string => {
|
||||
const pair = getSpanColor({
|
||||
span,
|
||||
isDarkMode,
|
||||
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
|
||||
});
|
||||
return isDarkMode ? pair.color : pair.colorDark;
|
||||
})(),
|
||||
event: {
|
||||
name: event.name,
|
||||
timeOffsetMs: eventTimeMs - span.timestamp,
|
||||
@@ -244,11 +247,14 @@ export function useFlamegraphHover(
|
||||
durationMs: span.durationNano / 1e6,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
spanColor: getSpanColor({
|
||||
span,
|
||||
isDarkMode,
|
||||
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
|
||||
}),
|
||||
spanColor: ((): string => {
|
||||
const pair = getSpanColor({
|
||||
span,
|
||||
isDarkMode,
|
||||
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
|
||||
});
|
||||
return isDarkMode ? pair.color : pair.colorDark;
|
||||
})(),
|
||||
previewRows: buildPreviewRows(span),
|
||||
});
|
||||
updateCursor(canvas, span);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import {
|
||||
ColorPair,
|
||||
darkenHex,
|
||||
generateColorPair,
|
||||
RESERVED_ERROR,
|
||||
} from 'pages/TraceDetailsV3/utils/generateColorPair';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
@@ -106,15 +110,12 @@ interface GetSpanColorArgs {
|
||||
groupValue: string;
|
||||
}
|
||||
|
||||
export function getSpanColor(args: GetSpanColorArgs): string {
|
||||
const { span, isDarkMode, groupValue } = args;
|
||||
let color = generateColor(groupValue, themeColors.traceDetailColorsV3);
|
||||
|
||||
export function getSpanColor(args: GetSpanColorArgs): ColorPair {
|
||||
const { span, groupValue } = args;
|
||||
if (span.hasError) {
|
||||
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
return { color: RESERVED_ERROR, colorDark: RESERVED_ERROR };
|
||||
}
|
||||
|
||||
return color;
|
||||
return generateColorPair(groupValue);
|
||||
}
|
||||
|
||||
export interface EventDotColor {
|
||||
@@ -130,8 +131,8 @@ export function getEventDotColor(
|
||||
): EventDotColor {
|
||||
if (isError) {
|
||||
return {
|
||||
fill: isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)',
|
||||
stroke: isDarkMode ? 'rgb(185, 28, 28)' : 'rgb(153, 27, 27)',
|
||||
fill: RESERVED_ERROR,
|
||||
stroke: darkenHex(RESERVED_ERROR, 0.22),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,6 +210,9 @@ interface DrawSpanBarArgs {
|
||||
spanRectsArray: SpanRect[];
|
||||
eventRectsArray: EventRect[];
|
||||
color: string;
|
||||
// Darkened variant used as foreground (stroke + label) on light mode
|
||||
// hover/selected, where the base color sits against a near-white panel.
|
||||
colorDark: string;
|
||||
isDarkMode: boolean;
|
||||
metrics: FlamegraphRowMetrics;
|
||||
selectedSpanId?: string | null;
|
||||
@@ -228,6 +232,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
spanRectsArray,
|
||||
eventRectsArray,
|
||||
color,
|
||||
colorDark,
|
||||
isDarkMode,
|
||||
metrics,
|
||||
selectedSpanId,
|
||||
@@ -259,15 +264,21 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
if (isSelected) {
|
||||
ctx.setLineDash(DASHED_BORDER_LINE_DASH);
|
||||
}
|
||||
ctx.strokeStyle = color;
|
||||
ctx.strokeStyle = isDarkMode ? color : colorDark;
|
||||
ctx.lineWidth = isSelected ? 2 : 1;
|
||||
ctx.stroke();
|
||||
if (isSelected) {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = color;
|
||||
// Light mode uses the darkened variant as fill so bars contrast against
|
||||
// the white panel background; dark mode keeps the bright base.
|
||||
ctx.fillStyle = isDarkMode ? color : colorDark;
|
||||
ctx.fill();
|
||||
// Subtle outline to match spec: 1px semi-transparent black border at rest
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
spanRectsArray.push({
|
||||
@@ -292,7 +303,10 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
const eventX = x + (clampedOffset / 100) * width;
|
||||
const eventY = spanY + metrics.SPAN_BAR_HEIGHT / 2;
|
||||
|
||||
const dotColor = getEventDotColor(color, event.isError, isDarkMode);
|
||||
// Event dots derive from the effective bar color so they track the
|
||||
// light/dark variant the bar is rendered with.
|
||||
const parentBarColor = isDarkMode ? color : colorDark;
|
||||
const dotColor = getEventDotColor(parentBarColor, event.isError, isDarkMode);
|
||||
const eventKey = `${span.spanId}-${event.name}-${event.timeUnixNano}`;
|
||||
const isEventHovered = hoveredEventKey === eventKey;
|
||||
const dotSize = isEventHovered
|
||||
@@ -328,6 +342,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
y: spanY,
|
||||
width,
|
||||
color,
|
||||
colorDark,
|
||||
isSelectedOrHovered,
|
||||
isDarkMode,
|
||||
spanBarHeight: metrics.SPAN_BAR_HEIGHT,
|
||||
@@ -347,6 +362,7 @@ interface DrawSpanLabelArgs {
|
||||
y: number;
|
||||
width: number;
|
||||
color: string;
|
||||
colorDark: string;
|
||||
isSelectedOrHovered: boolean;
|
||||
isDarkMode: boolean;
|
||||
spanBarHeight: number;
|
||||
@@ -360,6 +376,7 @@ function drawSpanLabel(args: DrawSpanLabelArgs): void {
|
||||
y,
|
||||
width,
|
||||
color,
|
||||
colorDark,
|
||||
isSelectedOrHovered,
|
||||
isDarkMode,
|
||||
spanBarHeight,
|
||||
@@ -379,11 +396,12 @@ function drawSpanLabel(args: DrawSpanLabelArgs): void {
|
||||
ctx.clip();
|
||||
|
||||
ctx.font = LABEL_FONT;
|
||||
const hoverLabelColor = isDarkMode ? color : colorDark;
|
||||
ctx.fillStyle = isSelectedOrHovered
|
||||
? color
|
||||
? hoverLabelColor
|
||||
: isDarkMode
|
||||
? 'rgba(0, 0, 0, 0.9)'
|
||||
: 'rgba(255, 255, 255, 0.9)';
|
||||
? 'rgba(0, 0, 0, 0.7)'
|
||||
: 'rgba(255, 255, 255, 0.95)';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const textY = y + spanBarHeight / 2;
|
||||
|
||||
@@ -3,12 +3,6 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
// QuerySearch child sets `query-builder-search-v2` globally; size it to the
|
||||
// search container by reaching into the descendant.
|
||||
:global(.query-builder-search-v2) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// ToggleGroup children use generated class names; nest the global selectors
|
||||
// under the local row so they only apply inside this filter row.
|
||||
:global([class*='toggle-group']) {
|
||||
@@ -20,8 +14,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Expanded-mode root: grows to fill .filter wrapper, and lets the search
|
||||
// input flex within. In collapsed mode none of these grow — the whole
|
||||
// Filters region is content-sized (just the pill + result + toggle).
|
||||
.isExpanded {
|
||||
flex: 1;
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.searchAndNav {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.categoryControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.searchPill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchAndNav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
@@ -29,6 +58,25 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resultActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expandedActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.highlightControl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -85,14 +133,6 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.highlightErrorsToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -100,37 +140,3 @@
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preNextToggle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preNextCount {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: auto;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.filterStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.hasError {
|
||||
color: var(--destructive);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Info,
|
||||
Loader,
|
||||
Search,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { ChevronsRight, Copy, Search, X } from '@signozhq/icons';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
@@ -21,7 +13,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import cx from 'classnames';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
@@ -42,6 +33,7 @@ import {
|
||||
SpanCategory,
|
||||
useSpanCategoryFilter,
|
||||
} from './hooks/useSpanCategoryFilter';
|
||||
import QueryResult from './QueryResult';
|
||||
|
||||
import styles from './Filters.module.scss';
|
||||
|
||||
@@ -152,6 +144,16 @@ function Filters({
|
||||
runQuery(expressionRef.current);
|
||||
}, [runQuery]);
|
||||
|
||||
const handleClear = useCallback((): void => {
|
||||
setExpression('');
|
||||
expressionRef.current = '';
|
||||
setFilters({ items: [], op: 'AND' });
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], false);
|
||||
setCurrentSearchedIndex(0);
|
||||
setNoData(false);
|
||||
}, [onFilteredSpansChange]);
|
||||
|
||||
// Expression-based filter hooks
|
||||
const filterProps = {
|
||||
expression,
|
||||
@@ -266,164 +268,167 @@ function Filters({
|
||||
</div>
|
||||
);
|
||||
|
||||
const statusIndicators = (
|
||||
<>
|
||||
{isFetching && <Loader className="animate-spin" />}
|
||||
{error && (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cx(styles.filterStatus, styles.hasError)}>
|
||||
<Info />
|
||||
API error
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(error as AxiosError)?.message || 'Something went wrong'}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
)}
|
||||
{!error && noData && (
|
||||
<Typography.Text className={styles.filterStatus}>
|
||||
No results found
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
const hasExpression = expression.trim().length > 0;
|
||||
const hasResults = filteredSpanIds.length > 0;
|
||||
|
||||
const handlePrev = useCallback((): void => {
|
||||
handlePrevNext(currentSearchedIndex - 1);
|
||||
setCurrentSearchedIndex((prev) => prev - 1);
|
||||
}, [currentSearchedIndex, handlePrevNext]);
|
||||
|
||||
const handleNext = useCallback((): void => {
|
||||
handlePrevNext(currentSearchedIndex + 1);
|
||||
setCurrentSearchedIndex((prev) => prev + 1);
|
||||
}, [currentSearchedIndex, handlePrevNext]);
|
||||
|
||||
const pill = (
|
||||
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
|
||||
<div className={styles.pill} onClick={onExpand}>
|
||||
<Search size={12} />
|
||||
<span className={styles.pillText}>{expression || 'Search...'}</span>
|
||||
{expression && <span className={styles.pillIndicator} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
// --- COLLAPSED VIEW ---
|
||||
if (!isExpanded) {
|
||||
const pill = (
|
||||
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
|
||||
<div className={styles.pill} onClick={onExpand}>
|
||||
<Search size={12} />
|
||||
<span className={styles.pillText}>{expression || 'Search...'}</span>
|
||||
{expression && <span className={styles.pillIndicator} />}
|
||||
</div>
|
||||
);
|
||||
const pillWithPopover = expression ? (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>{pill}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<div className={styles.pillPopover}>
|
||||
<div className={styles.pillPopoverHeader}>
|
||||
<Typography.Text>Search query</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void => {
|
||||
setCopy(expression);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: false,
|
||||
position: 'top-right',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.pillPopoverExpression}>{expression}</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
) : (
|
||||
pill
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className={styles.root}>
|
||||
{expression ? (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>{pill}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<div className={styles.pillPopover}>
|
||||
<div className={styles.pillPopoverHeader}>
|
||||
<Typography.Text>Search query</Typography.Text>
|
||||
// Mode-conditional render: only one of (pill | QuerySearch) is mounted
|
||||
// at a time. Collapsing unmounts the editor — half-written queries are
|
||||
// dropped, so collapse can't accidentally commit a malformed expression
|
||||
// and fire an erroring /query_range request.
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={cx(styles.root, isExpanded && styles.isExpanded)}
|
||||
ref={containerRef}
|
||||
onBlur={(e): void => {
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
const blurredIntoSelf = !!containerRef.current?.contains(relatedTarget);
|
||||
if (!blurredIntoSelf) {
|
||||
handleBlur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExpanded && (
|
||||
<div className={styles.categoryControls}>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={selectedCategory}
|
||||
onChange={(value): void => {
|
||||
if (value) {
|
||||
handleCategoryChange(value as SpanCategory);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<ToggleGroupItem key={category} value={category}>
|
||||
{category}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.searchInput}>
|
||||
{isExpanded ? (
|
||||
<div className={styles.searchAndNav}>
|
||||
<div className={styles.searchContainer}>
|
||||
<QuerySearch
|
||||
queryData={{
|
||||
...BASE_FILTER_QUERY,
|
||||
filters,
|
||||
filter: { expression },
|
||||
}}
|
||||
onChange={handleExpressionChange}
|
||||
onRun={handleRunQuery}
|
||||
dataSource={DataSource.TRACES}
|
||||
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.searchPill}>{pillWithPopover}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.resultActions}>
|
||||
<QueryResult
|
||||
hasExpression={hasExpression}
|
||||
hasResults={hasResults}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
noData={noData}
|
||||
currentIndex={currentSearchedIndex}
|
||||
total={filteredSpanIds.length}
|
||||
onPrev={handlePrev}
|
||||
onNext={handleNext}
|
||||
showNavigation={isExpanded}
|
||||
/>
|
||||
{isExpanded && (
|
||||
<div className={styles.expandedActions}>
|
||||
{hasExpression && (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void => {
|
||||
setCopy(expression);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: false,
|
||||
position: 'top-right',
|
||||
});
|
||||
}}
|
||||
onClick={handleClear}
|
||||
>
|
||||
<Copy size={12} />
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.pillPopoverExpression}>{expression}</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
) : (
|
||||
pill
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Clear filter</TooltipContent>
|
||||
</TooltipRoot>
|
||||
)}
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={onCollapse}
|
||||
>
|
||||
<ChevronsRight size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Collapse filters</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
)}
|
||||
{highlightErrorsToggle}
|
||||
{statusIndicators}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- EXPANDED VIEW ---
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className={cx(styles.root, styles.isExpanded)}>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={selectedCategory}
|
||||
onChange={(value): void => {
|
||||
if (value) {
|
||||
handleCategoryChange(value as SpanCategory);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<ToggleGroupItem key={category} value={category}>
|
||||
{category}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={styles.searchContainer}
|
||||
ref={containerRef}
|
||||
onBlur={(e): void => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
|
||||
handleBlur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<QuerySearch
|
||||
queryData={{
|
||||
...BASE_FILTER_QUERY,
|
||||
filters,
|
||||
filter: { expression },
|
||||
}}
|
||||
onChange={handleExpressionChange}
|
||||
onRun={handleRunQuery}
|
||||
dataSource={DataSource.TRACES}
|
||||
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
|
||||
/>
|
||||
</div>
|
||||
{filteredSpanIds.length > 0 && (
|
||||
<div className={styles.preNextToggle}>
|
||||
<Typography.Text className={styles.preNextCount}>
|
||||
{currentSearchedIndex + 1} / {filteredSpanIds.length}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
disabled={currentSearchedIndex === 0}
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex - 1);
|
||||
setCurrentSearchedIndex((prev) => prev - 1);
|
||||
}}
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex + 1);
|
||||
setCurrentSearchedIndex((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className={styles.collapseBtn}
|
||||
onClick={onCollapse}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
{highlightErrorsToggle}
|
||||
{statusIndicators}
|
||||
<div className={styles.highlightControl}>{highlightErrorsToggle}</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.resultNavCount {
|
||||
padding: 0 6px;
|
||||
white-space: nowrap;
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.resultNavDivider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--l3-border);
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.hasError {
|
||||
color: var(--destructive);
|
||||
cursor: help;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ChevronDown, ChevronUp, Info, Loader } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './QueryResult.module.scss';
|
||||
|
||||
type QueryResultProps = {
|
||||
hasExpression: boolean;
|
||||
hasResults: boolean;
|
||||
isFetching: boolean;
|
||||
error: unknown;
|
||||
noData: boolean;
|
||||
currentIndex: number;
|
||||
total: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
showNavigation?: boolean;
|
||||
};
|
||||
|
||||
function QueryResult({
|
||||
hasExpression,
|
||||
hasResults,
|
||||
isFetching,
|
||||
error,
|
||||
noData,
|
||||
currentIndex,
|
||||
total,
|
||||
onPrev,
|
||||
onNext,
|
||||
showNavigation = true,
|
||||
}: QueryResultProps): JSX.Element | null {
|
||||
if (!hasExpression) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let content: JSX.Element | null = null;
|
||||
if (hasResults && showNavigation) {
|
||||
// Prefer count over loader on refresh so stale results stay visible.
|
||||
content = (
|
||||
<>
|
||||
<Typography.Text className={styles.resultNavCount}>
|
||||
{currentIndex + 1} / {total}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
disabled={currentIndex === 0}
|
||||
onClick={onPrev}
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
disabled={currentIndex === total - 1}
|
||||
onClick={onNext}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
} else if (isFetching) {
|
||||
content = <Loader className="animate-spin" />;
|
||||
} else if (error) {
|
||||
content = (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cx(styles.filterStatus, styles.hasError)}>
|
||||
<Info />
|
||||
API error
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(error as AxiosError)?.message || 'Something went wrong'}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
);
|
||||
} else if (noData) {
|
||||
content = (
|
||||
<Typography.Text className={styles.filterStatus}>
|
||||
No results found
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{content}
|
||||
{showNavigation && <span className={styles.resultNavDivider} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
QueryResult.defaultProps = {
|
||||
showNavigation: true,
|
||||
};
|
||||
|
||||
export default QueryResult;
|
||||
@@ -433,6 +433,7 @@
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin: 0 6px;
|
||||
background-color: var(--service-dot-color);
|
||||
|
||||
&.hasError {
|
||||
box-shadow: 0 0 0 2px rgba(255, 70, 70, 0.3);
|
||||
@@ -514,7 +515,7 @@
|
||||
.spanBar {
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
top: 5px;
|
||||
top: 3px;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -522,7 +523,9 @@
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
// Theme-resolved in JS: dark text on the bright dark-mode fill, white text on
|
||||
// the darkened light-mode fill. See SpanDuration in Success.tsx.
|
||||
color: var(--span-text-color);
|
||||
background-color: var(--span-color);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
@@ -548,7 +551,6 @@
|
||||
|
||||
.spanDurationText {
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
font-size: 10px;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
@@ -607,25 +609,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// `.spanBar` text color is the one place where semantic tokens don't fit
|
||||
// cleanly: in dark mode the bar's bright `--span-color` background needs dark
|
||||
// text; in light mode `generateColor` produces darker bar fills, so the text
|
||||
// must flip to white.
|
||||
:global(.lightMode) {
|
||||
.root {
|
||||
.spanDuration .spanBar {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.timelineRow:hover .spanBar,
|
||||
.timelineRow.hoveredSpan .spanBar,
|
||||
.isInterested .spanBar,
|
||||
.isSelectedNonMatching .spanBar {
|
||||
color: var(--span-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltips for the row's hover-revealed action buttons (Copy / Add to Funnel).
|
||||
// Bumped above FloatingPanel (z-index 999) so they stay visible when the
|
||||
// SpanDetailsPanel is docked as a floating panel.
|
||||
|
||||
@@ -29,6 +29,7 @@ import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||
@@ -214,8 +215,12 @@ const SpanOverview = memo(function SpanOverview({
|
||||
const isRootSpan = span.level === 0;
|
||||
const { onSpanCopy } = useCopySpanLink(span);
|
||||
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const color = resolveSpanColor(span, colorByFieldName);
|
||||
const { color, colorDark } = resolveSpanColor(span, colorByFieldName);
|
||||
// Single theme-resolved color: bright base in dark mode, darkened variant in
|
||||
// light mode so the dot stands out against the white panel.
|
||||
const effectiveColor = isDarkMode ? color : colorDark;
|
||||
|
||||
// Smart highlighting logic
|
||||
const {
|
||||
@@ -317,7 +322,11 @@ const SpanOverview = memo(function SpanOverview({
|
||||
{/* Colored service dot */}
|
||||
<span
|
||||
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
|
||||
style={{ backgroundColor: color }}
|
||||
style={
|
||||
{
|
||||
'--service-dot-color': effectiveColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Span name + service name */}
|
||||
@@ -391,9 +400,16 @@ export const SpanDuration = memo(function SpanDuration({
|
||||
const width = (span.duration_nano * 1e2) / (spread * 1e6);
|
||||
|
||||
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
|
||||
const color = resolveSpanColor(span, colorByFieldName);
|
||||
// `resolveSpanColor` returns a CSS variable for errors; `colorToRgb` can't parse it.
|
||||
const rgbColor = span.has_error ? '239, 68, 68' : colorToRgb(color);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { color, colorDark } = resolveSpanColor(span, colorByFieldName);
|
||||
// Single theme-resolved color: bright base in dark mode, darkened variant in
|
||||
// light mode (so the bar stands out against the white panel and hover/selected
|
||||
// foregrounds stay legible). The bar's text flips dark↔white to suit the fill.
|
||||
const effectiveColor = isDarkMode ? color : colorDark;
|
||||
const rgbColor = colorToRgb(effectiveColor);
|
||||
const spanTextColor = isDarkMode
|
||||
? 'rgba(0, 0, 0, 0.7)'
|
||||
: 'rgba(255, 255, 255, 0.95)';
|
||||
|
||||
const {
|
||||
isSelected,
|
||||
@@ -424,8 +440,9 @@ export const SpanDuration = memo(function SpanDuration({
|
||||
{
|
||||
left: `${leftOffset}%`,
|
||||
width: `${width}%`,
|
||||
'--span-color': color,
|
||||
'--span-color': effectiveColor,
|
||||
'--span-color-rgb': rgbColor,
|
||||
'--span-text-color': spanTextColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
|
||||
@@ -103,6 +103,7 @@ jest.mock('components/TimelineV3/TimelineV3', () => {
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: (): string => '#1890ff',
|
||||
colorToRgb: (): string => '24, 144, 255',
|
||||
hashFn: (): number => 0,
|
||||
}));
|
||||
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import {
|
||||
ColorPair,
|
||||
generateColorPair,
|
||||
RESERVED_ERROR,
|
||||
} from './utils/generateColorPair';
|
||||
|
||||
/**
|
||||
* Look up an attribute from both `resource` and `attributes` on a span.
|
||||
* Resources are checked first (service.name, k8s.* etc. live there).
|
||||
@@ -92,18 +96,16 @@ export function getSpanGroupValue(
|
||||
|
||||
/**
|
||||
* Resolves the rendering colour for a span. Error spans always get the
|
||||
* semantic destructive colour; everything else is derived deterministically
|
||||
* from its group value via `generateColor`.
|
||||
* reserved error colour; everything else is derived deterministically from its
|
||||
* group value via `generateColorPair`. Returns both the base color and a
|
||||
* darkened variant for light-mode hover/selected foregrounds.
|
||||
*/
|
||||
export function resolveSpanColor(
|
||||
span: SpanV3,
|
||||
colorByFieldName: string,
|
||||
): string {
|
||||
): ColorPair {
|
||||
if (span.has_error) {
|
||||
return 'var(--destructive)';
|
||||
return { color: RESERVED_ERROR, colorDark: RESERVED_ERROR };
|
||||
}
|
||||
return generateColor(
|
||||
getSpanGroupValue(span, colorByFieldName),
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
return generateColorPair(getSpanGroupValue(span, colorByFieldName));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
darkenHex,
|
||||
generateColorPair,
|
||||
PALETTE_V3,
|
||||
RESERVED_ERROR,
|
||||
RESERVED_OK,
|
||||
RESERVED_WARNING,
|
||||
} from '../generateColorPair';
|
||||
|
||||
describe('generateColorPair', () => {
|
||||
it('is deterministic: same name returns the same pair across calls', () => {
|
||||
const a = generateColorPair('payment-service');
|
||||
const b = generateColorPair('payment-service');
|
||||
expect(a).toBe(b); // cache hit returns the same reference
|
||||
expect(a.color).toBe(b.color);
|
||||
expect(a.colorDark).toBe(b.colorDark);
|
||||
});
|
||||
|
||||
it('returns a palette color for a normal name', () => {
|
||||
const { color } = generateColorPair('any-service');
|
||||
expect(PALETTE_V3).toContain(color);
|
||||
});
|
||||
|
||||
it('colorDark differs from color (darker variant computed via darkenHex)', () => {
|
||||
const { color, colorDark } = generateColorPair('checkout-svc');
|
||||
expect(colorDark).not.toBe(color);
|
||||
expect(colorDark).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
});
|
||||
|
||||
it('produces different colors for different names (palette wraps modulo length)', () => {
|
||||
const a = generateColorPair('aaa');
|
||||
const b = generateColorPair('bbb');
|
||||
// Not strictly guaranteed (hash collisions exist with 28 buckets), but
|
||||
// for these two short strings djb2 produces different bucket indices.
|
||||
expect(a.color).not.toBe(b.color);
|
||||
});
|
||||
});
|
||||
|
||||
describe('darkenHex', () => {
|
||||
it('returns a darker hex than the input for amount > 0', () => {
|
||||
const input = '#4D6BD8';
|
||||
const out = darkenHex(input, 0.22);
|
||||
expect(out).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
expect(out).not.toBe(input);
|
||||
});
|
||||
|
||||
it('handles amount = 0 as a near-identity', () => {
|
||||
const out = darkenHex('#4D6BD8', 0);
|
||||
// HSL round-trip may shift a digit; only assert format.
|
||||
expect(out).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserved status colors', () => {
|
||||
it('matches spec section 8 hexes', () => {
|
||||
expect(RESERVED_ERROR).toBe('#FC4E4E');
|
||||
expect(RESERVED_WARNING).toBe('#fbbf24');
|
||||
expect(RESERVED_OK).toBe('#4ade80');
|
||||
});
|
||||
});
|
||||
|
||||
// Visual inspection table: each palette color paired with its darkenHex(0.22)
|
||||
// variant. Confirms the darkening produces a distinct, non-collapsed hex per
|
||||
// entry. Run with `yarn jest generateColorPair --verbose` to see the table.
|
||||
describe('PALETTE_V3 darken-pair table', () => {
|
||||
const PALETTE_NAMES = [
|
||||
'Slate blue',
|
||||
'Sage',
|
||||
'Amber',
|
||||
'Dusty pink',
|
||||
'Lavender',
|
||||
'Peach',
|
||||
'Sky teal',
|
||||
'Sakura',
|
||||
'Terracotta',
|
||||
'Forest',
|
||||
'Cornflower',
|
||||
'Iris',
|
||||
'Olive gold',
|
||||
'Mint',
|
||||
'Mauve',
|
||||
'Dusty teal',
|
||||
'Burnt orange',
|
||||
'Pistachio',
|
||||
'Periwinkle',
|
||||
'Coral blush',
|
||||
'Sienna',
|
||||
'Robin',
|
||||
'Sandy gold',
|
||||
'Powder blue',
|
||||
'Umber',
|
||||
'Aqua',
|
||||
'Warm tan',
|
||||
'Antique rose',
|
||||
];
|
||||
|
||||
it.each(
|
||||
PALETTE_V3.map((hex, i) => [PALETTE_NAMES[i] ?? `idx-${i}`, hex] as const),
|
||||
)('%s (%s) darkens to a distinct hex', (name, hex) => {
|
||||
const dark = darkenHex(hex, 0.22);
|
||||
expect(dark).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
expect(dark.toLowerCase()).not.toBe(hex.toLowerCase());
|
||||
});
|
||||
|
||||
it('all 28 darkened variants are unique (no collisions)', () => {
|
||||
const darks = PALETTE_V3.map((hex) => darkenHex(hex, 0.22).toLowerCase());
|
||||
const unique = new Set(darks);
|
||||
expect(unique.size).toBe(PALETTE_V3.length);
|
||||
});
|
||||
|
||||
it('prints the base→dark table for visual inspection', () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\nPALETTE_V3 base → darkenHex(0.22) pairs:');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('idx name base dark');
|
||||
PALETTE_V3.forEach((hex, i) => {
|
||||
const dark = darkenHex(hex, 0.22);
|
||||
const name = (PALETTE_NAMES[i] ?? '').padEnd(13);
|
||||
const idx = String(i).padStart(2, ' ');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${idx} ${name} ${hex} ${dark}`);
|
||||
});
|
||||
// Sentinel assertion so the test is not flagged as having none.
|
||||
expect(PALETTE_V3).toHaveLength(28);
|
||||
});
|
||||
});
|
||||
111
frontend/src/pages/TraceDetailsV3/utils/generateColorPair.ts
Normal file
111
frontend/src/pages/TraceDetailsV3/utils/generateColorPair.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// Source-of-truth doc: ./COLOR_PALETTE.md
|
||||
//
|
||||
// Color system for TraceDetailsV3 (waterfall + flamegraph). Returns a base
|
||||
// color (deterministic per group name) plus a darkened variant used as the
|
||||
// light-mode foreground / fill. Reuses the shared djb2 `hashFn`.
|
||||
|
||||
import { hashFn } from 'lib/uPlotLib/utils/generateColor';
|
||||
|
||||
// 28 colors from the doc's "Updated Colour Palette" (Section 1), in doc order.
|
||||
// Hash output `% PALETTE.length` adjusts automatically if entries are added.
|
||||
export const PALETTE_V3: readonly string[] = [
|
||||
'#4D6BD8', // Slate blue
|
||||
'#84B270', // Sage
|
||||
'#EB9E40', // Amber
|
||||
'#D58998', // Dusty pink
|
||||
'#8278D5', // Lavender
|
||||
'#E69C6F', // Peach
|
||||
'#3CB4DA', // Sky teal
|
||||
'#F24769', // Sakura
|
||||
'#D4694A', // Terracotta
|
||||
'#25E192', // Forest
|
||||
'#5BA2D6', // Cornflower
|
||||
'#9D57D0', // Iris
|
||||
'#D4B638', // Olive gold
|
||||
'#6CC4A4', // Mint
|
||||
'#D188CB', // Mauve
|
||||
'#2FB59B', // Dusty teal
|
||||
'#E68340', // Burnt orange
|
||||
'#B8C474', // Pistachio
|
||||
'#3C84E5', // Periwinkle
|
||||
'#E29F8E', // Coral blush
|
||||
'#C56330', // Sienna
|
||||
'#4E74F8', // Robin
|
||||
'#E8B752', // Sandy gold
|
||||
'#8DBEDF', // Powder blue
|
||||
'#8B7544', // Umber
|
||||
'#23C4F8', // Aqua
|
||||
'#CB874A', // Warm tan
|
||||
'#C886A9', // Antique rose
|
||||
];
|
||||
|
||||
// Reserved status colors per spec section 8. Error is wired today;
|
||||
// warning + OK are exported for future use (no render path consumes them yet).
|
||||
export const RESERVED_ERROR = '#FC4E4E';
|
||||
export const RESERVED_WARNING = '#fbbf24';
|
||||
export const RESERVED_OK = '#4ade80';
|
||||
|
||||
function hexToHsl(hex: string): [number, number, number] {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const r = ((n >> 16) & 255) / 255;
|
||||
const g = ((n >> 8) & 255) / 255;
|
||||
const b = (n & 255) / 255;
|
||||
const mx = Math.max(r, g, b);
|
||||
const mn = Math.min(r, g, b);
|
||||
const d = mx - mn;
|
||||
const l = (mx + mn) / 2;
|
||||
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
|
||||
let h: number;
|
||||
if (d === 0) {
|
||||
h = 0;
|
||||
} else if (mx === r) {
|
||||
h = 60 * (((g - b) / d) % 6);
|
||||
} else if (mx === g) {
|
||||
h = 60 * ((b - r) / d + 2);
|
||||
} else {
|
||||
h = 60 * ((r - g) / d + 4);
|
||||
}
|
||||
return [(h + 360) % 360, s * 100, l * 100];
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const S = s / 100;
|
||||
const L = l / 100;
|
||||
const k = (n: number): number => (n + h / 30) % 12;
|
||||
const a = S * Math.min(L, 1 - L);
|
||||
const f = (n: number): number =>
|
||||
Math.round(
|
||||
255 * (L - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))),
|
||||
);
|
||||
return `#${[f(0), f(8), f(4)]
|
||||
.map((v) => v.toString(16).padStart(2, '0'))
|
||||
.join('')}`;
|
||||
}
|
||||
|
||||
export function darkenHex(hex: string, amount: number): string {
|
||||
const [h, s, l] = hexToHsl(hex);
|
||||
return hslToHex(h, s, Math.max(0, l * (1 - amount)));
|
||||
}
|
||||
|
||||
export interface ColorPair {
|
||||
color: string;
|
||||
colorDark: string;
|
||||
}
|
||||
|
||||
// Distinct-name cardinality is bounded by deployment service count (~10s, not 1000s),
|
||||
// so unbounded growth is not a concern.
|
||||
const cache = new Map<string, ColorPair>();
|
||||
|
||||
export function generateColorPair(name: string): ColorPair {
|
||||
const hit = cache.get(name);
|
||||
if (hit) {
|
||||
return hit;
|
||||
}
|
||||
const base = PALETTE_V3[hashFn(name) % PALETTE_V3.length];
|
||||
const result: ColorPair = {
|
||||
color: base,
|
||||
colorDark: darkenHex(base, 0.22),
|
||||
};
|
||||
cache.set(name, result);
|
||||
return result;
|
||||
}
|
||||
@@ -29,24 +29,5 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetWaterfallV4",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get waterfall view for a trace",
|
||||
Description: "Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3",
|
||||
Request: new(spantypes.PostableWaterfall),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spantypes.GettableWaterfallTrace),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -38,24 +38,3 @@ func (h *handler) GetWaterfall(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(spantypes.PostableWaterfall)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.GetWaterfallV4(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans, req.Limit)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package impltracedetail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
@@ -46,7 +45,7 @@ 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.
|
||||
// getTraceData returns the waterfall cache for the given traceID with fallback on DB.
|
||||
func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.WaterfallTrace, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
@@ -62,86 +61,6 @@ func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.W
|
||||
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
|
||||
// visible window only. Aggregations are not returned.
|
||||
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
effectiveLimit := min(selectAllLimit, m.config.Waterfall.MaxLimitToSelectAllSpans)
|
||||
if summary.NumSpans > uint64(effectiveLimit) {
|
||||
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, summary.Start, summary.End)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(minimalSpans) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
nodes := make([]*spantypes.WaterfallSpan, len(minimalSpans))
|
||||
for i := range minimalSpans {
|
||||
nodes[i] = minimalSpans[i].ToWaterfallSpan(traceID)
|
||||
}
|
||||
waterfallTrace := spantypes.NewWaterfallTraceFromSpans(nodes)
|
||||
|
||||
selectedSpans, uncollapsedSpans := waterfallTrace.GetSelectedSpans(
|
||||
uncollapsedSpans,
|
||||
selectedSpanID,
|
||||
m.config.Waterfall.SpanPageSize,
|
||||
m.config.Waterfall.MaxDepthToAutoExpand,
|
||||
)
|
||||
|
||||
// Step 2: full fetch for the selected window only
|
||||
spanIDs := make([]string, len(selectedSpans))
|
||||
for i, s := range selectedSpans {
|
||||
spanIDs[i] = s.SpanID
|
||||
}
|
||||
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, traceID, start, end, spanIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spantypes.EnrichSelectedSpans(selectedSpans, fullSpans)
|
||||
|
||||
return spantypes.NewGettableWaterfallTrace(
|
||||
waterfallTrace, selectedSpans, uncollapsedSpans, false, nil,
|
||||
), nil
|
||||
traceData := spantypes.NewWaterfallTraceFromSpans(spanItems)
|
||||
return traceData, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
@@ -13,8 +12,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
)
|
||||
|
||||
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
|
||||
|
||||
type traceStore struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
}
|
||||
@@ -48,8 +45,8 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
|
||||
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder
|
||||
query := fmt.Sprintf(`
|
||||
SELECT DISTINCT ON (span_id)
|
||||
timestamp, duration_nano, span_id, has_error, kind,
|
||||
resource_string_service$$name, name,
|
||||
timestamp, duration_nano, span_id, trace_id, has_error, kind,
|
||||
resource_string_service$$name, name, links as references,
|
||||
attributes_string, attributes_number, attributes_bool, resources_string,
|
||||
events, status_message, status_code_string, kind_string, parent_span_id,
|
||||
flags, is_remote, trace_state, status_code,
|
||||
@@ -72,64 +69,3 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
|
||||
}
|
||||
return spanItems, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]spantypes.MinimalSpan, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) span_id",
|
||||
"parent_span_id", "timestamp", "duration_nano", "has_error",
|
||||
colServiceName,
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", end.Unix()),
|
||||
)
|
||||
sb.OrderByAsc("timestamp")
|
||||
sb.OrderByAsc("name")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var spans []spantypes.MinimalSpan
|
||||
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying minimal spans")
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
|
||||
if len(spanIDs) == 0 {
|
||||
return []spantypes.StorableSpan{}, nil
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) timestamp",
|
||||
"duration_nano", "span_id", "has_error", "kind",
|
||||
colServiceName, "name",
|
||||
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
ids := make([]any, len(spanIDs))
|
||||
for i, id := range spanIDs {
|
||||
ids[i] = id
|
||||
}
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.In("span_id", ids...),
|
||||
sb.GE("ts_bucket_start", start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", end.Unix()),
|
||||
)
|
||||
sb.OrderByAsc("timestamp")
|
||||
sb.OrderByAsc("name")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var spans []spantypes.StorableSpan
|
||||
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace spans by IDs")
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
@@ -10,11 +10,9 @@ import (
|
||||
// Handler exposes HTTP handlers for trace detail APIs.
|
||||
type Handler interface {
|
||||
GetWaterfall(http.ResponseWriter, *http.Request)
|
||||
GetWaterfallV4(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const traceOutsideRangeWarn = "Query %s references a trace_id that exists between %s and %s (UTC) but lies outside the selected time range; adjust the time range to see results"
|
||||
|
||||
type builderQuery[T any] struct {
|
||||
logger *slog.Logger
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
@@ -201,21 +199,7 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
|
||||
return q.executeWindowList(ctx)
|
||||
}
|
||||
|
||||
fromMS, toMS := q.fromMS, q.toMS
|
||||
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
|
||||
var overlap bool
|
||||
var warning string
|
||||
fromMS, toMS, overlap, warning = q.narrowWindowByTraceID(ctx, fromMS, toMS)
|
||||
if !overlap {
|
||||
res := emptyResultFor(q.kind, q.spec.Name)
|
||||
if warning != "" {
|
||||
res.Warnings = []string{warning}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
stmt, err := q.stmtBuilder.Build(ctx, fromMS, toMS, q.kind, q.spec, q.variables)
|
||||
stmt, err := q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -231,88 +215,6 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// narrowWindowByTraceID inspects the filter for trace_id predicates and clamps
|
||||
// [fromMS,toMS] to the time range stored in signoz_traces.distributed_trace_summary.
|
||||
// Returns the (possibly narrowed) window, overlap=false when the trace lies
|
||||
// completely outside the query window (callers should short-circuit), and a
|
||||
// warning string the caller should attach to the empty result when the trace
|
||||
// exists but is outside the selected window.
|
||||
//
|
||||
// When the trace_id is not present in trace_summary the behaviour differs by
|
||||
// signal:
|
||||
// - traces: trace_summary is derived from the spans table, so a missing row
|
||||
// means no spans exist for that trace_id; we short-circuit to empty.
|
||||
// - logs: logs can carry a trace_id even when traces are not ingested at all
|
||||
// (e.g. traces disabled). We must not short-circuit; instead leave the
|
||||
// window untouched and let the query run.
|
||||
func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toMS uint64) (uint64, uint64, bool, string) {
|
||||
if q.spec.Filter == nil || q.spec.Filter.Expression == "" {
|
||||
return fromMS, toMS, true, ""
|
||||
}
|
||||
|
||||
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
|
||||
if !found || len(traceIDs) == 0 {
|
||||
return fromMS, toMS, true, ""
|
||||
}
|
||||
|
||||
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
|
||||
traceStart, traceEnd, exists, err := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
|
||||
if err != nil {
|
||||
return fromMS, toMS, true, ""
|
||||
}
|
||||
if !exists {
|
||||
if q.spec.Signal == telemetrytypes.SignalTraces {
|
||||
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; short-circuiting traces query to empty",
|
||||
slog.Any("trace_ids", traceIDs))
|
||||
return fromMS, toMS, false, ""
|
||||
}
|
||||
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; leaving time range untouched for logs",
|
||||
slog.Any("trace_ids", traceIDs))
|
||||
return fromMS, toMS, true, ""
|
||||
}
|
||||
|
||||
traceStartMS := uint64(traceStart) / 1_000_000
|
||||
traceEndMS := uint64(traceEnd) / 1_000_000
|
||||
if traceStartMS == 0 || traceEndMS == 0 {
|
||||
return fromMS, toMS, true, ""
|
||||
}
|
||||
|
||||
if traceStartMS > toMS || traceEndMS < fromMS {
|
||||
traceStartUTC := time.UnixMilli(int64(traceStartMS)).UTC().Format(time.RFC3339)
|
||||
traceEndUTC := time.UnixMilli(int64(traceEndMS)).UTC().Format(time.RFC3339)
|
||||
return fromMS, toMS, false, fmt.Sprintf(traceOutsideRangeWarn, q.spec.Name, traceStartUTC, traceEndUTC)
|
||||
}
|
||||
if traceStartMS > fromMS {
|
||||
fromMS = traceStartMS
|
||||
}
|
||||
if traceEndMS < toMS {
|
||||
toMS = traceEndMS
|
||||
}
|
||||
q.logger.DebugContext(ctx, "optimized time range using trace_id lookup",
|
||||
slog.String("signal", q.spec.Signal.StringValue()),
|
||||
slog.Any("trace_ids", traceIDs),
|
||||
slog.Uint64("start", fromMS),
|
||||
slog.Uint64("end", toMS))
|
||||
return fromMS, toMS, true, ""
|
||||
}
|
||||
|
||||
// emptyResultFor returns an empty result payload appropriate for the given kind.
|
||||
func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result {
|
||||
var value any
|
||||
switch kind {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
value = &qbtypes.TimeSeriesData{QueryName: queryName}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
value = &qbtypes.ScalarData{QueryName: queryName}
|
||||
default:
|
||||
value = &qbtypes.RawData{QueryName: queryName}
|
||||
}
|
||||
return &qbtypes.Result{
|
||||
Type: kind,
|
||||
Value: value,
|
||||
}
|
||||
}
|
||||
|
||||
// executeWithContext executes the query with query window and step context for partial value detection.
|
||||
func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string, args []any) (*qbtypes.Result, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
@@ -408,27 +310,42 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
|
||||
totalBytes := uint64(0)
|
||||
start := time.Now()
|
||||
|
||||
// Check if filter contains trace_id(s) and optimize time range if needed.
|
||||
// Applies to both traces (the listing this branch was built for) and logs
|
||||
// (which carry trace_id and benefit from the same clamp before bucketing).
|
||||
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
|
||||
var overlap bool
|
||||
var warning string
|
||||
fromMS, toMS, overlap, warning = q.narrowWindowByTraceID(ctx, fromMS, toMS)
|
||||
if !overlap {
|
||||
res := &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeRaw,
|
||||
Value: &qbtypes.RawData{
|
||||
QueryName: q.spec.Name,
|
||||
},
|
||||
Stats: qbtypes.ExecStats{
|
||||
DurationMS: uint64(time.Since(start).Milliseconds()),
|
||||
},
|
||||
// Check if filter contains trace_id(s) and optimize time range if needed
|
||||
if q.spec.Signal == telemetrytypes.SignalTraces &&
|
||||
q.spec.Filter != nil && q.spec.Filter.Expression != "" {
|
||||
|
||||
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
|
||||
if found && len(traceIDs) > 0 {
|
||||
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
|
||||
|
||||
traceStart, traceEnd, ok := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
|
||||
traceStartMS := uint64(traceStart) / 1_000_000
|
||||
traceEndMS := uint64(traceEnd) / 1_000_000
|
||||
if !ok {
|
||||
q.logger.DebugContext(ctx, "failed to get trace time range", slog.Any("trace_ids", traceIDs))
|
||||
} else if traceStartMS > 0 && traceEndMS > 0 {
|
||||
// no overlap — nothing to return
|
||||
if uint64(traceStartMS) > toMS || uint64(traceEndMS) < fromMS {
|
||||
return &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeRaw,
|
||||
Value: &qbtypes.RawData{
|
||||
QueryName: q.spec.Name,
|
||||
},
|
||||
Stats: qbtypes.ExecStats{
|
||||
DurationMS: uint64(time.Since(start).Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// clamp window to trace time range before bucketing
|
||||
if uint64(traceStartMS) > fromMS {
|
||||
fromMS = uint64(traceStartMS)
|
||||
}
|
||||
if uint64(traceEndMS) < toMS {
|
||||
toMS = uint64(traceEndMS)
|
||||
}
|
||||
q.logger.DebugContext(ctx, "optimized time range for traces", slog.Any("trace_ids", traceIDs), slog.Uint64("start", fromMS), slog.Uint64("end", toMS))
|
||||
}
|
||||
if warning != "" {
|
||||
res.Warnings = []string{warning}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,19 +21,19 @@ func NewTraceTimeRangeFinder(telemetryStore telemetrystore.TelemetryStore) *Trac
|
||||
}
|
||||
}
|
||||
|
||||
func (f *TraceTimeRangeFinder) GetTraceTimeRange(ctx context.Context, traceID string) (startNano, endNano int64, exists bool, error error) {
|
||||
func (f *TraceTimeRangeFinder) GetTraceTimeRange(ctx context.Context, traceID string) (startNano, endNano int64, ok bool) {
|
||||
traceIDs := []string{traceID}
|
||||
return f.GetTraceTimeRangeMulti(ctx, traceIDs)
|
||||
}
|
||||
|
||||
func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, traceIDs []string) (startNano, endNano int64, exists bool, error error) {
|
||||
func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, traceIDs []string) (startNano, endNano int64, ok bool) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "trace-time-range",
|
||||
instrumentationtypes.CodeFunctionName: "GetTraceTimeRangeMulti",
|
||||
})
|
||||
if len(traceIDs) == 0 {
|
||||
return 0, 0, false, nil
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
cleanedIDs := make([]string, len(traceIDs))
|
||||
@@ -49,8 +49,7 @@ func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, trace
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
count(),
|
||||
SELECT
|
||||
toUnixTimestamp64Nano(min(start)),
|
||||
toUnixTimestamp64Nano(max(end))
|
||||
FROM %s.%s
|
||||
@@ -59,14 +58,9 @@ func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, trace
|
||||
|
||||
row := f.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...)
|
||||
|
||||
var rowCount uint64
|
||||
err := row.Scan(&rowCount, &startNano, &endNano)
|
||||
err := row.Scan(&startNano, &endNano)
|
||||
if err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
|
||||
if rowCount == 0 {
|
||||
return 0, 0, false, nil
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
if startNano > 1_000_000_000 {
|
||||
@@ -74,5 +68,5 @@ func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, trace
|
||||
}
|
||||
endNano += 1_000_000_000
|
||||
|
||||
return startNano, endNano, true, nil
|
||||
return startNano, endNano, true
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestGetTraceTimeRangeMulti(t *testing.T) {
|
||||
finder := &TraceTimeRangeFinder{telemetryStore: nil}
|
||||
|
||||
if !tt.expectOK {
|
||||
_, _, ok, _ := finder.GetTraceTimeRangeMulti(ctx, tt.traceIDs)
|
||||
_, _, ok := finder.GetTraceTimeRangeMulti(ctx, tt.traceIDs)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ package spantypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -27,6 +26,4 @@ type SpanMapperStore interface {
|
||||
type TraceStore interface {
|
||||
GetTraceSummary(ctx context.Context, traceID string) (*TraceSummary, error)
|
||||
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
|
||||
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
|
||||
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
}
|
||||
|
||||
@@ -103,10 +103,12 @@ type StorableSpan struct {
|
||||
StartTime time.Time `ch:"timestamp"`
|
||||
DurationNano uint64 `ch:"duration_nano"`
|
||||
SpanID string `ch:"span_id"`
|
||||
TraceID string `ch:"trace_id"`
|
||||
HasError bool `ch:"has_error"`
|
||||
Kind int8 `ch:"kind"`
|
||||
ServiceName string `ch:"resource_string_service$$name"`
|
||||
Name string `ch:"name"`
|
||||
References string `ch:"references"`
|
||||
AttributesString map[string]string `ch:"attributes_string"`
|
||||
AttributesNumber map[string]float64 `ch:"attributes_number"`
|
||||
AttributesBool map[string]bool `ch:"attributes_bool"`
|
||||
@@ -130,32 +132,6 @@ type StorableSpan struct {
|
||||
ResponseStatusCode string `ch:"response_status_code"`
|
||||
}
|
||||
|
||||
// MinimalSpan with only the fields needed to build the parent-child tree.
|
||||
type MinimalSpan struct {
|
||||
SpanID string `ch:"span_id"`
|
||||
ParentSpanID string `ch:"parent_span_id"`
|
||||
StartTime time.Time `ch:"timestamp"`
|
||||
DurationNano uint64 `ch:"duration_nano"`
|
||||
HasError bool `ch:"has_error"`
|
||||
ServiceName string `ch:"resource_string_service$$name"`
|
||||
}
|
||||
|
||||
func (item *MinimalSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
return &WaterfallSpan{
|
||||
SpanID: item.SpanID,
|
||||
TraceID: traceID,
|
||||
ParentSpanID: item.ParentSpanID,
|
||||
TimeUnix: uint64(item.StartTime.UnixNano()),
|
||||
DurationNano: item.DurationNano,
|
||||
HasError: item.HasError,
|
||||
ServiceName: item.ServiceName,
|
||||
Resource: map[string]string{"service.name": item.ServiceName},
|
||||
Children: make([]*WaterfallSpan, 0),
|
||||
Attributes: make(map[string]any),
|
||||
Events: make([]Event, 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{
|
||||
@@ -285,7 +261,7 @@ func (item *StorableSpan) UnmarshalledEvents() []Event {
|
||||
return events
|
||||
}
|
||||
|
||||
func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
func (item *StorableSpan) ToWaterfallSpan() *WaterfallSpan {
|
||||
resources := make(map[string]string)
|
||||
maps.Copy(resources, item.ResourcesString)
|
||||
|
||||
@@ -313,7 +289,7 @@ func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
StatusCode: item.StatusCode,
|
||||
StatusCodeString: item.StatusCodeString,
|
||||
StatusMessage: item.StatusMessage,
|
||||
TraceID: traceID,
|
||||
TraceID: item.TraceID,
|
||||
TraceState: item.TraceState,
|
||||
Children: make([]*WaterfallSpan, 0),
|
||||
TimeUnix: uint64(item.StartTime.UnixNano()),
|
||||
@@ -321,24 +297,6 @@ func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
}
|
||||
}
|
||||
|
||||
func EnrichSelectedSpans(window []*WaterfallSpan, fullSpans []StorableSpan) {
|
||||
fullByID := make(map[string]*StorableSpan, len(fullSpans))
|
||||
for i := range fullSpans {
|
||||
fullByID[fullSpans[i].SpanID] = &fullSpans[i]
|
||||
}
|
||||
for i, ws := range window {
|
||||
full, ok := fullByID[ws.SpanID]
|
||||
if !ok {
|
||||
continue // synthesized MissingSpan — keep empty shell
|
||||
}
|
||||
newWS := full.ToWaterfallSpan(ws.TraceID)
|
||||
newWS.Level = ws.Level
|
||||
newWS.HasChildren = ws.HasChildren
|
||||
newWS.SubTreeNodeCount = ws.SubTreeNodeCount
|
||||
window[i] = newWS
|
||||
}
|
||||
}
|
||||
|
||||
// getSpanIndex returns the index of matched span and -1 for no match.
|
||||
func getSpanIndex(spans []*WaterfallSpan, targetSpanID string) int {
|
||||
for i, s := range spans {
|
||||
|
||||
@@ -62,24 +62,26 @@ func NewWaterfallTrace(
|
||||
}
|
||||
}
|
||||
|
||||
// NewWaterfallTraceFromSpans requires WaterfallSpan nodes with only below fields:
|
||||
// SpanID, ParentSpanID, TimeUnix, DurationNano, HasError, and ServiceName.
|
||||
func NewWaterfallTraceFromSpans(nodes []*WaterfallSpan) *WaterfallTrace {
|
||||
func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
|
||||
var (
|
||||
startTime, endTime, totalErrorSpans uint64
|
||||
spanIDToSpanNodeMap = make(map[string]*WaterfallSpan, len(nodes))
|
||||
spanIDToSpanNodeMap = make(map[string]*WaterfallSpan, len(spans))
|
||||
traceRoots []*WaterfallSpan
|
||||
hasMissingSpans bool
|
||||
)
|
||||
|
||||
for _, span := range nodes {
|
||||
if startTime == 0 || span.TimeUnix < startTime {
|
||||
startTime = span.TimeUnix
|
||||
for _, item := range spans {
|
||||
span := item.ToWaterfallSpan()
|
||||
startTimeUnixNano := uint64(item.StartTime.UnixNano())
|
||||
if startTime == 0 || startTimeUnixNano < startTime {
|
||||
startTime = startTimeUnixNano
|
||||
}
|
||||
endTime = max(endTime, span.TimeUnix+span.DurationNano)
|
||||
endTime = max(endTime, startTimeUnixNano+span.DurationNano)
|
||||
|
||||
if span.HasError {
|
||||
totalErrorSpans++
|
||||
}
|
||||
|
||||
spanIDToSpanNodeMap[span.SpanID] = span
|
||||
}
|
||||
|
||||
@@ -114,7 +116,7 @@ func NewWaterfallTraceFromSpans(nodes []*WaterfallSpan) *WaterfallTrace {
|
||||
return NewWaterfallTrace(
|
||||
startTime,
|
||||
endTime,
|
||||
uint64(len(nodes)),
|
||||
uint64(len(spans)),
|
||||
totalErrorSpans,
|
||||
spanIDToSpanNodeMap,
|
||||
traceRoots,
|
||||
|
||||
@@ -20,7 +20,6 @@ from fixtures.querier import (
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
def test_logs_list(
|
||||
@@ -2294,334 +2293,3 @@ def test_logs_formula_orderby_and_limit(
|
||||
assert len(f3_services) == 3, f"F3: expected 3 rows after limit, got {len(f3_services)}"
|
||||
assert f3_values == f4_values[:3], f"F3 values {f3_values} do not match F4[:3] values {f4_values[:3]}"
|
||||
assert set(f3_services) == set(f4_services[:3]), f"F3 services {f3_services} do not match F4[:3] services {f4_services[:3]}"
|
||||
|
||||
|
||||
def test_logs_list_filter_by_trace_id(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Tests that filtering logs by trace_id uses the trace_summary lookup to
|
||||
narrow the query window before scanning the logs table:
|
||||
1. Returns the matching log (narrow window, single bucket).
|
||||
2. Does not return duplicate logs when the query window should span multiple
|
||||
exponential buckets (>1 h). But is clamped to the timerange of trace.
|
||||
3. Returns no results when the query window does not contain the trace.
|
||||
4. Logs carrying a trace_id whose trace is NOT in trace_summary (e.g.
|
||||
traces disabled) are still returned — the lookup miss must not
|
||||
short-circuit logs queries.
|
||||
"""
|
||||
target_trace_id = TraceIdGenerator.trace_id()
|
||||
orphan_trace_id = TraceIdGenerator.trace_id()
|
||||
target_root_span_id = TraceIdGenerator.span_id()
|
||||
target_child_span_id = TraceIdGenerator.span_id()
|
||||
orphan_span_id = TraceIdGenerator.span_id()
|
||||
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
common_resources = {
|
||||
"deployment.environment": "production",
|
||||
"service.name": "logs-trace-filter-service",
|
||||
"cloud.provider": "integration",
|
||||
}
|
||||
|
||||
# Populate signoz_traces.distributed_trace_summary by inserting spans for
|
||||
# the target trace_id. trace_summary records min/max of span timestamps
|
||||
# (it ignores span duration), so two spans are inserted to give the trace
|
||||
# a non-trivial recorded window of [now-10s, now-5s].
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=10),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=target_trace_id,
|
||||
span_id=target_root_span_id,
|
||||
parent_span_id="",
|
||||
name="root-span",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources=common_resources,
|
||||
attributes={},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=5),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=target_trace_id,
|
||||
span_id=target_child_span_id,
|
||||
parent_span_id=target_root_span_id,
|
||||
name="child-span",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources=common_resources,
|
||||
attributes={},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Insert logs:
|
||||
# - one with the target trace_id, at a timestamp within the trace's
|
||||
# recorded window (now-10s..now-5s, padded ±1s).
|
||||
# - one with an orphan trace_id whose trace was never ingested — used to
|
||||
# verify the lookup miss does NOT short-circuit logs queries.
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=7),
|
||||
resources=common_resources,
|
||||
attributes={"http.method": "GET"},
|
||||
body="log inside the target trace window",
|
||||
severity_text="INFO",
|
||||
trace_id=target_trace_id,
|
||||
span_id=target_root_span_id,
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
resources=common_resources,
|
||||
attributes={"http.method": "PUT"},
|
||||
body="log with a trace_id absent from trace_summary",
|
||||
severity_text="INFO",
|
||||
trace_id=orphan_trace_id,
|
||||
span_id=orphan_span_id,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
def _query(start_ms: int, end_ms: int, trace_id: str) -> tuple[list, list[str]]:
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type="raw",
|
||||
queries=[
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"disabled": False,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"filter": {"expression": f"trace_id = '{trace_id}'"},
|
||||
"order": [
|
||||
{"key": {"name": "timestamp"}, "direction": "desc"},
|
||||
{"key": {"name": "id"}, "direction": "desc"},
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
rows = response.json()["data"]["data"]["results"][0]["rows"] or []
|
||||
warning = (response.json().get("data") or {}).get("warning") or {}
|
||||
messages = [w.get("message", "") for w in (warning.get("warnings") or [])]
|
||||
return rows, messages
|
||||
|
||||
outside_range_msg = "lies outside the selected time range"
|
||||
|
||||
now_ms = int(now.timestamp() * 1000)
|
||||
|
||||
# --- Test 1: narrow window (single bucket, <1 h) ---
|
||||
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
narrow_rows, narrow_warnings = _query(narrow_start_ms, now_ms, target_trace_id)
|
||||
|
||||
assert len(narrow_rows) == 1, f"Expected 1 log for trace_id filter (narrow window), got {len(narrow_rows)}"
|
||||
assert narrow_rows[0]["data"]["trace_id"] == target_trace_id
|
||||
assert narrow_rows[0]["data"]["span_id"] == target_root_span_id
|
||||
assert not any(outside_range_msg in m for m in narrow_warnings), f"Did not expect outside-range warning, got {narrow_warnings}"
|
||||
|
||||
# --- Test 2: wide window (>1 h, clamp to the timerange from trace_summary) ---
|
||||
# Should still return exactly one log — no duplicates from multi-bucket scan.
|
||||
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
|
||||
wide_rows, wide_warnings = _query(wide_start_ms, now_ms, target_trace_id)
|
||||
|
||||
assert len(wide_rows) == 1, f"Expected 1 log for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-log regression"
|
||||
assert wide_rows[0]["data"]["trace_id"] == target_trace_id
|
||||
assert wide_rows[0]["data"]["span_id"] == target_root_span_id
|
||||
assert not any(outside_range_msg in m for m in wide_warnings), f"Did not expect outside-range warning, got {wide_warnings}"
|
||||
|
||||
# --- Test 3: window that does not contain the trace returns no results + warning ---
|
||||
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
|
||||
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
|
||||
past_rows, past_warnings = _query(past_start_ms, past_end_ms, target_trace_id)
|
||||
|
||||
assert len(past_rows) == 0, f"Expected 0 logs for trace_id filter outside time window, got {len(past_rows)}"
|
||||
assert any(outside_range_msg in m for m in past_warnings), f"Expected outside-range warning, got warnings={past_warnings}"
|
||||
|
||||
# --- Test 4: trace_id not present in trace_summary still returns logs (no warning) ---
|
||||
orphan_rows, orphan_warnings = _query(narrow_start_ms, now_ms, orphan_trace_id)
|
||||
|
||||
assert len(orphan_rows) == 1, f"Expected 1 log for orphan trace_id (no trace_summary entry), got {len(orphan_rows)} — logs query may have been incorrectly short-circuited"
|
||||
assert orphan_rows[0]["data"]["trace_id"] == orphan_trace_id
|
||||
assert not any(outside_range_msg in m for m in orphan_warnings), f"Did not expect outside-range warning for orphan trace_id, got {orphan_warnings}"
|
||||
|
||||
|
||||
def test_logs_aggregation_filter_by_trace_id(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Tests that the trace_id time-range optimization also applies to
|
||||
non-window-list (time_series / aggregation) logs queries:
|
||||
1. Wide query window containing the trace returns the correct count.
|
||||
2. Query window outside the trace's time range short-circuits to an
|
||||
empty result.
|
||||
3. A trace_id with no row in trace_summary (e.g. traces disabled) still
|
||||
returns the matching logs — the lookup miss must not short-circuit
|
||||
logs aggregation queries.
|
||||
"""
|
||||
target_trace_id = TraceIdGenerator.trace_id()
|
||||
orphan_trace_id = TraceIdGenerator.trace_id()
|
||||
target_root_span_id = TraceIdGenerator.span_id()
|
||||
target_child_span_id = TraceIdGenerator.span_id()
|
||||
orphan_span_id = TraceIdGenerator.span_id()
|
||||
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
common_resources = {
|
||||
"deployment.environment": "production",
|
||||
"service.name": "logs-trace-agg-service",
|
||||
"cloud.provider": "integration",
|
||||
}
|
||||
|
||||
# trace_summary records min/max of span timestamps (it ignores duration),
|
||||
# so insert two spans to give the trace a recorded window wide enough to
|
||||
# comfortably contain the log timestamps below.
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=10),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=target_trace_id,
|
||||
span_id=target_root_span_id,
|
||||
parent_span_id="",
|
||||
name="root-span",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources=common_resources,
|
||||
attributes={},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=5),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=target_trace_id,
|
||||
span_id=target_child_span_id,
|
||||
parent_span_id=target_root_span_id,
|
||||
name="child-span",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources=common_resources,
|
||||
attributes={},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Two logs for the target trace_id, both inside the recorded trace window.
|
||||
# One additional log carries an orphan trace_id with no row in
|
||||
# trace_summary — used to verify that the lookup miss does not
|
||||
# short-circuit logs aggregations.
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=7),
|
||||
resources=common_resources,
|
||||
attributes={},
|
||||
body="log A inside trace window",
|
||||
severity_text="INFO",
|
||||
trace_id=target_trace_id,
|
||||
span_id=target_root_span_id,
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=6),
|
||||
resources=common_resources,
|
||||
attributes={},
|
||||
body="log B inside trace window",
|
||||
severity_text="INFO",
|
||||
trace_id=target_trace_id,
|
||||
span_id=target_root_span_id,
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
resources=common_resources,
|
||||
attributes={},
|
||||
body="log with a trace_id absent from trace_summary",
|
||||
severity_text="INFO",
|
||||
trace_id=orphan_trace_id,
|
||||
span_id=orphan_span_id,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
def _count(start_ms: int, end_ms: int, trace_id: str) -> tuple[float, list[str]]:
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type="time_series",
|
||||
queries=[
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"stepInterval": 60,
|
||||
"disabled": False,
|
||||
"filter": {"expression": f"trace_id = '{trace_id}'"},
|
||||
"having": {"expression": ""},
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
warning = (response.json().get("data") or {}).get("warning") or {}
|
||||
messages = [w.get("message", "") for w in (warning.get("warnings") or [])]
|
||||
aggregations = results[0].get("aggregations") or []
|
||||
if not aggregations:
|
||||
return 0, messages
|
||||
series = aggregations[0].get("series") or []
|
||||
if not series:
|
||||
return 0, messages
|
||||
return sum(v["value"] for v in series[0]["values"]), messages
|
||||
|
||||
outside_range_msg = "lies outside the selected time range"
|
||||
|
||||
now_ms = int(now.timestamp() * 1000)
|
||||
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
|
||||
# --- Test 1: wide window (>1 h) containing the trace returns 2 logs ---
|
||||
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
|
||||
wide_count, wide_warnings = _count(wide_start_ms, now_ms, target_trace_id)
|
||||
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
|
||||
assert not any(outside_range_msg in m for m in wide_warnings), f"Did not expect outside-range warning, got {wide_warnings}"
|
||||
|
||||
# --- Test 2: window outside the trace short-circuits to empty + warning ---
|
||||
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
|
||||
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
|
||||
past_count, past_warnings = _count(past_start_ms, past_end_ms, target_trace_id)
|
||||
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
|
||||
assert any(outside_range_msg in m for m in past_warnings), f"Expected outside-range warning, got warnings={past_warnings}"
|
||||
|
||||
# --- Test 3: trace_id not present in trace_summary still returns logs (no warning) ---
|
||||
orphan_count, orphan_warnings = _count(narrow_start_ms, now_ms, orphan_trace_id)
|
||||
assert orphan_count == 1, f"Expected count=1 for orphan trace_id aggregation, got {orphan_count} — query may have been incorrectly short-circuited"
|
||||
assert not any(outside_range_msg in m for m in orphan_warnings), f"Did not expect outside-range warning for orphan trace_id, got {orphan_warnings}"
|
||||
|
||||
@@ -2062,7 +2062,7 @@ def test_traces_list_filter_by_trace_id(
|
||||
|
||||
trace_filter = f"trace_id = '{target_trace_id}'"
|
||||
|
||||
def _query(start_ms: int, end_ms: int) -> tuple[list, list[str]]:
|
||||
def _query(start_ms: int, end_ms: int) -> list:
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
@@ -2096,157 +2096,30 @@ def test_traces_list_filter_by_trace_id(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
rows = response.json()["data"]["data"]["results"][0]["rows"] or []
|
||||
warning = (response.json().get("data") or {}).get("warning") or {}
|
||||
messages = [w.get("message", "") for w in (warning.get("warnings") or [])]
|
||||
return rows, messages
|
||||
|
||||
outside_range_msg = "lies outside the selected time range"
|
||||
return response.json()["data"]["data"]["results"][0]["rows"] or []
|
||||
|
||||
now_ms = int(now.timestamp() * 1000)
|
||||
|
||||
# --- Test 1: narrow window (single bucket, <1 h) ---
|
||||
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
narrow_rows, narrow_warnings = _query(narrow_start_ms, now_ms)
|
||||
narrow_rows = _query(narrow_start_ms, now_ms)
|
||||
|
||||
assert len(narrow_rows) == 1, f"Expected 1 span for trace_id filter (narrow window), got {len(narrow_rows)}"
|
||||
assert narrow_rows[0]["data"]["span_id"] == span_id_root
|
||||
assert narrow_rows[0]["data"]["trace_id"] == target_trace_id
|
||||
assert not any(outside_range_msg in m for m in narrow_warnings), f"Did not expect outside-range warning, got {narrow_warnings}"
|
||||
|
||||
# --- Test 2: wide window (>1 h, triggers multiple exponential buckets) ---
|
||||
# should just return 1 span, not duplicate
|
||||
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
|
||||
wide_rows, wide_warnings = _query(wide_start_ms, now_ms)
|
||||
wide_rows = _query(wide_start_ms, now_ms)
|
||||
|
||||
assert len(wide_rows) == 1, f"Expected 1 span for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-span regression"
|
||||
assert wide_rows[0]["data"]["span_id"] == span_id_root
|
||||
assert wide_rows[0]["data"]["trace_id"] == target_trace_id
|
||||
assert not any(outside_range_msg in m for m in wide_warnings), f"Did not expect outside-range warning, got {wide_warnings}"
|
||||
|
||||
# --- Test 3: window that does not contain the trace returns no results + warning ---
|
||||
# --- Test 3: window that does not contain the trace returns no results ---
|
||||
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
|
||||
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
|
||||
past_rows, past_warnings = _query(past_start_ms, past_end_ms)
|
||||
past_rows = _query(past_start_ms, past_end_ms)
|
||||
|
||||
assert len(past_rows) == 0, f"Expected 0 spans for trace_id filter outside time window, got {len(past_rows)}"
|
||||
assert any(outside_range_msg in m for m in past_warnings), f"Expected outside-range warning, got warnings={past_warnings}"
|
||||
|
||||
|
||||
def test_traces_aggregation_filter_by_trace_id(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Tests that the trace_id time-range optimization also applies to
|
||||
non-window-list (time_series / aggregation) traces queries:
|
||||
1. Wide query window containing the trace returns the correct count.
|
||||
2. Query window outside the trace's time range short-circuits to empty.
|
||||
3. Filter referencing a trace_id with no row in trace_summary
|
||||
short-circuits to empty (trace_summary is authoritative for traces).
|
||||
"""
|
||||
target_trace_id = TraceIdGenerator.trace_id()
|
||||
target_root_span_id = TraceIdGenerator.span_id()
|
||||
target_child_span_id = TraceIdGenerator.span_id()
|
||||
missing_trace_id = TraceIdGenerator.trace_id()
|
||||
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
common_resources = {
|
||||
"deployment.environment": "production",
|
||||
"service.name": "traces-agg-filter-service",
|
||||
"cloud.provider": "integration",
|
||||
}
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=10),
|
||||
duration=timedelta(seconds=5),
|
||||
trace_id=target_trace_id,
|
||||
span_id=target_root_span_id,
|
||||
parent_span_id="",
|
||||
name="root-span",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources=common_resources,
|
||||
attributes={"http.request.method": "GET"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=9),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=target_trace_id,
|
||||
span_id=target_child_span_id,
|
||||
parent_span_id=target_root_span_id,
|
||||
name="child-span",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources=common_resources,
|
||||
attributes={},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
def _count(start_ms: int, end_ms: int, trace_id: str) -> tuple[float, list[str]]:
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type="time_series",
|
||||
queries=[
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"stepInterval": 60,
|
||||
"disabled": False,
|
||||
"filter": {"expression": f"trace_id = '{trace_id}'"},
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
warning = (response.json().get("data") or {}).get("warning") or {}
|
||||
messages = [w.get("message", "") for w in (warning.get("warnings") or [])]
|
||||
aggregations = results[0].get("aggregations") or []
|
||||
if not aggregations:
|
||||
return 0, messages
|
||||
series = aggregations[0].get("series") or []
|
||||
if not series:
|
||||
return 0, messages
|
||||
return sum(v["value"] for v in series[0]["values"]), messages
|
||||
|
||||
outside_range_msg = "lies outside the selected time range"
|
||||
|
||||
now_ms = int(now.timestamp() * 1000)
|
||||
|
||||
# --- Test 1: wide window (>1 h) containing the trace returns both spans ---
|
||||
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
|
||||
wide_count, wide_warnings = _count(wide_start_ms, now_ms, target_trace_id)
|
||||
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
|
||||
assert not any(outside_range_msg in m for m in wide_warnings), f"Did not expect outside-range warning, got {wide_warnings}"
|
||||
|
||||
# --- Test 2: window outside the trace short-circuits to empty + warning ---
|
||||
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
|
||||
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
|
||||
past_count, past_warnings = _count(past_start_ms, past_end_ms, target_trace_id)
|
||||
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
|
||||
assert any(outside_range_msg in m for m in past_warnings), f"Expected outside-range warning, got warnings={past_warnings}"
|
||||
|
||||
# --- Test 3: trace_id with no entry in trace_summary short-circuits (no warning) ---
|
||||
missing_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
missing_count, missing_warnings = _count(missing_start_ms, now_ms, missing_trace_id)
|
||||
assert missing_count == 0, f"Expected count=0 for trace_id absent from trace_summary, got {missing_count}"
|
||||
assert not any(outside_range_msg in m for m in missing_warnings), f"Did not expect outside-range warning for missing trace_id, got {missing_warnings}"
|
||||
|
||||
Reference in New Issue
Block a user