Compare commits

...

20 Commits

Author SHA1 Message Date
Piyush Singariya
dafa81f3b4 Merge branch 'main' into traceop-returnspansfrom 2026-05-12 21:03:16 +05:30
Piyush Singariya
a992a13f56 revert: unused test 2026-05-12 20:58:17 +05:30
Piyush Singariya
79b36abbd7 chore: comments and test 2026-05-12 20:57:00 +05:30
Aditya Singh
7f6bdcbb8c Feat/trace details pending (#11170)
* fix: style fix

* fix: update color

* feat: bg color for selected and hover spans

* feat: remove unnecessary props

* feat: minor comment added

* feat: add test cases for flamegraph

* feat: add test utils

* feat: waterfall init

* feat: decouple waterfall left (span tree) and right (timeline bars) panels

Split the waterfall into two independent panels with a shared virtualizer
so deeply nested span names are visible via horizontal scroll in the left
panel. Left panel uses useReactTable + <table> for future column
extensibility; right panel uses plain divs for timeline bars. A draggable
resize handle separates the two panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add TimelineV3 ruler to waterfall header with padding fix

Add the TimelineV3 component to the sticky header of the waterfall's
right panel so timeline tick marks are visible. Add horizontal padding
to both the timeline header and span duration bars to prevent label
overflow/clipping at the edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: match span style

* feat: fix hover option overflow

* feat: span hover popover sync

* feat: row based flamegraph

* feat: subtree segregated tree

* feat: subtree segregated tree

* feat: subtree segregated tree

* feat: move to service worker

* feat: connector line ux

* feat: event dots in trace details

* feat: waterfall resizable

* feat: span details init

* feat: span details header

* feat: details field component

* feat: added span percentile

* feat: key attr section added

* feat: added pretty view

* feat: update yarn lock

* feat: minor change

* feat: search in pretty view

* feat: refactor

* feat: style fix

* feat: json viewer with select dropdown added

* feat: span details floating drawer added

* feat: span details folder rename

* feat: replace draggable package

* feat: fix pinning. fix drag on top

* feat: add bound to drags while floating

* feat: add collapsible sections in trace details

* feat: use resizable for waterfall table as well

* feat: copy link change and url clear on span close

* feat: fix span details headr

* feat: key value label style fixes

* feat: linked spans

* feat: style fixes

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* feat: api integration

* feat: add limit

* feat: minor change

* feat: supress click

* chore: generate openapi spec for v3 waterfall

* feat: fix test

* feat: fix test

* feat: lint fix

* feat: span details ux

* feat: analytics

* feat: add icons

* feat: added loading to flamegraph and timeout to webworker

* feat: sync error and loading state for flamegraph for n/w and computation logic

* feat: auto scroll horizontally to span

* feat: show total span count

* feat: disable anaytics span tab for now

* feat: add span details loader

* feat: prevent api call on closing span detail

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* feat: make filter and search work with flamegraph

* feat: filter ui fix

* feat: remove trace header

* feat: new filter ui

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* feat: api integration

* feat: automatically scroll left on vertical scroll

* feat: reduce time

* feat: set limit to 100k for flamegraph

* feat: show child count in waterfall

* fix: align timeline and span length in flamegraph and waterfall

* feat: fix flamegraph and waterfall bg color

* feat: show caution on sampled flamegraph

* feat: api integration v3

* feat: disable scroll to view for collapse and uncollapse

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* refactor: break down GetWaterfall method for readability

* chore: avoid returning nil, nil

* refactor: move type creation and constants to types package

- Move DB/table/cache/windowing constants to tracedetailtypes package
- Add NewWaterfallTrace and NewWaterfallResponse constructors in types
- Use constructors in module.go instead of inline struct literals
- Reorder waterfall.go so public functions precede private ones

* refactor: extract ClickHouse queries into a store abstraction

Move GetTraceSummary and GetTraceSpans out of module.go into a
traceStore interface backed by clickhouseTraceStore in store.go.
The module struct now holds a traceStore instead of a raw
telemetrystore.TelemetryStore, keeping DB access separate from
business logic.

* refactor: move error to types as well

* refactor: separate out store calls and computations

* refactor: breakdown GetSelectedSpans for readability

* refactor: return 404 on missing trace and other cleanup

* refactor: use same method for cache key creation

* chore: remove unused duration nano field

* chore: use sqlbuilder in clickhouse store where possible

* feat: dropdown added to span details

* feat: fix color duplications

* feat: no data screen

* feat: old trace btn added

* feat: minor fix

* feat: rename copy to copy value

* feat: delete unused file

* feat: use semantic tokens

* feat: use semantic tokens

* feat: add crosshair

* feat: fix test

* feat: disable crosshair in waterfall

* feat: fix colors

* feat: minor fix

* feat: add status codes

* feat: load all spans in waterfall under limit

* feat: uncollapse spans on select from flamegraph

* feat: style fix

* feat: add service name

* feat: open in new tab

* feat: add trace details header

* feat: add trace details header styles

* feat: add trace details header styles

* feat: minor changes

* feat: floating fields set

* feat: filters init

* feat: filter toggle added

* feat: fix color

* fix: scroll to span in frontend mode

* feat: delete waterfall go

* feat: minor change

* feat: minor change

* feat: lint fix

* feat: analytics spans

* feat: color by field

* feat: save color by pref in user pref

* feat: migrate v2 pinned attr

* feat: preview fields

* feat: minor refactors

* feat: minor refactors

* feat: v3 behind feature flag

* feat: minor refactors

* feat: packages remove

* feat: packages remove

* feat: remove common component

* feat: remove antd component usage

* feat: leaf node indent fix

* feat: fix mouse wheel in json view

* feat: update signoz ui

* feat: remove feature flag

* feat: fixed the waterfall span hover card

* feat: fix hidden filters

* feat: trace details always visible

* feat: correct status code

* fix: pagination calls in waterfall

* feat: fix failing test

* feat: show error count

* feat: fix waterfall child sibling indent

* feat: change how we show span hover data in waterfall

* feat: fix logs in span details styles

* feat: minor fixes

* feat: make trace id copyable

* feat: add status message to highlight section

* feat: persist user choosing old view

* feat: add more fields in color by

* feat: add llm as fast filter

* feat: show api error correctly

* feat: update test cases

* feat: revert route change

* feat: replace antd btns

* feat: allow removing all fields in preview

* feat: additional check

* feat: minor fix

* feat: minor fix

* feat: dont use antd button and tooltip

* feat: dont use antd button and tooltip

* feat: update icons

* feat: minor change

* feat: minor change

* feat: minor fix

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Nikhil Soni <nikhil.soni@signoz.io>
2026-05-12 15:13:39 +00:00
Piyush Singariya
181c307d1a Merge branch 'main' into traceop-returnspansfrom 2026-05-12 18:14:09 +05:30
Piyush Singariya
becdd4d3b4 revert: build list query 2026-05-12 18:11:35 +05:30
Piyush Singariya
de0311201a revert: double select 2026-05-12 17:15:41 +05:30
Piyush Singariya
1804bfe802 fix: return spans from 2026-05-12 16:53:31 +05:30
Piyush Singariya
357444c94e Merge branch 'main' into traceop 2026-05-11 20:53:51 +05:30
Piyush Singariya
a8598f3bfa fix: alias all core columns 2026-05-11 20:53:09 +05:30
Piyush Singariya
bca71f9a33 chore: remove comments 2026-05-11 16:04:32 +05:30
Piyush Singariya
c93660357d chore: fmt python 2026-05-11 16:02:18 +05:30
Piyush Singariya
5651e3b7a8 Merge branch 'main' into traceop 2026-05-11 14:28:58 +05:30
Piyush Singariya
cf2cfbc7d4 fix: remove specific of timestamp 2026-05-11 14:27:01 +05:30
Piyush Singariya
a969c38224 chore: fmtlint 2026-05-07 13:53:12 +05:30
Piyush Singariya
b892a0f0a5 chore: file rename 2026-05-07 13:51:22 +05:30
Piyush Singariya
4d47762eba chore: separate e2e test file 2026-05-07 13:50:11 +05:30
Piyush Singariya
77396a0bb3 Merge branch 'main' into traceop 2026-05-07 12:56:59 +05:30
Piyush Singariya
28c05e1bab Merge branch 'main' into traceop 2026-05-04 14:27:19 +05:30
Piyush Singariya
2b9e383994 fix: trace raw export e2e 2026-04-30 15:25:43 +05:30
68 changed files with 3886 additions and 730 deletions

View File

@@ -29,6 +29,18 @@ if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function (): void {};
}
if (typeof window.IntersectionObserver === 'undefined') {
class IntersectionObserverMock {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
}
(window as any).IntersectionObserver = IntersectionObserverMock;
}
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),

View File

@@ -40,6 +40,7 @@ const getTraceV3 = async (
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
...span,
'service.name': span.resource?.['service.name'] || '',
timestamp: span.time_unix,
}));
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),

View File

@@ -44,7 +44,11 @@ function HttpStatusBadge({
const color = getStatusCodeColor(numericStatusCode);
return <Badge color={color}>{statusCode}</Badge>;
return (
<Badge color={color} variant="outline">
{statusCode}
</Badge>
);
}
export default HttpStatusBadge;

View File

@@ -38,6 +38,7 @@ export enum LOCALSTORAGE {
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
}

View File

@@ -3,5 +3,7 @@ export const USER_PREFERENCES = {
NAV_SHORTCUTS: 'nav_shortcuts',
LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version',
SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
SPAN_DETAILS_PREVIEW_ATTRIBUTES: 'span_details_preview_attributes',
SPAN_DETAILS_COLOR_BY_ATTRIBUTE: 'span_details_color_by_attribute',
SPAN_PERCENTILE_RESOURCE_ATTRIBUTES: 'span_percentile_resource_attributes',
};

View File

@@ -218,7 +218,7 @@ function SpanLogs({
<Virtuoso
className="span-logs-virtuoso"
key="span-logs-virtuoso"
style={logs.length <= 35 ? { height: `calc(${logs.length} * 22px)` } : {}}
style={{ height: '100%' }}
data={logs}
totalCount={logs.length}
itemContent={getItemContent}

View File

@@ -1,12 +1,15 @@
.span-logs {
margin-inline: 16px;
height: calc(100% - 64px - 55px - 56px);
height: 100%;
display: flex;
flex-direction: column;
&-virtuoso {
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
}
&-list-container {
height: 100%;
flex: 1;
min-height: 0;
.logs-loading-skeleton {
height: 100%;

View File

@@ -68,6 +68,10 @@
border-left: unset;
border-radius: 0px 4px 4px 0px;
}
.new-view-btn {
margin-left: 8px;
}
}
.second-row {

View File

@@ -1,8 +1,12 @@
import { useMemo } from 'react';
import { Button, Skeleton, Tooltip } from 'antd';
import { useLocation, useRouteMatch } from 'react-router-dom';
import { Skeleton, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import removeLocalStorageKey from 'api/browser/localstorage/remove';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import history from 'lib/history';
@@ -60,18 +64,51 @@ function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
}
};
const isOnOldRoute = !!useRouteMatch({
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
});
const location = useLocation();
const handleSwitchToNewView = (): void => {
removeLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW);
history.replace({
pathname: `/trace/${traceID}`,
search: location.search,
hash: location.hash,
state: location.state,
});
};
return (
<div className="trace-metadata">
<section className="metadata-info">
<div className="first-row">
<Button className="previous-btn" onClick={handlePreviousBtnClick}>
<ArrowLeft size={14} />
</Button>
<Button
variant="solid"
color="secondary"
size="icon"
className="previous-btn"
prefix={<ArrowLeft size={14} />}
onClick={handlePreviousBtnClick}
/>
<div className="trace-name">
<DraftingCompass size={14} className="drafting" />
<Typography.Text className="trace-id">Trace ID</Typography.Text>
</div>
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
{isOnOldRoute && (
<Button
variant="solid"
color="primary"
size="md"
className="new-view-btn"
onClick={handleSwitchToNewView}
>
New view
</Button>
)}
</div>
{isDataLoading && (

View File

@@ -1,14 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import setLocalStorageApi from 'api/browser/localstorage/set';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import { useCallback, useMemo } from 'react';
import { LOCALSTORAGE } from 'constants/localStorage';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import { useLocalStorage } from '../useLocalStorage';
@@ -19,59 +10,25 @@ interface UsePinnedAttributesReturn {
}
/**
* Hook for managing pinned span attributes with backend persistence.
* Falls back to localStorage during initial load and handles migration.
* V2 trace-details pinned-attributes hook. localStorage-only.
*
* @param availableAttributes - Object keys of the current span's flattened attributes
* @returns Object with pinned state, toggle function, and check function
* NOTE: V2 used to also persist to the user-pref API
* (`span_details_pinned_attributes`) but V3 now owns that key with a different
* (nested-path) format. V2 is isolated to localStorage so it doesn't fight V3
* over the same backend value. See `useMigratePinnedAttributes` in V3.
*/
export function usePinnedAttributes(
availableAttributes: string[],
): UsePinnedAttributesReturn {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { notifications } = useNotifications();
// API mutation for updating preferences
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
{
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
// Local state for optimistic updates
const [pinnedKeys, setPinnedKeys] = useState<string[]>([]);
// Get localStorage fallback for initial load
const [localStoragePinnedKeys] = useLocalStorage<string[]>(
const [pinnedKeys, setPinnedKeys] = useLocalStorage<string[]>(
LOCALSTORAGE.SPAN_DETAILS_PINNED_ATTRIBUTES,
[],
);
// Initialize from user preferences when loaded
useEffect(() => {
if (userPreferences !== null) {
const preference = userPreferences.find(
(pref) => pref.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
);
if (preference?.value) {
// use backend data
setPinnedKeys(preference.value as string[]);
} else if (localStoragePinnedKeys.length > 0) {
// use local storage data
setPinnedKeys(localStoragePinnedKeys);
}
}
}, [userPreferences, localStoragePinnedKeys]);
// Create pinned attributes state from stored keys, filtering by available attributes
const pinnedAttributes = useMemo(
(): Record<string, boolean> =>
pinnedKeys.reduce(
(acc, key) => {
// Only include if the attribute exists in the current span
if (availableAttributes.includes(key)) {
acc[key] = true;
}
@@ -82,38 +39,17 @@ export function usePinnedAttributes(
[pinnedKeys, availableAttributes],
);
// Toggle pin state for an attribute
const togglePin = useCallback(
(attributeKey: string): void => {
const currentlyPinned = pinnedKeys.includes(attributeKey);
const newPinnedKeys = currentlyPinned
? pinnedKeys.filter((key) => key !== attributeKey)
: [...pinnedKeys, attributeKey];
// Optimistically update local state for instant UI feedback
setPinnedKeys(newPinnedKeys);
updateUserPreferenceInContext({
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
value: newPinnedKeys,
} as UserPreference);
// Save to localStorage immediately for offline resilience
setLocalStorageApi(
LOCALSTORAGE.SPAN_DETAILS_PINNED_ATTRIBUTES,
JSON.stringify(newPinnedKeys),
setPinnedKeys((prev) =>
prev.includes(attributeKey)
? prev.filter((k) => k !== attributeKey)
: [...prev, attributeKey],
);
// Make the API call in the background
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
value: newPinnedKeys,
});
},
[pinnedKeys, updateUserPreferenceInContext, updateUserPreferenceMutation],
[setPinnedKeys],
);
// Check if an attribute is pinned
const isPinned = useCallback(
(attributeKey: string): boolean => pinnedAttributes[attributeKey] === true,
[pinnedAttributes],

View File

@@ -12,8 +12,11 @@ const useGetTraceFlamegraph = (
): UseLicense =>
useQuery({
queryFn: () => getTraceFlamegraph(props),
// if any of the props changes then we need to trigger an API call as the older data will be obsolete
queryKey: [REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH, props.traceId],
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
props.traceId,
props.selectFields,
],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,

View File

@@ -15,6 +15,7 @@ const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
props.traceId,
props.selectedSpanId,
props.isSelectedSpanIDUnCollapsed,
props.aggregations,
],
enabled: !!props.traceId,
keepPreviousData: true,

View File

@@ -1,5 +1,25 @@
import { Redirect, useParams } from 'react-router-dom';
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import TraceDetailsV3 from '../TraceDetailsV3';
export default function TraceDetailV3Page(): JSX.Element {
const { id } = useParams<TraceDetailV2URLProps>();
const preferOld =
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true';
if (preferOld) {
return (
<Redirect
to={{
pathname: `/trace-old/${id}`,
search: window.location.search,
}}
/>
);
}
return <TraceDetailsV3 />;
}

View File

@@ -10,16 +10,14 @@ import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import { useTraceContext } from '../../contexts/TraceContext';
import { AGGREGATIONS } from '../../utils/aggregations';
import './AnalyticsPanel.styles.scss';
interface AnalyticsPanelProps {
isOpen: boolean;
onClose: () => void;
serviceExecTime?: Record<string, number>;
traceStartTime?: number;
traceEndTime?: number;
// TODO: Re-enable when backend provides per-service span counts
// spans?: Span[];
}
const PANEL_WIDTH = 350;
@@ -30,41 +28,46 @@ const PANEL_MARGIN_BOTTOM = 50;
function AnalyticsPanel({
isOpen,
onClose,
serviceExecTime = {},
traceStartTime = 0,
traceEndTime = 0,
}: AnalyticsPanelProps): JSX.Element | null {
const spread = traceEndTime - traceStartTime;
const { getAggregationMap } = useTraceContext();
const execTimePct = useMemo(
() => getAggregationMap(AGGREGATIONS.EXEC_TIME_PCT),
[getAggregationMap],
);
const spanCounts = useMemo(
() => getAggregationMap(AGGREGATIONS.SPAN_COUNT),
[getAggregationMap],
);
const execTimeRows = useMemo(() => {
if (spread <= 0) {
if (!execTimePct) {
return [];
}
return Object.entries(serviceExecTime)
.map(([service, duration]) => ({
service,
percentage: (duration * 100) / spread,
color: generateColor(service, themeColors.traceDetailColorsV3),
return Object.entries(execTimePct)
.map(([group, percentage]) => ({
group,
percentage,
color: generateColor(group, themeColors.traceDetailColorsV3),
}))
.sort((a, b) => b.percentage - a.percentage);
}, [serviceExecTime, spread]);
}, [execTimePct]);
// const spanCountRows = useMemo(() => {
// const counts: Record<string, number> = {};
// for (const span of spans) {
// const name = span.serviceName || 'unknown';
// counts[name] = (counts[name] || 0) + 1;
// }
// return Object.entries(counts)
// .map(([service, count]) => ({
// service,
// count,
// color: generateColor(service, themeColors.traceDetailColorsV3),
// }))
// .sort((a, b) => b.count - a.count);
// }, [spans]);
// const maxSpanCount = spanCountRows[0]?.count || 1;
const spanCountRows = useMemo(() => {
if (!spanCounts) {
return [];
}
const max = Math.max(...Object.values(spanCounts), 1);
return Object.entries(spanCounts)
.map(([group, count]) => ({
group,
count,
max,
color: generateColor(group, themeColors.traceDetailColorsV3),
}))
.sort((a, b) => b.count - a.count);
}, [spanCounts]);
if (!isOpen) {
return null;
@@ -103,11 +106,9 @@ function AnalyticsPanel({
<TabsTrigger value="exec-time" variant="secondary">
% exec time
</TabsTrigger>
{/* TODO: Enable when backend provides per-service span counts
<TabsTrigger value="spans" variant="secondary">
Spans
</TabsTrigger>
*/}
</TabsList>
<div className="analytics-panel__tabs-scroll">
@@ -116,17 +117,17 @@ function AnalyticsPanel({
{execTimeRows.map((row) => (
<>
<div
key={`${row.service}-dot`}
key={`${row.group}-dot`}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span
key={`${row.service}-name`}
key={`${row.group}-name`}
className="analytics-panel__service-name"
>
{row.service}
{row.group}
</span>
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
<div key={`${row.group}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className="analytics-panel__bar-fill"
@@ -145,28 +146,27 @@ function AnalyticsPanel({
</div>
</TabsContent>
{/* TODO: Enable when backend provides per-service span counts
<TabsContent value="spans">
<div className="analytics-panel__list">
{spanCountRows.map((row) => (
<>
<div
key={`${row.service}-dot`}
key={`${row.group}-dot`}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span
key={`${row.service}-name`}
key={`${row.group}-name`}
className="analytics-panel__service-name"
>
{row.service}
{row.group}
</span>
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
<div key={`${row.group}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className="analytics-panel__bar-fill"
style={{
width: `${(row.count / maxSpanCount) * 100}%`,
width: `${(row.count / row.max) * 100}%`,
backgroundColor: row.color,
}}
/>
@@ -179,7 +179,6 @@ function AnalyticsPanel({
))}
</div>
</TabsContent>
*/}
</div>
</TabsRoot>
</div>

View File

@@ -64,9 +64,13 @@
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
display: flex;
flex-direction: column;
[role='tabpanel'] {
padding: 0;
flex: 1;
min-height: 0;
}
}
@@ -112,7 +116,8 @@
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success-500);
background: var(--accent-forest);
flex-shrink: 0;
}
&__trace-id {
@@ -123,6 +128,17 @@
display: block;
}
&__trace-id-copy {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
height: auto;
justify-content: flex-start;
}
&__key-attributes {
display: flex;
flex-direction: column;

View File

@@ -41,7 +41,12 @@ import Events from 'container/SpanDetailsDrawer/Events/Events';
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
import dayjs from 'dayjs';
import { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
import { useMigratePinnedAttributes } from 'pages/TraceDetailsV3/hooks/useMigratePinnedAttributes';
import {
getSpanAttribute,
getSpanDisplayData,
hasInfraMetadata,
} from 'pages/TraceDetailsV3/utils';
import { DataViewer } from 'periscope/components/DataViewer';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
@@ -59,6 +64,7 @@ import {
VISIBLE_ACTIONS,
} from './constants';
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
import {
LinkedSpansPanel,
LinkedSpansToggle,
@@ -77,7 +83,6 @@ interface SpanDetailsPanelProps {
onVariantChange?: (variant: SpanDetailVariant) => void;
traceStartTime?: number;
traceEndTime?: number;
serviceExecTime?: Record<string, number>;
}
function SpanDetailsContent({
@@ -94,6 +99,17 @@ function SpanDetailsContent({
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
// One-time conversion of any V2-format value still living in the
// `span_details_pinned_attributes` user pref into V3 nested-path format.
useMigratePinnedAttributes(selectedSpan);
const { value: pinnedFieldsValue, onChange: onPinnedFieldsChange } =
useTracePinnedFields();
const spanDisplayData = useMemo(
() => getSpanDisplayData(selectedSpan),
[selectedSpan],
);
// Map span attribute actions to PrettyView actions format.
// Use the last key in fieldKeyPath (the actual attribute key), not the full display path.
const prettyViewCustomActions = useMemo(
@@ -391,12 +407,14 @@ function SpanDetailsContent({
<div className="span-details-panel__tabs-scroll">
<TabsContent value="overview">
<DataViewer
data={selectedSpan}
data={spanDisplayData}
drawerKey="trace-details"
prettyViewProps={{
showPinned: true,
actions: prettyViewCustomActions,
visibleActions: VISIBLE_ACTIONS,
pinnedFieldsValue,
onPinnedFieldsChange,
}}
/>
</TabsContent>
@@ -451,7 +469,6 @@ function SpanDetailsPanel({
onVariantChange,
traceStartTime,
traceEndTime,
serviceExecTime,
}: SpanDetailsPanelProps): JSX.Element {
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
@@ -558,9 +575,6 @@ function SpanDetailsPanel({
<AnalyticsPanel
isOpen={isAnalyticsOpen}
onClose={(): void => setIsAnalyticsOpen(false)}
serviceExecTime={serviceExecTime}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
);
@@ -594,6 +608,7 @@ function SpanDetailsPanel({
isOpen={panelState.isOpen}
className="span-details-panel"
width={PANEL_WIDTH}
minWidth={480}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
defaultPosition={{
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,

View File

@@ -0,0 +1,59 @@
import { Link, useRouteMatch } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import ROUTES from 'constants/routes';
import { SpanV3 } from 'types/api/trace/getTraceV3';
interface TraceIdFieldProps {
span: SpanV3;
}
/**
* Renders a span's trace id. When the user is already on the trace detail
* page for this trace, clicking the id copies it to the clipboard (the
* "navigate" affordance would be a no-op). Otherwise, falls back to the
* existing link to the trace detail page.
*/
export function TraceIdField({ span }: TraceIdFieldProps): JSX.Element {
const match = useRouteMatch<{ id: string }>({
path: ROUTES.TRACE_DETAIL,
exact: true,
});
const [, setCopy] = useCopyToClipboard();
const isCurrentTrace = match?.params.id === span.trace_id;
if (isCurrentTrace) {
const handleCopy = (): void => {
setCopy(span.trace_id);
toast.success('Trace ID copied to clipboard', {
position: 'top-right',
});
};
return (
<Button
variant="link"
color="secondary"
className="span-details-panel__trace-id-copy"
onClick={handleCopy}
title="Click to copy trace ID"
>
{span.trace_id}
</Button>
);
}
return (
<Link
to={{
pathname: `/trace/${span.trace_id}`,
search: window.location.search,
}}
className="span-details-panel__trace-id"
>
{span.trace_id}
</Link>
);
}

View File

@@ -1,8 +1,9 @@
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@signozhq/ui/badge';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { TraceIdField } from './TraceIdField';
interface HighlightedOption {
key: string;
label: string;
@@ -33,17 +34,7 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
key: 'traceId',
label: 'TRACE ID',
render: (span): ReactNode | null =>
span.trace_id ? (
<Link
to={{
pathname: `/trace/${span.trace_id}`,
search: window.location.search,
}}
className="span-details-panel__trace-id"
>
{span.trace_id}
</Link>
) : null,
span.trace_id ? <TraceIdField span={span} /> : null,
},
{
key: 'spanKind',
@@ -51,4 +42,12 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
render: (span): ReactNode | null =>
span.kind_string ? <Badge color="vanilla">{span.kind_string}</Badge> : null,
},
{
key: 'statusMessage',
label: 'STATUS MESSAGE',
render: (span): ReactNode | null =>
span.status_message ? (
<Badge color="vanilla">{span.status_message}</Badge>
) : null,
},
];

View File

@@ -0,0 +1,51 @@
import { useCallback, useMemo } from 'react';
import { useMutation } from 'react-query';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { isV3PinnedAttribute } from 'pages/TraceDetailsV3/utils';
import { useAppContext } from 'providers/App/App';
interface UseTracePinnedFieldsReturn {
value: string[];
onChange: (next: string[]) => void;
}
/**
* Reads/writes V3 trace-details pinned attributes from the user-preference
* `span_details_pinned_attributes` (cross-device sync). Drops legacy V2-format
* entries from the rendered set so PrettyView never tries to render an
* un-parseable path while the migration is in flight.
*
* Migration of V2 → V3 format is handled separately by
* `useMigratePinnedAttributes`.
*/
export function useTracePinnedFields(): UseTracePinnedFieldsReturn {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate } = useMutation(updateUserPreferenceAPI);
const value = useMemo<string[]>(() => {
const pref = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
);
const arr = (pref?.value as string[] | undefined) ?? [];
return arr.filter(isV3PinnedAttribute);
}, [userPreferences]);
const onChange = useCallback(
(next: string[]) => {
const existing = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
);
if (existing) {
updateUserPreferenceInContext({ ...existing, value: next });
}
mutate({
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
value: next,
});
},
[userPreferences, updateUserPreferenceInContext, mutate],
);
return { value, onChange };
}

View File

@@ -29,7 +29,7 @@ export function EventTooltipContent({
{eventName}
</div>
<div className="event-tooltip-content__time">
{toFixed(time, 2)} {timeUnitName} from start
{toFixed(time, 2)} {timeUnitName} since span start
</div>
{Object.keys(attributeMap).length > 0 && (
<>

View File

@@ -1,26 +1,23 @@
.span-hover-card-wrapper {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
// Invisible 1 px anchor mounted inside the scrollable waterfall body. Its
// `top` is updated by the parent to track the hovered row's Y; its `left`
// is the sidebar/timeline boundary so the popover always opens at the same
// X regardless of which row is hovered.
.span-hover-card-anchor {
position: absolute;
width: 1px;
pointer-events: none;
}
.span-hover-card-popover {
// Event-dot tooltip is rendered while the SpanDetailsPanel may be docked as
// a FloatingPanel (z-index 999); bump above the default tooltip z-index 50.
// Hover card may be rendered while the SpanDetailsPanel is docked as
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
--tooltip-z-index: 1000;
.ant-popover-inner {
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
}
.ant-popover-inner-content {
padding: 0;
}
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
}
// Flamegraph tooltip — rendered as a portal, uses same semantic tokens.

View File

@@ -1,16 +1,33 @@
import { memo, ReactNode, useCallback, useRef, useState } from 'react';
import { Popover } from 'antd';
import { themeColors } from 'constants/theme';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { useMemo } from 'react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
/**
* Span-level fields that the tooltip always shows (as the colored title or
* one of the status/start/duration rows). Preview rows for these keys are
* filtered out to avoid duplication.
*/
export const RESERVED_PREVIEW_KEYS: ReadonlySet<string> = new Set([
'name',
'has_error',
'timestamp',
'duration_nano',
]);
export interface SpanPreviewRow {
key: string;
value: string;
}
export interface SpanTooltipContentProps {
@@ -19,6 +36,7 @@ export interface SpanTooltipContentProps {
hasError: boolean;
relativeStartMs: number;
durationMs: number;
previewRows?: SpanPreviewRow[];
}
export function SpanTooltipContent({
@@ -27,6 +45,7 @@ export function SpanTooltipContent({
hasError,
relativeStartMs,
durationMs,
previewRows,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
@@ -37,104 +56,118 @@ export function SpanTooltipContent({
{spanName}
</div>
<div className="span-hover-card-content__row">
Status: {hasError ? 'error' : 'ok'}
status: {hasError ? 'error' : 'ok'}
</div>
<div className="span-hover-card-content__row">
Start: {toFixed(relativeStartMs, 2)} ms
start: {toFixed(relativeStartMs, 2)} ms
</div>
<div className="span-hover-card-content__row">
Duration: {toFixed(formattedDuration, 2)} {timeUnitName}
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
</div>
{previewRows && previewRows.length > 0 && (
<div className="span-hover-card-content__preview">
{previewRows.map((row) => (
<div key={row.key} className="span-hover-card-content__row">
<span className="span-hover-card-content__preview-key">{row.key}:</span>{' '}
<span className="span-hover-card-content__preview-value">
{row.value}
</span>
</div>
))}
</div>
)}
</div>
);
}
interface SpanHoverCardProps {
span: SpanV3;
traceMetadata: ITraceMetadata;
children: ReactNode;
/**
* Single hover card anchored at a fixed X (sidebar/timeline boundary). The
* Y of the anchor is derived from the hovered span's index in the list,
* so the card slides vertically in place rather than jumping with the cursor.
*
* Mount this inside the scrollable waterfall body so `anchorTop` is in
* content coordinates — Radix portals the content layer out automatically.
*/
export interface SpanHoverCardProps {
hoveredSpanId: string | null;
onOpenChange: (open: boolean) => void;
anchorLeft: number;
rowHeight: number;
spans: SpanV3[];
traceStartTime: number;
}
/**
* Lazy hover card — only mounts the expensive antd Popover when the user
* actually hovers over the element (after a short delay). During fast scrolling,
* rows mount and unmount without ever creating a Popover instance, avoiding
* expensive DOM/effect overhead from antd Tooltip/Trigger internals.
*/
const SpanHoverCard = memo(function SpanHoverCard({
span,
traceMetadata,
children,
export function SpanHoverCard({
hoveredSpanId,
onOpenChange,
anchorLeft,
rowHeight,
spans,
traceStartTime,
}: SpanHoverCardProps): JSX.Element {
const [showPopover, setShowPopover] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { previewFields, resolveSpanColor } = useTraceContext();
const handleMouseEnter = useCallback((): void => {
timerRef.current = setTimeout(() => {
setShowPopover(true);
}, 200);
}, []);
const handleMouseLeave = useCallback((): void => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
const hoverCardData = useMemo(() => {
if (!hoveredSpanId) {
return null;
}
setShowPopover(false);
}, []);
const idx = spans.findIndex((s) => s.span_id === hoveredSpanId);
if (idx === -1) {
return null;
}
const span = spans[idx];
const previewRows: SpanPreviewRow[] = previewFields
.filter((f) => !RESERVED_PREVIEW_KEYS.has(f.key))
.map((f) => {
const value = getSpanAttribute(span, f.key);
return value !== undefined && value !== ''
? { key: f.key, value: String(value) }
: null;
})
.filter((r): r is SpanPreviewRow => r !== null);
if (!showPopover) {
return (
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
<span
className="span-hover-card-wrapper"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
</span>
);
}
const durationMs = span.duration_nano / 1e6;
const relativeStartMs = span.timestamp - traceMetadata.startTime;
let color = generateColor(
span['service.name'],
themeColors.traceDetailColorsV3,
);
if (span.has_error) {
color = 'var(--bg-cherry-500)';
}
return {
anchorTop: idx * rowHeight,
tooltip: {
spanName: span.name,
color: resolveSpanColor(span),
hasError: span.has_error,
relativeStartMs: span.timestamp - traceStartTime,
durationMs: span.duration_nano / 1e6,
previewRows,
},
};
}, [
hoveredSpanId,
spans,
previewFields,
resolveSpanColor,
rowHeight,
traceStartTime,
]);
return (
<Popover
open
content={
<SpanTooltipContent
spanName={span.name}
color={color}
hasError={span.has_error}
relativeStartMs={relativeStartMs}
durationMs={durationMs}
/>
}
trigger="hover"
rootClassName="span-hover-card-popover"
autoAdjustOverflow
arrow={false}
onOpenChange={(open): void => {
if (!open) {
setShowPopover(false);
}
}}
>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<span className="span-hover-card-wrapper" onMouseLeave={handleMouseLeave}>
{children}
</span>
</Popover>
<TooltipProvider>
<Tooltip open={hoverCardData !== null} onOpenChange={onOpenChange}>
<TooltipTrigger asChild>
<div
className="span-hover-card-anchor"
style={{
top: hoverCardData?.anchorTop ?? 0,
left: anchorLeft,
height: rowHeight,
}}
/>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
sideOffset={8}
className="span-hover-card-popover"
>
{hoverCardData && <SpanTooltipContent {...hoverCardData.tooltip} />}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
export default SpanHoverCard;
}

View File

@@ -1,3 +1,8 @@
.trace-details-header-wrapper {
flex-shrink: 0;
position: relative;
}
.trace-details-header {
display: flex;
align-items: center;
@@ -16,13 +21,53 @@
&.trace-v3-filter-row {
padding: 0;
}
max-width: 850px;
flex: 1;
min-width: 0;
&:not(&--expanded) {
margin-left: auto;
}
&--expanded {
max-width: none;
flex: 1;
}
}
&__old-view-btn {
margin-left: auto;
flex-shrink: 0;
}
&__sub-header {
display: flex;
align-items: center;
gap: 16px;
padding: 4px 16px 8px;
font-size: 13px;
color: var(--l2-foreground);
}
&__sub-item {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
&__separator {
color: var(--l2-foreground);
opacity: 0.5;
}
&__entry-point-badge {
padding: 2px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
font-size: 12px;
}
&__skeleton .ant-skeleton-input {
width: 160px !important;
min-height: 20px !important;
height: 20px !important;
}
}

View File

@@ -1,15 +1,27 @@
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Skeleton } from 'antd';
import setLocalStorageKey from 'api/browser/localstorage/set';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import history from 'lib/history';
import { ArrowLeft } from '@signozhq/icons';
import { ArrowLeft, CalendarClock, Server, Timer } from '@signozhq/icons';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import { DataSource } from 'types/common/queryBuilder';
import FieldsSettings from '../components/FieldsSettings/FieldsSettings';
import { useTraceContext } from '../contexts/TraceContext';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import TraceOptionsMenu from './TraceOptionsMenu';
import './TraceDetailsHeader.styles.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
interface FilterMetadata {
startTime: number;
@@ -17,20 +29,53 @@ interface FilterMetadata {
traceId: string;
}
export interface TraceMetadataForHeader {
startTimestampMillis: number;
endTimestampMillis: number;
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
}
interface TraceDetailsHeaderProps {
filterMetadata: FilterMetadata;
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
noData?: boolean;
isDataLoaded?: boolean;
traceMetadata?: TraceMetadataForHeader;
}
const SKELETON_COUNT = 3;
function DetailsLoader(): JSX.Element {
return (
<>
{Array.from({ length: SKELETON_COUNT }).map((_, i) => (
<Skeleton.Input
// eslint-disable-next-line react/no-array-index-key
key={i}
active
size="small"
className="trace-details-header__skeleton"
/>
))}
</>
);
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
noData,
isDataLoaded,
traceMetadata,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV2URLProps>();
const [showTraceDetails, setShowTraceDetails] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const { previewFields, setPreviewFields } = useTraceContext();
const handleSwitchToOldView = useCallback((): void => {
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW, 'true');
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
history.replace(oldUrl);
}, [traceID]);
@@ -49,42 +94,127 @@ function TraceDetailsHeader({
}
}, []);
const handleToggleTraceDetails = useCallback((): void => {
setShowTraceDetails((prev) => !prev);
}, []);
const durationMs = traceMetadata
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
: 0;
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
return (
<div className="trace-details-header">
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__back-btn"
onClick={handlePreviousBtnClick}
>
<ArrowLeft size={14} />
</Button>
<KeyValueLabel
badgeKey="Trace ID"
badgeValue={traceID || ''}
maxCharacters={100}
/>
{!noData && (
<>
<div className="trace-details-header__filter">
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
<div className="trace-details-header-wrapper">
<div className="trace-details-header">
{!isFilterExpanded && (
<>
<Button
variant="solid"
color="secondary"
size="md"
className="trace-details-header__back-btn"
onClick={handlePreviousBtnClick}
>
<ArrowLeft size={14} />
</Button>
<KeyValueLabel
badgeKey="Trace ID"
badgeValue={traceID || ''}
maxCharacters={100}
/>
</div>
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Old View
</Button>
</>
</>
)}
{isDataLoaded && (
<>
<div
className={`trace-details-header__filter${
isFilterExpanded ? ' trace-details-header__filter--expanded' : ''
}`}
>
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
isExpanded={isFilterExpanded}
onExpand={(): void => setIsFilterExpanded(true)}
onCollapse={(): void => setIsFilterExpanded(false)}
/>
</div>
{!isFilterExpanded && (
<>
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Old View
</Button>
<TraceOptionsMenu
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
/>
</>
)}
</>
)}
</div>
{showTraceDetails && (
<div className="trace-details-header__sub-header">
{traceMetadata ? (
<>
<span className="trace-details-header__sub-item">
<Server size={13} />
{traceMetadata.rootServiceName}
<span className="trace-details-header__separator"></span>
<span className="trace-details-header__entry-point-badge">
{traceMetadata.rootServiceEntryPoint}
</span>
</span>
<span className="trace-details-header__sub-item">
<Timer size={13} />
{parseFloat(formattedDuration.toFixed(2))} {timeUnitName}
</span>
<span className="trace-details-header__sub-item">
<CalendarClock size={13} />
{dayjs(traceMetadata.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
</span>
{traceMetadata.rootSpanStatusCode && (
<HttpStatusBadge statusCode={traceMetadata.rootSpanStatusCode} />
)}
</>
) : (
<DetailsLoader />
)}
</div>
)}
{isPreviewFieldsOpen && (
<FloatingPanel
isOpen
width={350}
height={window.innerHeight - 100}
defaultPosition={{
x: window.innerWidth - 350 - 100,
y: 50,
}}
enableResizing={false}
>
<FieldsSettings
title="Preview fields"
fields={previewFields}
onFieldsChange={setPreviewFields}
onClose={(): void => setIsPreviewFieldsOpen(false)}
dataSource={DataSource.TRACES}
/>
</FloatingPanel>
)}
</div>
);

View File

@@ -0,0 +1,93 @@
import { useMemo } from 'react';
import type { MenuItem } from '@signozhq/ui';
import { Button } from '@signozhq/ui';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import { Ellipsis } from '@signozhq/icons';
import { useTraceContext } from '../contexts/TraceContext';
interface TraceOptionsMenuProps {
showTraceDetails: boolean;
onToggleTraceDetails: () => void;
onOpenPreviewFields: () => void;
}
function TraceOptionsMenu({
showTraceDetails,
onToggleTraceDetails,
onOpenPreviewFields,
}: TraceOptionsMenuProps): JSX.Element {
const { colorByField, setColorByField, availableColorByOptions } =
useTraceContext();
const menuItems: MenuItem[] = useMemo(() => {
const items: MenuItem[] = [
{
key: 'toggle-trace-details',
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
onClick: onToggleTraceDetails,
},
{
key: 'preview-fields',
label: 'Preview fields',
onClick: onOpenPreviewFields,
},
];
// Only show the "Colour by" submenu if there's an actual choice to make.
if (availableColorByOptions.length > 1) {
items.push({
key: 'colour-by',
label: 'Colour by',
children: [
{
type: 'group',
label: 'COLOUR BY',
children: [
{
type: 'radio-group',
value: colorByField.name,
onChange: (name: string): void => {
const next = availableColorByOptions.find(
(o) => o.field.name === name,
);
if (next) {
setColorByField(next.field);
}
},
children: availableColorByOptions.map((opt) => ({
type: 'radio',
key: opt.field.name,
label: opt.label,
value: opt.field.name,
})),
},
],
},
],
});
}
return items;
}, [
showTraceDetails,
onToggleTraceDetails,
onOpenPreviewFields,
colorByField.name,
setColorByField,
availableColorByOptions,
]);
return (
<Dropdown menu={{ items: menuItems }}>
<Button
variant="solid"
color="secondary"
size="sm"
prefix={<Ellipsis size={14} />}
/>
</Dropdown>
);
}
export default TraceOptionsMenu;

View File

@@ -48,6 +48,10 @@
color: var(--l2-foreground);
}
&__collapse-count-errors {
color: var(--destructive);
}
&__flame-collapse {
flex-shrink: 0;

View File

@@ -214,6 +214,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
hasError={tooltipContent.status === 'error'}
relativeStartMs={tooltipContent.startMs}
durationMs={tooltipContent.durationMs}
previewRows={tooltipContent.previewRows}
/>
)}
</div>,

View File

@@ -5,7 +5,13 @@ import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import useUrlQuery from 'hooks/useUrlQuery';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import { COLOR_BY_FIELDS } from '../constants';
import { useTraceContext } from '../contexts/TraceContext';
import Error from '../TraceWaterfall/TraceWaterfallStates/Error/Error';
import {
mergeTelemetryFieldKeys,
toTelemetryFieldKey,
} from '../utils/previewFields';
import { FLAMEGRAPH_SPAN_LIMIT } from './constants';
import FlamegraphCanvas from './FlamegraphCanvas';
import { useVisualLayoutWorker } from './hooks/useVisualLayoutWorker';
@@ -44,6 +50,19 @@ function TraceFlamegraph({
[history, search],
);
const { previewFields } = useTraceContext();
// Color-by fields baseline + user-picked preview fields. De-duped by `name`,
// color-by entries first so their canonical metadata wins on collision.
const flamegraphSelectFields = useMemo(
() =>
mergeTelemetryFieldKeys(
COLOR_BY_FIELDS,
previewFields.map(toTelemetryFieldKey),
),
[previewFields],
);
const {
data,
isFetching,
@@ -52,6 +71,7 @@ function TraceFlamegraph({
traceId,
// selectedSpanId: firstSpanAtFetchLevel,
limit: FLAMEGRAPH_SPAN_LIMIT,
selectFields: flamegraphSelectFields,
});
const spans = useMemo(

View File

@@ -1,15 +1,25 @@
import type React from 'react';
import React from 'react';
import { act, renderHook } from '@testing-library/react';
import { AllTheProviders } from 'tests/test-utils';
import { TraceProvider } from '../../contexts/TraceContext';
import { useFlamegraphHover } from '../hooks/useFlamegraphHover';
import type { SpanRect } from '../types';
import { MOCK_SPAN, MOCK_TRACE_METADATA } from './testUtils';
function wrapper({ children }: { children: React.ReactNode }): JSX.Element {
return (
<AllTheProviders>
<TraceProvider aggregations={undefined}>{children}</TraceProvider>
</AllTheProviders>
);
}
function createMockCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
canvas.getBoundingClientRect = jest.fn(
jest.spyOn(canvas, 'getBoundingClientRect').mockImplementation(
(): DOMRect =>
({
left: 0,
@@ -59,7 +69,9 @@ describe('useFlamegraphHover', () => {
});
it('sets hoveredSpanId and tooltipContent when hovering on span', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
act(() => {
result.current.handleHoverMouseMove({
@@ -76,7 +88,9 @@ describe('useFlamegraphHover', () => {
});
it('clears hover when moving to empty area', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
act(() => {
result.current.handleHoverMouseMove({
@@ -99,7 +113,9 @@ describe('useFlamegraphHover', () => {
});
it('clears hover on mouse leave', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
act(() => {
result.current.handleHoverMouseMove({
@@ -117,7 +133,9 @@ describe('useFlamegraphHover', () => {
});
it('suppresses click when drag distance exceeds threshold', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
act(() => {
result.current.handleMouseDownForClick({
@@ -137,7 +155,9 @@ describe('useFlamegraphHover', () => {
});
it('calls onSpanClick when clicking on span', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
act(() => {
result.current.handleClick({
@@ -150,7 +170,9 @@ describe('useFlamegraphHover', () => {
});
it('uses clientX/clientY for tooltip positioning', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
act(() => {
result.current.handleHoverMouseMove({
@@ -164,7 +186,9 @@ describe('useFlamegraphHover', () => {
});
it('does not update hover during drag', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
defaultArgs.isDraggingRef.current = true;
act(() => {

View File

@@ -1,4 +1,6 @@
import { getSpanColor } from '../utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { getFlamegraphSpanGroupValue, getSpanColor } from '../utils';
import { MOCK_SPAN } from './testUtils';
const mockGenerateColor = jest.fn();
@@ -8,6 +10,17 @@ jest.mock('lib/uPlotLib/utils/generateColor', () => ({
mockGenerateColor(key, colorMap),
}));
const SERVICE_FIELD: TelemetryFieldKey = {
name: 'service.name',
fieldContext: 'resource',
fieldDataType: 'string',
};
const HOST_FIELD: TelemetryFieldKey = {
name: 'host.name',
fieldContext: 'resource',
fieldDataType: 'string',
};
describe('Presentation / Styling Utils', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -15,16 +28,17 @@ describe('Presentation / Styling Utils', () => {
});
describe('getSpanColor', () => {
it('uses generated service color for normal span', () => {
it('uses generated colour from groupValue for normal span', () => {
mockGenerateColor.mockReturnValue('#1890ff');
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: false },
isDarkMode: false,
groupValue: 'my-bucket',
});
expect(mockGenerateColor).toHaveBeenCalledWith(
MOCK_SPAN.serviceName,
'my-bucket',
expect.any(Object),
);
expect(color).toBe('#1890ff');
@@ -36,6 +50,7 @@ describe('Presentation / Styling Utils', () => {
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: true },
isDarkMode: false,
groupValue: 'my-bucket',
});
expect(color).toBe('rgb(220, 38, 38)');
@@ -47,21 +62,50 @@ describe('Presentation / Styling Utils', () => {
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: true },
isDarkMode: true,
groupValue: 'my-bucket',
});
expect(color).toBe('rgb(239, 68, 68)');
});
});
it('passes serviceName to generateColor', () => {
getSpanColor({
span: { ...MOCK_SPAN, serviceName: 'my-service' },
isDarkMode: false,
});
expect(mockGenerateColor).toHaveBeenCalledWith(
'my-service',
expect.any(Object),
describe('getFlamegraphSpanGroupValue', () => {
it('returns resource[field.name] when present', () => {
const value = getFlamegraphSpanGroupValue(
{
serviceName: 'legacy',
resource: { 'service.name': 'svc-from-resource' },
},
SERVICE_FIELD,
);
expect(value).toBe('svc-from-resource');
});
it('falls back to top-level serviceName for service.name when resource is empty', () => {
const value = getFlamegraphSpanGroupValue(
{ serviceName: 'svc-legacy', resource: {} },
SERVICE_FIELD,
);
expect(value).toBe('svc-legacy');
});
it('returns "unknown" for non-service fields when resource is missing', () => {
const value = getFlamegraphSpanGroupValue(
{ serviceName: 'svc', resource: {} },
HOST_FIELD,
);
expect(value).toBe('unknown');
});
it('reads host.name from resource when present', () => {
const value = getFlamegraphSpanGroupValue(
{
serviceName: 'svc',
resource: { 'host.name': 'host-1' },
},
HOST_FIELD,
);
expect(value).toBe('host-1');
});
});
});

View File

@@ -6,6 +6,9 @@ export interface ConnectorLine {
childRow: number;
timestampMs: number;
serviceName: string;
// Snapshot of the child span's resource so draw-time can resolve the
// `colorByField` group value without crossing the worker boundary.
resource?: Record<string, string>;
}
export interface VisualLayout {
@@ -357,6 +360,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
childRow,
timestampMs: child.timestamp,
serviceName: child.serviceName,
resource: child.resource,
});
}
}

View File

@@ -1,7 +1,9 @@
import React, { RefObject, useCallback, useMemo, useRef } from 'react';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { ConnectorLine } from '../computeVisualLayout';
import { EventRect, SpanRect } from '../types';
@@ -10,6 +12,7 @@ import {
drawSpanBar,
FlamegraphRowMetrics,
getFlamegraphRowMetrics,
getFlamegraphSpanGroupValue,
getSpanColor,
} from '../utils';
@@ -51,6 +54,7 @@ interface DrawLevelArgs {
selectedSpanId: string | undefined;
hoveredSpanId: string;
isDarkMode: boolean;
colorByField: TelemetryFieldKey;
spanRectsArray: SpanRect[];
eventRectsArray: EventRect[];
metrics: FlamegraphRowMetrics;
@@ -71,6 +75,7 @@ function drawLevel(args: DrawLevelArgs): void {
selectedSpanId,
hoveredSpanId,
isDarkMode,
colorByField,
spanRectsArray,
eventRectsArray,
metrics,
@@ -112,7 +117,8 @@ function drawLevel(args: DrawLevelArgs): void {
// Minimum 1px width so tiny spans remain visible
width = clamp(width, 1, Infinity);
const color = getSpanColor({ span, isDarkMode });
const groupValue = getFlamegraphSpanGroupValue(span, colorByField);
const color = getSpanColor({ span, isDarkMode, groupValue });
const isDimmedByFilter =
!!isFilterActiveInLevel &&
@@ -148,6 +154,7 @@ interface DrawConnectorLinesArgs {
cssWidth: number;
viewportHeight: number;
metrics: FlamegraphRowMetrics;
colorByField: TelemetryFieldKey;
}
function drawConnectorLines(args: DrawConnectorLinesArgs): void {
@@ -160,6 +167,7 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
cssWidth,
viewportHeight,
metrics,
colorByField,
} = args;
ctx.save();
@@ -185,10 +193,11 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
continue;
}
const color = generateColor(
conn.serviceName,
themeColors.traceDetailColorsV3,
const groupValue = getFlamegraphSpanGroupValue(
{ serviceName: conn.serviceName, resource: conn.resource },
colorByField,
);
const color = generateColor(groupValue, themeColors.traceDetailColorsV3);
ctx.strokeStyle = color;
const x = clamp(xFrac * cssWidth, 0, cssWidth);
@@ -228,11 +237,10 @@ export function useFlamegraphDraw(
const eventRectsRefInternal = useRef<EventRect[]>([]);
const eventRectsRef = eventRectsRefProp ?? eventRectsRefInternal;
const { colorByField } = useTraceContext();
const filteredSpanIdsSet = useMemo(
() =>
isFilterActive && filteredSpanIds && filteredSpanIds.length > 0
? new Set(filteredSpanIds)
: null,
() => (isFilterActive && filteredSpanIds ? new Set(filteredSpanIds) : null),
[filteredSpanIds, isFilterActive],
);
@@ -285,6 +293,7 @@ export function useFlamegraphDraw(
cssWidth,
viewportHeight,
metrics,
colorByField,
});
const spanRectsArray: SpanRect[] = [];
@@ -309,6 +318,7 @@ export function useFlamegraphDraw(
selectedSpanId,
hoveredSpanId,
isDarkMode,
colorByField,
spanRectsArray,
eventRectsArray,
metrics,
@@ -335,6 +345,7 @@ export function useFlamegraphDraw(
hoveredSpanId,
hoveredEventKey,
isDarkMode,
colorByField,
filteredSpanIdsSet,
isFilterActive,
]);

View File

@@ -8,11 +8,18 @@ import {
useRef,
useState,
} from 'react';
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
import { RESERVED_PREVIEW_KEYS } from 'pages/TraceDetailsV3/SpanHoverCard/SpanHoverCard';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { EventRect, SpanRect } from '../types';
import { ITraceMetadata } from '../types';
import { getSpanColor } from '../utils';
import {
getFlamegraphServiceName,
getFlamegraphSpanGroupValue,
getSpanColor,
} from '../utils';
function getCanvasPointer(
canvas: HTMLCanvasElement,
@@ -69,6 +76,11 @@ export interface EventTooltipData {
attributeMap: Record<string, string>;
}
export interface SpanPreviewRowData {
key: string;
value: string;
}
export interface TooltipContent {
serviceName: string;
spanName: string;
@@ -78,6 +90,7 @@ export interface TooltipContent {
clientX: number;
clientY: number;
spanColor: string;
previewRows?: SpanPreviewRowData[];
event?: EventTooltipData;
}
@@ -125,6 +138,25 @@ export function useFlamegraphHover(
null,
);
const { colorByField, previewFields } = useTraceContext();
const buildPreviewRows = useCallback(
(span: FlamegraphSpan): SpanPreviewRowData[] =>
previewFields
.filter((field) => !RESERVED_PREVIEW_KEYS.has(field.key))
.map((field) => {
const value = getSpanAttribute(
{ resource: span.resource, attributes: span.attributes },
field.key,
);
return value !== undefined && value !== ''
? { key: field.key, value: String(value) }
: null;
})
.filter((r): r is SpanPreviewRowData => r !== null),
[previewFields],
);
const isZoomed =
viewStartTs !== traceMetadata.startTime ||
viewEndTs !== traceMetadata.endTime;
@@ -171,14 +203,18 @@ export function useFlamegraphHover(
setHoveredEventKey(`${span.spanId}-${event.name}-${event.timeUnixNano}`);
setHoveredSpanId(span.spanId);
setTooltipContent({
serviceName: span.serviceName || '',
serviceName: getFlamegraphServiceName(span),
spanName: span.name || 'unknown',
status: span.hasError ? 'error' : 'ok',
startMs: span.timestamp - traceMetadata.startTime,
durationMs: span.durationNano / 1e6,
clientX: e.clientX,
clientY: e.clientY,
spanColor: getSpanColor({ span, isDarkMode }),
spanColor: getSpanColor({
span,
isDarkMode,
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
}),
event: {
name: event.name,
timeOffsetMs: eventTimeMs - span.timestamp,
@@ -200,14 +236,19 @@ export function useFlamegraphHover(
setHoveredEventKey(null);
setHoveredSpanId(span.spanId);
setTooltipContent({
serviceName: span.serviceName || '',
serviceName: getFlamegraphServiceName(span),
spanName: span.name || 'unknown',
status: span.hasError ? 'error' : 'ok',
startMs: span.timestamp - traceMetadata.startTime,
durationMs: span.durationNano / 1e6,
clientX: e.clientX,
clientY: e.clientY,
spanColor: getSpanColor({ span, isDarkMode }),
spanColor: getSpanColor({
span,
isDarkMode,
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
}),
previewRows: buildPreviewRows(span),
});
updateCursor(canvas, span);
} else {
@@ -225,6 +266,8 @@ export function useFlamegraphHover(
isDraggingRef,
updateCursor,
isDarkMode,
colorByField,
buildPreviewRows,
],
);

View File

@@ -2,7 +2,9 @@
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 { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import {
DASHED_BORDER_LINE_DASH,
@@ -66,14 +68,47 @@ export function getFlamegraphRowMetrics(
};
}
/**
* Resolve the displayed service.name for a flamegraph span. Used by tooltips
* (service identity, independent of the active colour-by field). Prefers
* `resource['service.name']` with legacy top-level `serviceName` fallback.
*/
export function getFlamegraphServiceName(
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
): string {
return getSpanAttribute(span, 'service.name') || span.serviceName || '';
}
/**
* Resolve the value used to bucket a flamegraph span by colour for the given
* field. Prefers `resource[field.name]` (new contract from `selectFields`).
* For `service.name`, falls back to the legacy top-level `serviceName` when
* resource is empty (backward-compat with backends that haven't shipped
* `selectFields` yet). For other fields, falls back to `'unknown'`.
*/
export function getFlamegraphSpanGroupValue(
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
field: TelemetryFieldKey,
): string {
const fromAttribute = getSpanAttribute(span, field.name);
if (fromAttribute) {
return fromAttribute;
}
if (field.name === 'service.name') {
return span.serviceName || 'unknown';
}
return 'unknown';
}
interface GetSpanColorArgs {
span: FlamegraphSpan;
isDarkMode: boolean;
groupValue: string;
}
export function getSpanColor(args: GetSpanColorArgs): string {
const { span, isDarkMode } = args;
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
const { span, isDarkMode, groupValue } = args;
let color = generateColor(groupValue, themeColors.traceDetailColorsV3);
if (span.hasError) {
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
@@ -211,7 +246,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
// Alpha is applied to bar + events only; label is drawn after restoring alpha to 1
// so text stays readable against the faded bar.
if (shouldDim) {
ctx.globalAlpha = 0.4;
ctx.globalAlpha = 0.15;
}
ctx.beginPath();

View File

@@ -11,13 +11,9 @@ import NoData from './TraceWaterfallStates/NoData/NoData';
import Success from './TraceWaterfallStates/Success/Success';
import { getVisibleSpans } from './utils';
import './TraceWaterfall.styles.scss';
import { IInterestedSpan } from './types';
export interface IInterestedSpan {
spanId: string;
isUncollapsed: boolean;
scrollToSpan?: boolean;
}
import './TraceWaterfall.styles.scss';
interface ITraceWaterfallProps {
traceId: string;

View File

@@ -3,18 +3,114 @@
align-items: center;
gap: 12px;
&.expanded {
flex: 1;
}
.filter-search-container {
flex: 1;
min-width: 0;
}
.query-builder-search-v2 {
width: 100%;
}
// --- Collapsed pill ---
.filter-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--l1-border);
border-radius: 4px;
cursor: pointer;
max-width: 220px;
min-width: 120px;
height: 32px;
background: var(--l1-background);
&:hover {
border-color: var(--l3-border);
}
&__text {
font-size: 12px;
color: var(--l2-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
&__indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary-background);
flex-shrink: 0;
}
}
// --- Collapsed pill popover ---
.filter-pill-popover {
max-width: 400px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
&__expression {
font-family: 'Geist Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--l1-foreground);
word-break: break-all;
padding: 6px 8px;
background: var(--l2-background);
border-radius: 4px;
}
}
// --- ToggleGroup override: size to content, don't stretch items ---
[class*='toggle-group'] {
flex-shrink: 0;
[class*='toggle-group-item'] {
flex: 0 0 auto;
}
}
// --- Collapse button ---
.filter-collapse-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
// --- Highlight errors toggle ---
.highlight-errors-toggle {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
white-space: nowrap;
}
// --- Prev/next navigation ---
.pre-next-toggle {
display: flex;
flex-shrink: 0;
gap: 12px;
.ant-typography {
&__count {
display: flex;
align-items: center;
margin: auto;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
@@ -30,14 +126,20 @@
}
}
.no-results {
.filter-status {
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;
&--error {
color: var(--destructive);
cursor: help;
}
}
}

View File

@@ -1,19 +1,44 @@
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { Button, Spin, Tooltip } from 'antd';
import { useCopyToClipboard } from 'react-use';
import {
ChevronDown,
ChevronUp,
Copy,
Info,
Loader,
Search,
X,
} from '@signozhq/icons';
import { Switch, ToggleGroup, ToggleGroupItem, toast } from '@signozhq/ui';
import { Button } from '@signozhq/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import { LoaderCircle, Info, ChevronDown, ChevronUp } from '@signozhq/icons';
import {
DataSource,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { BASE_FILTER_QUERY } from './constants';
import { useHighlightErrors } from './hooks/useHighlightErrors';
import {
SpanCategory,
useSpanCategoryFilter,
} from './hooks/useSpanCategoryFilter';
import './Filters.styles.scss';
@@ -44,6 +69,7 @@ function prepareQuery(filters: TagFilter, traceID: string): Query {
},
],
},
selectColumns: [],
},
],
},
@@ -55,31 +81,87 @@ function Filters({
endTime,
traceID,
onFilteredSpansChange = (): void => {},
isExpanded,
onExpand,
onCollapse,
}: {
startTime: number;
endTime: number;
traceID: string;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
isExpanded: boolean;
onExpand: () => void;
onCollapse: () => void;
}): JSX.Element {
const [, setCopy] = useCopyToClipboard();
const [filters, setFilters] = useState<TagFilter>(
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
);
const [expression, setExpression] = useState<string>('');
const [noData, setNoData] = useState<boolean>(false);
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
const expressionRef = useRef<string>('');
const containerRef = useRef<HTMLDivElement>(null);
const handleFilterChange = useCallback(
(value: TagFilter): void => {
if (value.items.length === 0) {
const runQuery = useCallback(
(value: string): void => {
const items = convertExpressionToFilters(value);
setFilters({ items, op: 'AND' });
// Clear results when expression produces no filters
if (items.length === 0) {
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
setFilters(value);
},
[onFilteredSpansChange],
);
// onChange fires on every keystroke — only store the expression, don't trigger API
const handleExpressionChange = useCallback(
(value: string): void => {
setExpression(value);
expressionRef.current = value;
// Clear results when expression is emptied
if (!value.trim()) {
setFilters({ items: [], op: 'AND' });
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
},
[onFilteredSpansChange],
);
// onRun fires on Ctrl+Enter
const handleRunQuery = useCallback(
(value: string): void => {
runQuery(value);
},
[runQuery],
);
// Run query on blur (click outside the filter input)
const handleBlur = useCallback((): void => {
runQuery(expressionRef.current);
}, [runQuery]);
// Expression-based filter hooks
const filterProps = {
expression,
filters,
setExpression,
expressionRef,
runQuery,
};
const { isHighlightErrors, handleToggle: handleToggleHighlightErrors } =
useHighlightErrors(filterProps);
const { selectedCategory, categories, handleCategoryChange } =
useSpanCategoryFilter(filterProps);
const { search } = useLocation();
const history = useHistory();
@@ -110,14 +192,14 @@ function Filters({
tableParams: {
pagination: {
offset: 0,
limit: 200,
limit: 10000,
},
selectColumns: [
{
key: 'name',
key: 'spanID',
dataType: 'string',
type: 'tag',
id: 'name--string--tag--true',
id: 'spanId--string--tag--true',
isIndexed: false,
},
],
@@ -147,58 +229,187 @@ function Filters({
setCurrentSearchedIndex(0);
}
},
onError: () => {
const isFilterActive = filters.items.length > 0;
setNoData(false);
setFilteredSpanIds([]);
onFilteredSpansChange?.([], isFilterActive);
setCurrentSearchedIndex(0);
},
},
);
return (
<div className="trace-v3-filter-row">
<QueryBuilderSearchV2
query={{
...BASE_FILTER_QUERY,
filters,
}}
onChange={handleFilterChange}
hideSpanScopeSelector={false}
skipQueryBuilderRedirect
selectProps={{ listHeight: 125 }}
const highlightErrorsToggle = (
<div className="highlight-errors-toggle">
<Typography.Text>Highlight errors</Typography.Text>
<Switch
color="cherry"
value={isHighlightErrors}
onChange={handleToggleHighlightErrors}
/>
{filteredSpanIds.length > 0 && (
<div className="pre-next-toggle">
<Typography.Text>
{currentSearchedIndex + 1} / {filteredSpanIds.length}
</Typography.Text>
<Button
icon={<ChevronUp size={14} />}
disabled={currentSearchedIndex === 0}
type="text"
onClick={(): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}}
/>
<Button
icon={<ChevronDown size={14} />}
type="text"
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
onClick={(): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}}
/>
</div>
)}
{isFetching && (
<Spin indicator={<LoaderCircle className="animate-spin" />} size="small" />
)}
</div>
);
const statusIndicators = (
<>
{isFetching && <Loader className="animate-spin" />}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<Info size={14} />
<Tooltip>
<TooltipTrigger asChild>
<span className="filter-status filter-status--error">
<Info />
API error
</span>
</TooltipTrigger>
<TooltipContent>
{(error as AxiosError)?.message || 'Something went wrong'}
</TooltipContent>
</Tooltip>
)}
{noData && (
<Typography.Text className="no-results">No results found</Typography.Text>
{!error && noData && (
<Typography.Text className="filter-status">
No results found
</Typography.Text>
)}
</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="filter-pill" onClick={onExpand}>
<Search size={12} />
<span className="filter-pill__text">{expression || 'Search...'}</span>
{expression && <span className="filter-pill__indicator" />}
</div>
);
return (
<TooltipProvider>
<div className="trace-v3-filter-row collapsed">
{expression ? (
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<div className="filter-pill-popover">
<div className="filter-pill-popover__header">
<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="filter-pill-popover__expression">{expression}</div>
</div>
</TooltipContent>
</Tooltip>
) : (
pill
)}
{highlightErrorsToggle}
{statusIndicators}
</div>
</TooltipProvider>
);
}
// --- EXPANDED VIEW ---
return (
<TooltipProvider>
<div className="trace-v3-filter-row expanded">
<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="filter-search-container"
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="pre-next-toggle">
<Typography.Text className="pre-next-toggle__count">
{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="filter-collapse-btn"
onClick={onCollapse}
>
<X size={14} />
</Button>
{highlightErrorsToggle}
{statusIndicators}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,30 @@
import { MutableRefObject } from 'react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
/**
* Shared props for expression-based filter hooks.
* Each hook reads the current expression + derived filters,
* and manipulates the expression via remove/add pattern.
*/
export interface ExpressionFilterProps {
expression: string;
filters: TagFilter;
setExpression: (expr: string) => void;
expressionRef: MutableRefObject<string>;
runQuery: (expr: string) => void;
}
/**
* Helper: update expression state, ref, and trigger query.
*/
export function applyExpression(
newExpression: string,
props: Pick<
ExpressionFilterProps,
'setExpression' | 'expressionRef' | 'runQuery'
>,
): void {
props.setExpression(newExpression);
props.expressionRef.current = newExpression;
props.runQuery(newExpression);
}

View File

@@ -0,0 +1,45 @@
import { useCallback, useMemo } from 'react';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import { applyExpression, ExpressionFilterProps } from './types';
interface UseHighlightErrorsReturn {
isHighlightErrors: boolean;
handleToggle: (checked: boolean) => void;
}
const ERROR_KEY = 'has_error';
export function useHighlightErrors(
props: ExpressionFilterProps,
): UseHighlightErrorsReturn {
const { expression, filters, setExpression, expressionRef, runQuery } = props;
// Derive from filters (only updates after runQuery, not on every keystroke)
const isHighlightErrors = useMemo(
() =>
filters.items.some(
(item) =>
item.key?.key === ERROR_KEY &&
(item.value === true || item.value === 'true'),
),
[filters],
);
const handleToggle = useCallback(
(checked: boolean): void => {
// Always remove existing has_error first (whatever its value)
let newExpr = removeKeysFromExpression(expression, [ERROR_KEY]);
// Add back if turning ON
if (checked) {
newExpr = newExpr.trim()
? `${newExpr.trim()} AND has_error = true`
: `has_error = true`;
}
applyExpression(newExpr, { setExpression, expressionRef, runQuery });
},
[expression, setExpression, expressionRef, runQuery],
);
return { isHighlightErrors, handleToggle };
}

View File

@@ -0,0 +1,84 @@
import { useCallback, useMemo } from 'react';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import { applyExpression, ExpressionFilterProps } from './types';
export type SpanCategory =
| 'All'
| 'Database'
| 'Functions'
| 'HTTP'
| 'Jobs'
| 'LLM';
export const SPAN_CATEGORIES: readonly SpanCategory[] = [
'All',
'Database',
'Functions',
'HTTP',
'Jobs',
'LLM',
];
// Map each category to the attribute key it filters on
const CATEGORY_KEYS: Record<Exclude<SpanCategory, 'All'>, string> = {
Database: 'db.system',
HTTP: 'http.method',
Functions: 'kind_string',
Jobs: 'messaging.system',
LLM: 'gen_ai.request.model',
};
// All category keys — used for bulk removal when switching categories
const ALL_CATEGORY_KEYS = Object.values(CATEGORY_KEYS);
// The expression clause to add for each category
const CATEGORY_EXPRESSIONS: Record<Exclude<SpanCategory, 'All'>, string> = {
Database: 'db.system exists',
HTTP: 'http.method exists',
Functions: "kind_string = 'Internal'",
Jobs: 'messaging.system exists',
LLM: 'gen_ai.request.model exists',
};
interface UseSpanCategoryFilterReturn {
selectedCategory: SpanCategory;
categories: readonly SpanCategory[];
handleCategoryChange: (category: SpanCategory) => void;
}
export function useSpanCategoryFilter(
props: ExpressionFilterProps,
): UseSpanCategoryFilterReturn {
const { expression, filters, setExpression, expressionRef, runQuery } = props;
// Derive active category from filters (only updates after runQuery)
const selectedCategory = useMemo((): SpanCategory => {
for (const [category, key] of Object.entries(CATEGORY_KEYS)) {
if (filters.items.some((item) => item.key?.key === key)) {
return category as SpanCategory;
}
}
return 'All';
}, [filters]);
const handleCategoryChange = useCallback(
(category: SpanCategory): void => {
// Remove ALL category keys first
let newExpr = removeKeysFromExpression(expression, ALL_CATEGORY_KEYS);
// Add the selected category clause (unless "All")
if (category !== 'All') {
const clause = CATEGORY_EXPRESSIONS[category];
newExpr = newExpr.trim() ? `${newExpr.trim()} AND ${clause}` : clause;
}
applyExpression(newExpr, { setExpression, expressionRef, runQuery });
},
[expression, setExpression, expressionRef, runQuery],
);
return {
selectedCategory,
categories: SPAN_CATEGORIES,
handleCategoryChange,
};
}

View File

@@ -131,6 +131,24 @@
position: relative;
}
// Invisible IntersectionObserver targets pinned at the top and bottom of
// the virtualized content. See `useBoundaryPagination`.
.waterfall-load-more-sentinel {
position: absolute;
left: 0;
right: 0;
height: 1px;
pointer-events: none;
&--top {
top: 0;
}
&--bottom {
bottom: 0;
}
}
.waterfall-sidebar {
overflow-x: auto;
overflow-y: hidden;
@@ -243,7 +261,7 @@
}
&.dimmed-span {
opacity: 0.4;
opacity: 0.15;
}
}
}
@@ -315,7 +333,7 @@
.tree-connector {
position: absolute;
width: 11px;
width: 14px;
height: 50%;
border-left: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
@@ -323,6 +341,16 @@
pointer-events: none;
}
// Reserved horizontal space for the chevron — present on every row,
// filled only when the span has children. Keeps sibling icons aligned.
.tree-arrow-slot {
width: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tree-arrow {
display: inline-flex;
align-items: center;
@@ -339,6 +367,18 @@
}
}
// Reserved horizontal space for the subtree-count badge — same reason.
// Right-aligns the badge inside so single-digit counts don't push the
// icon left of where multi-digit counts would put it.
.subtree-count-slot {
min-width: 20px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: flex-end;
padding-right: 4px;
}
.subtree-count {
display: inline-flex;
align-items: center;
@@ -539,7 +579,7 @@
}
.dimmed-span {
opacity: 0.4;
opacity: 0.15;
}
.highlighted-span {
opacity: 1;

View File

@@ -27,12 +27,11 @@ import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
import cx from 'classnames';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb, generateColor } from 'lib/uPlotLib/utils/generateColor';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import {
ArrowUpRight,
ChevronDown,
@@ -41,15 +40,17 @@ import {
Link,
ListPlus,
} from '@signozhq/icons';
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
import { useCrosshair } from 'pages/TraceDetailsV3/hooks/useCrosshair';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { EventV3, SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
import SpanHoverCard from '../../../SpanHoverCard/SpanHoverCard';
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from '../../TraceWaterfall';
import { IInterestedSpan } from '../../types';
import './Success.styles.scss';
@@ -76,7 +77,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = useCallback((): void => {
timerRef.current = setTimeout(() => setShowPopover(true), 150);
timerRef.current = setTimeout(() => setShowPopover(true), 200);
}, []);
const handleMouseLeave = useCallback((): void => {
@@ -133,7 +134,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
});
// css config
const CONNECTOR_WIDTH = 20;
const CONNECTOR_WIDTH = 30;
const VERTICAL_CONNECTOR_WIDTH = 1;
interface SpanStateClasses {
@@ -194,8 +195,9 @@ const SpanOverview = memo(function SpanOverview({
selectedSpan,
filteredSpanIds,
isFilterActive,
traceMetadata,
onAddSpanToFunnel,
onHoverEnter,
onHoverLeave,
}: {
span: SpanV3;
isSpanCollapsed: boolean;
@@ -204,19 +206,15 @@ const SpanOverview = memo(function SpanOverview({
handleSpanClick: (span: SpanV3) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
traceMetadata: ITraceMetadata;
onAddSpanToFunnel: (span: SpanV3) => void;
onHoverEnter: (spanId: string) => void;
onHoverLeave: () => void;
}): JSX.Element {
const isRootSpan = span.level === 0;
const { onSpanCopy } = useCopySpanLink(span);
const { resolveSpanColor } = useTraceContext();
let color = generateColor(
span['service.name'],
themeColors.traceDetailColorsV3,
);
if (span.has_error) {
color = `var(--bg-cherry-500)`;
}
const color = resolveSpanColor(span);
// Smart highlighting logic
const {
@@ -232,6 +230,9 @@ const SpanOverview = memo(function SpanOverview({
isFilterActive,
);
// All siblings at the same level share the same indent so the "same X =
// same level" visual rule holds. Parent/child distinction is conveyed by
// the chevron and the L-connector, not by an icon-X offset.
const indentWidth = isRootSpan ? 0 : span.level * CONNECTOR_WIDTH;
const handleFunnelClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
@@ -240,126 +241,128 @@ const SpanOverview = memo(function SpanOverview({
};
return (
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className={cx('span-overview', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onClick={(): void => handleSpanClick(span)}
>
{/* Tree connector lines — always draw vertical lines at all ancestor levels + L-connector */}
{!isRootSpan &&
Array.from({ length: span.level }, (_, i) => {
const lvl = i + 1;
const xPos = (lvl - 1) * CONNECTOR_WIDTH + 9;
if (lvl < span.level) {
// Stop the line at 50% for the last child's parent level
const isLastChildParentLine =
!span.has_sibling && lvl === span.level - 1;
return (
<div
key={lvl}
className="tree-line"
style={{
left: xPos,
top: 0,
width: 1,
height: isLastChildParentLine ? '50%' : '100%',
}}
/>
);
}
<div
className={cx('span-overview', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onClick={(): void => handleSpanClick(span)}
onMouseEnter={(): void => onHoverEnter(span.span_id)}
onMouseLeave={(): void => onHoverLeave()}
>
{/* Tree connector lines — always draw vertical lines at all ancestor levels + L-connector */}
{!isRootSpan &&
Array.from({ length: span.level }, (_, i) => {
const lvl = i + 1;
const xPos = (lvl - 1) * CONNECTOR_WIDTH + 9;
if (lvl < span.level) {
// Stop the line at 50% for the last child's parent level
const isLastChildParentLine = !span.has_sibling && lvl === span.level - 1;
return (
<div key={lvl}>
<div
className="tree-line"
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
/>
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
</div>
<div
key={lvl}
className="tree-line"
style={{
left: xPos,
top: 0,
width: 1,
height: isLastChildParentLine ? '50%' : '100%',
}}
/>
);
})}
}
return (
<div key={lvl}>
<div
className="tree-line"
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
/>
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
</div>
);
})}
{/* Indent spacer */}
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
{/* Indent spacer */}
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
{/* Expand/collapse arrow + child count (only for spans with children) */}
{/* Expand/collapse arrow + child count slots — always render the
slots, fill them only when the span has children. Reserving the
horizontal space on leaf rows aligns sibling icons regardless
of whether each sibling is a parent or a leaf. */}
<span className="tree-arrow-slot">
{span.has_children && (
<>
<span
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();
handleCollapseUncollapse(span.span_id, !isSpanCollapsed);
}}
>
{isSpanCollapsed ? (
<ChevronRight size={14} />
) : (
<ChevronDown size={14} />
)}
</span>
<span className="subtree-count">
<Badge color="vanilla">{span.sub_tree_node_count}</Badge>
</span>
</>
<span
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();
handleCollapseUncollapse(span.span_id, !isSpanCollapsed);
}}
>
{isSpanCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
)}
</span>
<span className="subtree-count-slot">
{span.has_children && (
<span className="subtree-count">
<Badge color="vanilla">{span.sub_tree_node_count}</Badge>
</span>
)}
</span>
{/* Colored service dot */}
<span
className={cx('tree-icon', { 'is-error': span.has_error })}
style={{ backgroundColor: color }}
/>
{/* Colored service dot */}
<span
className={cx('tree-icon', { 'is-error': span.has_error })}
style={{ backgroundColor: color }}
/>
{/* Span name + service name */}
<span className="tree-label">
{span.name}
<span className="tree-service-name">{span['service.name']}</span>
</span>
{/* Span name + service name */}
<span className="tree-label">
{span.name}
<span className="tree-service-name">{span['service.name']}</span>
</span>
{/* Action buttons — shown on hover via CSS, right-aligned */}
<span className="span-row-actions">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={onSpanCopy}
>
<Link size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Copy Span Link
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Add to Trace Funnel
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</div>
</SpanHoverCard>
{/* Action buttons — shown on hover via CSS, right-aligned */}
<span className="span-row-actions">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={onSpanCopy}
>
<Link size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Copy Span Link
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Add to Trace Funnel
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</div>
);
});
@@ -386,16 +389,10 @@ export const SpanDuration = memo(function SpanDuration({
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
const width = (span.duration_nano * 1e2) / (spread * 1e6);
let color = generateColor(
span['service.name'],
themeColors.traceDetailColorsV3,
);
let rgbColor = colorToRgb(color);
if (span.has_error) {
color = `var(--bg-cherry-500)`;
rgbColor = '239, 68, 68';
}
const { resolveSpanColor } = useTraceContext();
const color = resolveSpanColor(span);
// `resolveSpanColor` returns a CSS variable for errors; `colorToRgb` can't parse it.
const rgbColor = span.has_error ? '239, 68, 68' : colorToRgb(color);
const {
isSelected,
@@ -420,27 +417,25 @@ export const SpanDuration = memo(function SpanDuration({
})}
onClick={(): void => handleSpanClick(span)}
>
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className="span-bar"
style={
{
left: `${leftOffset}%`,
width: `${width}%`,
'--span-color': color,
'--span-color-rgb': rgbColor,
} as React.CSSProperties
}
>
<span className="span-info">
<span className="span-name">{span.name}</span>
<span className="span-duration-text">{`${toFixed(
time,
2,
)} ${timeUnitName}`}</span>
</span>
</div>
</SpanHoverCard>
<div
className="span-bar"
style={
{
left: `${leftOffset}%`,
width: `${width}%`,
'--span-color': color,
'--span-color-rgb': rgbColor,
} as React.CSSProperties
}
>
<span className="span-info">
<span className="span-name">{span.name}</span>
<span className="span-duration-text">{`${toFixed(
time,
2,
)} ${timeUnitName}`}</span>
</span>
</div>
{span.events?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const spanDurationMs = span.duration_nano / 1e6;
@@ -506,6 +501,17 @@ function Success(props: ISuccessProps): JSX.Element {
const prevHoveredSpanIdRef = useRef<string | null>(null);
const autoScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const {
topSentinelRef: loadMoreTopSentinelRef,
bottomSentinelRef: loadMoreBottomSentinelRef,
} = useBoundaryPagination({
scrollContainerRef,
spans,
isFetching,
isFullDataLoaded,
setInterestedSpanId,
});
const {
cursorXPercent,
cursorX,
@@ -531,15 +537,32 @@ function Success(props: ISuccessProps): JSX.Element {
prevHoveredSpanIdRef.current = spanId;
}, []);
// Hover-card state — single popover anchored at the sidebar/timeline
// boundary, Y tracks the hovered row. Set after a 500 ms debounce so fast
// scrolls/cursor sweeps don't fire the card.
const [hoveredSpanId, setHoveredSpanId] = useState<string | null>(null);
const hoverDelayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleRowMouseEnter = useCallback(
(spanId: string): void => {
applyHoverClass(spanId);
if (hoverDelayTimerRef.current) {
clearTimeout(hoverDelayTimerRef.current);
}
hoverDelayTimerRef.current = setTimeout(() => {
setHoveredSpanId(spanId);
}, 500);
},
[applyHoverClass],
);
const handleRowMouseLeave = useCallback((): void => {
applyHoverClass(null);
if (hoverDelayTimerRef.current) {
clearTimeout(hoverDelayTimerRef.current);
hoverDelayTimerRef.current = null;
}
setHoveredSpanId(null);
}, [applyHoverClass]);
const handleCollapseUncollapse = useCallback(
@@ -555,14 +578,15 @@ function Success(props: ISuccessProps): JSX.Element {
}
return next;
});
} else {
// Backend mode: trigger API call (current behavior)
setInterestedSpanId({
spanId,
isUncollapsed: !collapse,
scrollToSpan: false,
});
}
// Backend mode: trigger API call (current behavior)
// keeping this for both mode to support scroll to view to function well.
// interestedspan would not make api call in frontend mode so it is safe to use for both mode.
setInterestedSpanId({
spanId,
isUncollapsed: !collapse,
scrollToSpan: false,
});
},
[isFullDataLoaded, setLocalUncollapsedNodes, setInterestedSpanId],
);
@@ -615,32 +639,8 @@ function Success(props: ISuccessProps): JSX.Element {
}
}, 20);
}
// In frontend mode all data is already loaded, no need to fetch more.
// In backend mode, skip auto-fetch when under 500 spans (nothing more to paginate).
if (isFullDataLoaded || spans.length < 500) {
return;
}
if (range?.startIndex === 0 && instance.isScrolling) {
// do not trigger for trace root as nothing to fetch above
if (spans[0].level !== 0) {
setInterestedSpanId({
spanId: spans[0].span_id,
isUncollapsed: false,
});
}
return;
}
if (range?.endIndex === spans.length - 1 && instance.isScrolling) {
setInterestedSpanId({
spanId: spans[spans.length - 1].span_id,
isUncollapsed: false,
});
}
},
[spans, setInterestedSpanId],
[spans],
);
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] =
@@ -685,10 +685,11 @@ function Success(props: ISuccessProps): JSX.Element {
}
selectedSpan={selectedSpan}
handleSpanClick={handleSpanClick}
traceMetadata={traceMetadata}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
onAddSpanToFunnel={handleAddSpanToFunnel}
onHoverEnter={handleRowMouseEnter}
onHoverLeave={handleRowMouseLeave}
/>
),
}),
@@ -698,12 +699,13 @@ function Success(props: ISuccessProps): JSX.Element {
uncollapsedNodes,
isFullDataLoaded,
localUncollapsedNodes,
traceMetadata,
selectedSpan,
handleSpanClick,
filteredSpanIds,
isFilterActive,
handleAddSpanToFunnel,
handleRowMouseEnter,
handleRowMouseLeave,
],
);
@@ -780,6 +782,12 @@ function Success(props: ISuccessProps): JSX.Element {
const virtualItems = virtualizer.getVirtualItems();
const leftRows = leftTable.getRowModel().rows;
const handleHoverCardOpenChange = useCallback((open: boolean): void => {
if (!open) {
setHoveredSpanId(null);
}
}, []);
return (
<div className="success-content">
{traceMetadata.hasMissingSpans && (
@@ -833,6 +841,24 @@ function Success(props: ISuccessProps): JSX.Element {
height: '100%',
}}
>
{/* Top / bottom sentinels: each transition into the viewport
fires a load-more via useBoundaryPagination. */}
<div
ref={loadMoreTopSentinelRef}
className="waterfall-load-more-sentinel waterfall-load-more-sentinel--top"
/>
<div
ref={loadMoreBottomSentinelRef}
className="waterfall-load-more-sentinel waterfall-load-more-sentinel--bottom"
/>
<SpanHoverCard
hoveredSpanId={hoveredSpanId}
onOpenChange={handleHoverCardOpenChange}
anchorLeft={sidebarWidth}
rowHeight={ROW_HEIGHT}
spans={spans}
traceStartTime={traceMetadata.startTime}
/>
{/* Left panel - table with horizontal scroll */}
<ResizableBox
direction="horizontal"
@@ -942,8 +968,8 @@ function Success(props: ISuccessProps): JSX.Element {
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
}}
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
onMouseLeave={handleRowMouseLeave}
onMouseEnter={(): void => applyHoverClass(span.span_id)}
onMouseLeave={(): void => applyHoverClass(null)}
>
<SpanDuration
span={span}

View File

@@ -2,8 +2,12 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { fireEvent, render, screen } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { TraceProvider } from '../../../../contexts/TraceContext';
import { SpanDuration } from '../Success';
const renderWithTraceProvider: typeof render = (ui, options) =>
render(<TraceProvider aggregations={undefined}>{ui}</TraceProvider>, options);
// Constants to avoid string duplication
const SPAN_DURATION_TEXT = '1.16 ms';
const SPAN_DURATION_CLASS = '.span-duration';
@@ -19,6 +23,9 @@ jest.mock('components/TimelineV3/TimelineV3', () => ({
// Mock the hooks
jest.mock('hooks/useUrlQuery');
jest.mock('@signozhq/ui', () => ({
Badge: jest.fn(),
}));
const mockSpan: SpanV3 = {
span_id: 'test-span-id',
@@ -88,7 +95,7 @@ describe('SpanDuration', () => {
it('calls handleSpanClick when clicked', () => {
const mockHandleSpanClick = jest.fn();
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -108,7 +115,7 @@ describe('SpanDuration', () => {
});
it('shows action buttons on hover', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -129,7 +136,7 @@ describe('SpanDuration', () => {
});
it('applies interested-span class when span is selected', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -147,7 +154,7 @@ describe('SpanDuration', () => {
});
it('applies highlighted-span class when span matches filter', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -166,7 +173,7 @@ describe('SpanDuration', () => {
});
it('applies dimmed-span class when span does not match filter', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -185,7 +192,7 @@ describe('SpanDuration', () => {
});
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -205,7 +212,7 @@ describe('SpanDuration', () => {
});
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -226,7 +233,7 @@ describe('SpanDuration', () => {
});
it('applies interested-span class when span is selected and no filter is active', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -247,7 +254,7 @@ describe('SpanDuration', () => {
});
it('dims span when filter is active but no matches found', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}

View File

@@ -2,8 +2,16 @@ import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { TraceProvider } from '../../../../contexts/TraceContext';
import Success from '../Success';
const renderWithTraceProvider: typeof render = (ui, options, customOptions) =>
render(
<TraceProvider aggregations={undefined}>{ui}</TraceProvider>,
options,
customOptions,
);
// Mock the required hooks with proper typing
const mockSafeNavigate = jest.fn() as jest.MockedFunction<
(params: { search: string }) => void
@@ -222,7 +230,7 @@ describe('Span Click User Flows', () => {
it('clicking span updates URL with spanId parameter', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
renderWithTraceProvider(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
@@ -261,7 +269,7 @@ describe('Span Click User Flows', () => {
it('clicking span duration visually selects the span', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
renderWithTraceProvider(<TestComponent />, undefined, {
initialRoute: '/trace',
});
@@ -300,7 +308,7 @@ describe('Span Click User Flows', () => {
it('both click areas produce the same visual result', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
renderWithTraceProvider(<TestComponent />, undefined, {
initialRoute: '/trace',
});
@@ -360,7 +368,7 @@ describe('Span Click User Flows', () => {
it('clicking different spans updates selection correctly', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
renderWithTraceProvider(<TestComponent />, undefined, {
initialRoute: '/trace',
});
@@ -404,7 +412,7 @@ describe('Span Click User Flows', () => {
mockUrlQuery.set('existingParam', 'existingValue');
mockUrlQuery.set('anotherParam', 'anotherValue');
render(
renderWithTraceProvider(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}

View File

@@ -0,0 +1,111 @@
import { RefObject, useEffect, useRef } from 'react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { IInterestedSpan } from '../types';
const MIN_SPANS_FOR_PAGINATION = 500;
interface UseBoundaryPaginationProps {
scrollContainerRef: RefObject<HTMLDivElement>;
spans: SpanV3[];
isFetching: boolean | undefined;
isFullDataLoaded: boolean;
setInterestedSpanId: (next: IInterestedSpan) => void;
}
interface UseBoundaryPaginationResult {
topSentinelRef: RefObject<HTMLDivElement>;
bottomSentinelRef: RefObject<HTMLDivElement>;
}
/**
* Drives load-more on a virtualized list via two `IntersectionObserver`
* sentinels (top + bottom of the inner content). The observer is created
* once and reads live state through refs — recreating it would re-fire
* IO's mandatory initial-intersection callback for sentinels still in view
* and produce a fetch spiral on every data update.
*
* Returns the two refs the caller must attach to its sentinel `<div>`s.
*/
export function useBoundaryPagination({
scrollContainerRef,
spans,
isFetching,
isFullDataLoaded,
setInterestedSpanId,
}: UseBoundaryPaginationProps): UseBoundaryPaginationResult {
const topSentinelRef = useRef<HTMLDivElement | null>(null);
const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
const spansRef = useRef<SpanV3[]>(spans);
const isFetchingRef = useRef<boolean | undefined>(isFetching);
const isFullDataLoadedRef = useRef<boolean>(isFullDataLoaded);
useEffect(() => {
spansRef.current = spans;
}, [spans]);
useEffect(() => {
isFetchingRef.current = isFetching;
}, [isFetching]);
useEffect(() => {
isFullDataLoadedRef.current = isFullDataLoaded;
}, [isFullDataLoaded]);
useEffect(() => {
const root = scrollContainerRef.current;
if (!root) {
return undefined;
}
const seenInitial = new WeakSet<Element>();
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!seenInitial.has(entry.target)) {
seenInitial.add(entry.target);
return;
}
if (
!entry.isIntersecting ||
isFetchingRef.current ||
isFullDataLoadedRef.current ||
spansRef.current.length < MIN_SPANS_FOR_PAGINATION
) {
return;
}
if (entry.target === bottomSentinelRef.current) {
const lastSpan = spansRef.current[spansRef.current.length - 1];
if (lastSpan) {
setInterestedSpanId({
spanId: lastSpan.span_id,
isUncollapsed: false,
});
}
} else if (entry.target === topSentinelRef.current) {
const firstSpan = spansRef.current[0];
if (firstSpan && firstSpan.level !== 0) {
setInterestedSpanId({
spanId: firstSpan.span_id,
isUncollapsed: false,
});
}
}
});
},
{ root, threshold: 0 },
);
if (bottomSentinelRef.current) {
observer.observe(bottomSentinelRef.current);
}
if (topSentinelRef.current) {
observer.observe(topSentinelRef.current);
}
return (): void => observer.disconnect();
}, [scrollContainerRef, setInterestedSpanId]);
return { topSentinelRef, bottomSentinelRef };
}

View File

@@ -0,0 +1,5 @@
export interface IInterestedSpan {
spanId: string;
isUncollapsed: boolean;
scrollToSpan?: boolean;
}

View File

@@ -0,0 +1,131 @@
import { useMemo } from 'react';
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@signozhq/ui/button';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
function SortableField({
field,
onRemove,
allowDrag,
}: {
field: BaseAutocompleteData;
onRemove: (field: BaseAutocompleteData) => void;
allowDrag: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: field.key });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`fs-field-item ${allowDrag ? 'drag-enabled' : 'drag-disabled'}`}
>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={14} />}
<span className="fs-field-key">{field.key}</span>
</div>
<Button
className="remove-field-btn periscope-btn"
variant="outlined"
color="destructive"
size="sm"
onClick={(): void => onRemove(field)}
>
Remove
</Button>
</div>
);
}
interface AddedFieldsProps {
inputValue: string;
fields: BaseAutocompleteData[];
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
}
function AddedFields({
inputValue,
fields,
onFieldsChange,
}: AddedFieldsProps): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = fields.findIndex((f) => f.key === active.id);
const newIndex = fields.findIndex((f) => f.key === over.id);
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
}
};
const filteredFields = useMemo(
() =>
fields.filter((f) => f.key.toLowerCase().includes(inputValue.toLowerCase())),
[fields, inputValue],
);
const handleRemove = (field: BaseAutocompleteData): void => {
onFieldsChange(fields.filter((f) => f.key !== field.key));
};
const allowDrag = inputValue.length === 0;
return (
<div className="fs-section fs-added">
<div className="fs-section-header">ADDED FIELDS</div>
<div className="fs-added-list">
<OverlayScrollbar>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
{filteredFields.length === 0 ? (
<div className="fs-no-values">No values found</div>
) : (
<SortableContext
items={fields.map((f) => f.key)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>
{filteredFields.map((field) => (
<SortableField
key={field.key}
field={field}
onRemove={handleRemove}
allowDrag={allowDrag}
/>
))}
</SortableContext>
)}
</DndContext>
</OverlayScrollbar>
</div>
</div>
);
}
export default AddedFields;

View File

@@ -0,0 +1,161 @@
.fields-settings {
display: flex;
flex-direction: column;
height: 100%;
background: var(--l1-background);
color: var(--l1-foreground);
overflow: hidden;
.fs-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--l1-border);
.fs-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 500;
}
.fs-close-icon {
cursor: pointer;
color: var(--l2-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}
.fs-search {
.fs-search-input {
background-color: var(--l1-background);
height: 40px;
border-radius: 0;
border-left: none;
border-right: none;
}
}
.fs-section {
display: flex;
flex-direction: column;
&.fs-added {
max-height: 40%;
border-bottom: 1px solid var(--l1-border);
}
&.fs-other {
flex: 1;
min-height: 0;
}
}
.fs-section-header {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 8px 12px;
}
.fs-added-list {
overflow: hidden;
}
.fs-other-list {
flex: 1;
min-height: 0;
overflow: hidden;
.ant-skeleton-input {
width: 300px;
margin: 8px 12px;
}
}
.fs-no-values {
padding: 8px;
text-align: center;
color: var(--l2-foreground);
font-size: 12px;
}
.fs-limit-hint {
padding: 8px 12px;
text-align: center;
color: var(--muted-foreground);
font-size: 11px;
}
}
.fs-field-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-radius: 4px;
user-select: none;
font-size: 13px;
.drag-handle {
display: flex;
align-items: center;
gap: 8px;
flex-grow: 1;
}
.fs-field-key {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.drag-enabled {
cursor: grab;
&:active {
cursor: grabbing;
}
}
&.drag-disabled {
padding: 6px 12px;
}
&.other-field-item {
height: 32px;
}
.remove-field-btn,
.add-field-btn {
padding: 4px 10px;
opacity: 0;
transition: opacity 0.15s ease-in-out;
flex-shrink: 0;
}
&:hover {
background-color: var(--l2-background);
.remove-field-btn,
.add-field-btn {
opacity: 1;
}
}
}
.fs-footer {
display: flex;
gap: 12px;
padding: 12px;
border-top: 1px solid var(--l1-border);
justify-content: space-between;
}

View File

@@ -0,0 +1,149 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import AddedFields from './AddedFields';
import OtherFields from './OtherFields';
import './FieldsSettings.styles.scss';
const MAX_FIELDS_DEFAULT = 10;
interface FieldsSettingsProps {
title: string;
// Picker's native shape (`BaseAutocompleteData`) is preserved end-to-end so
// downstream consumers (flamegraph `selectFields`, hover popovers) get full
// field metadata without a lossy conversion at add-time.
fields: BaseAutocompleteData[];
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
onClose: () => void;
dataSource: DataSource;
maxFields?: number;
}
function FieldsSettings({
title,
fields,
onFieldsChange,
onClose,
dataSource,
maxFields = MAX_FIELDS_DEFAULT,
}: FieldsSettingsProps): JSX.Element {
// Local draft state — changes here don't persist until Save
const [draftFields, setDraftFields] = useState<BaseAutocompleteData[]>(fields);
const [inputValue, setInputValue] = useState('');
const [debouncedInputValue, setDebouncedInputValue] = useState('');
const debouncedUpdate = useDebouncedFn((value) => {
setDebouncedInputValue(value as string);
}, 400);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim().toLowerCase();
setInputValue(value);
debouncedUpdate(value);
},
[debouncedUpdate],
);
const handleAdd = useCallback(
(field: BaseAutocompleteData): void => {
if (draftFields.length >= maxFields) {
return;
}
if (draftFields.some((f) => f.key === field.key)) {
return;
}
setDraftFields((prev) => [...prev, field]);
},
[draftFields, maxFields],
);
const handleSave = useCallback((): void => {
onFieldsChange(draftFields);
toast.success('Saved successfully', {
position: 'top-right',
});
onClose();
}, [draftFields, onFieldsChange, onClose]);
const handleDiscard = useCallback((): void => {
setDraftFields(fields);
}, [fields]);
const hasUnsavedChanges = useMemo(
() =>
!(
draftFields.length === fields.length &&
draftFields.every((f, i) => f.key === fields[i]?.key)
),
[draftFields, fields],
);
const isAtLimit = draftFields.length >= maxFields;
return (
<div className="fields-settings">
<div className="fs-header">
<div className="fs-title">
<TableColumnsSplit size={16} />
{title}
</div>
<X className="fs-close-icon" size={16} onClick={onClose} />
</div>
<section className="fs-search">
<Input
className="fs-search-input"
type="text"
value={inputValue}
placeholder="Search for a field..."
onChange={handleInputChange}
/>
</section>
<AddedFields
inputValue={inputValue}
fields={draftFields}
onFieldsChange={setDraftFields}
/>
<OtherFields
dataSource={dataSource}
debouncedInputValue={debouncedInputValue}
addedFields={draftFields}
onAdd={handleAdd}
isAtLimit={isAtLimit}
/>
{hasUnsavedChanges && (
<div className="fs-footer">
<Button
variant="outlined"
color="secondary"
onClick={handleDiscard}
prefix={<X width={14} height={14} />}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={handleSave}
prefix={<Check width={14} height={14} />}
>
Save changes
</Button>
</div>
)}
</div>
);
}
export default FieldsSettings;

View File

@@ -0,0 +1,99 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Skeleton } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
interface OtherFieldsProps {
dataSource: DataSource;
debouncedInputValue: string;
addedFields: BaseAutocompleteData[];
onAdd: (field: BaseAutocompleteData) => void;
isAtLimit: boolean;
}
function OtherFields({
dataSource,
debouncedInputValue,
addedFields,
onAdd,
isAtLimit,
}: OtherFieldsProps): JSX.Element {
// API call to get available attribute keys
const { data, isFetching } = useGetAggregateKeys(
{
searchText: debouncedInputValue,
dataSource,
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [
REACT_QUERY_KEY.GET_OTHER_FILTERS,
'preview-fields',
debouncedInputValue,
],
enabled: true,
},
);
// Filter out already-added fields, match on .key from API response objects
const otherFields = useMemo(() => {
const attributes = data?.payload?.attributeKeys || [];
const addedKeys = new Set(addedFields.map((f) => f.key));
return attributes.filter((attr) => !addedKeys.has(attr.key));
}, [data, addedFields]);
if (isFetching) {
return (
<div className="fs-section fs-other">
<div className="fs-section-header">OTHER FIELDS</div>
<div className="fs-other-list">
{Array.from({ length: 5 }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<Skeleton.Input active size="small" key={i} />
))}
</div>
</div>
);
}
return (
<div className="fs-section fs-other">
<div className="fs-section-header">OTHER FIELDS</div>
<div className="fs-other-list">
<OverlayScrollbar>
<>
{otherFields.length === 0 ? (
<div className="fs-no-values">No values found</div>
) : (
otherFields.map((attr) => (
<div key={attr.key} className="fs-field-item other-field-item">
<span className="fs-field-key">{attr.key}</span>
{!isAtLimit && (
<Button
className="add-field-btn periscope-btn"
variant="outlined"
color="secondary"
size="sm"
onClick={(): void => onAdd(attr)}
>
Add
</Button>
)}
</div>
))
)}
{isAtLimit && <div className="fs-limit-hint">Maximum 10 fields</div>}
</>
</OverlayScrollbar>
</div>
</div>
);
}
export default OtherFields;

View File

@@ -0,0 +1,55 @@
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export interface ColorByOption {
field: TelemetryFieldKey;
label: string;
}
export const COLOR_BY_OPTIONS: ColorByOption[] = [
{
field: {
name: 'service.name',
fieldContext: 'resource',
fieldDataType: 'string',
},
label: 'Service',
},
{
field: {
name: 'service.namespace',
fieldContext: 'resource',
fieldDataType: 'string',
},
label: 'Namespace',
},
{
field: {
name: 'host.name',
fieldContext: 'resource',
fieldDataType: 'string',
},
label: 'Host',
},
{
field: {
name: 'k8s.node.name',
fieldContext: 'resource',
fieldDataType: 'string',
},
label: 'Node',
},
{
field: {
name: 'k8s.container.name',
fieldContext: 'resource',
fieldDataType: 'string',
},
label: 'Container',
},
];
export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
(o) => o.field,
);
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];

View File

@@ -0,0 +1,220 @@
import {
// oxlint-disable-next-line no-restricted-imports
createContext,
ReactNode,
useCallback,
// oxlint-disable-next-line no-restricted-imports
useContext,
useMemo,
} from 'react';
import { useMutation } from 'react-query';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { themeColors } from 'constants/theme';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { useAppContext } from 'providers/App/App';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import {
SpanV3,
WaterfallAggregationResponse,
WaterfallAggregationType,
} from 'types/api/trace/getTraceV3';
import {
ColorByOption,
COLOR_BY_FIELDS,
COLOR_BY_OPTIONS,
DEFAULT_COLOR_BY_FIELD,
} from '../constants';
import { getSpanAttribute } from '../utils';
import {
AGGREGATIONS,
getAggregationMap as findAggregationMap,
} from '../utils/aggregations';
interface TraceContextValue {
colorByField: TelemetryFieldKey;
setColorByField: (field: TelemetryFieldKey) => void;
aggregations: WaterfallAggregationResponse[] | undefined;
getAggregationMap: (
type: WaterfallAggregationType,
) => Record<string, number> | undefined;
getSpanGroupValue: (span: SpanV3) => string;
resolveSpanColor: (span: SpanV3) => string;
/**
* Subset of COLOR_BY_OPTIONS whose data is populated on the current trace.
* `service.name` is always included; host/container only when their
* aggregation `value` map has entries.
*/
availableColorByOptions: ColorByOption[];
/**
* Per-user preview fields (selected via the floating "Preview fields"
* panel). Stored in `span_details_preview_attributes` user pref. Will be
* consumed by the flamegraph `selectFields` request and the waterfall +
* flamegraph hover popovers in follow-up phases.
*/
previewFields: BaseAutocompleteData[];
setPreviewFields: (next: BaseAutocompleteData[]) => void;
}
const TraceContext = createContext<TraceContextValue | null>(null);
export function TraceProvider({
aggregations,
children,
}: {
aggregations: WaterfallAggregationResponse[] | undefined;
children: ReactNode;
}): JSX.Element | null {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreferenceAPI,
);
// Source of truth: user-preferences API (loaded once on app init via
// AppProvider). Render nothing until it resolves so we never paint the
// default colour first and then swap to the user's persisted choice.
// AppProvider fires the prefs query as soon as the user is logged in, so
// this is usually already settled by the time TraceDetailsV3 mounts.
const persistedColorByField = useMemo<TelemetryFieldKey>(() => {
const pref = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
);
const name = (pref?.value as string) || '';
return COLOR_BY_FIELDS.find((f) => f.name === name) ?? DEFAULT_COLOR_BY_FIELD;
}, [userPreferences]);
const setColorByField = useCallback(
(field: TelemetryFieldKey): void => {
// Optimistically reflect the choice in the in-memory cache so the UI
// reacts immediately (the GET /user/preferences response on app init
// always includes the registered key, so `existing` will be defined).
const existing = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
);
if (existing) {
updateUserPreferenceInContext({ ...existing, value: field.name });
}
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
value: field.name,
});
},
[
userPreferences,
updateUserPreferenceInContext,
updateUserPreferenceMutation,
],
);
const previewFields = useMemo<BaseAutocompleteData[]>(() => {
const pref = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
);
const raw = (pref?.value as BaseAutocompleteData[] | undefined) ?? [];
// Defensive: keep only entries that have a string `key`.
return raw.filter(
(f): f is BaseAutocompleteData =>
typeof f === 'object' &&
f !== null &&
typeof (f as { key?: unknown }).key === 'string',
);
}, [userPreferences]);
const setPreviewFields = useCallback(
(next: BaseAutocompleteData[]): void => {
const existing = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
);
if (existing) {
updateUserPreferenceInContext({ ...existing, value: next });
}
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
value: next,
});
},
[
userPreferences,
updateUserPreferenceInContext,
updateUserPreferenceMutation,
],
);
const value = useMemo<TraceContextValue>(() => {
const isFieldAvailable = (fieldName: string): boolean => {
if (fieldName === DEFAULT_COLOR_BY_FIELD.name) {
return true;
}
// Pick any aggregation type — if execution_time_percentage is empty,
// span_count for the same field will be too (both are derived from
// the same set of spans).
const map = findAggregationMap(
aggregations,
AGGREGATIONS.EXEC_TIME_PCT,
fieldName,
);
return !!map && Object.keys(map).length > 0;
};
const availableColorByOptions = COLOR_BY_OPTIONS.filter((opt) =>
isFieldAvailable(opt.field.name),
);
const colorByField =
aggregations === undefined || isFieldAvailable(persistedColorByField.name)
? persistedColorByField
: DEFAULT_COLOR_BY_FIELD;
const getAggregationMap = (
type: WaterfallAggregationType,
): Record<string, number> | undefined =>
findAggregationMap(aggregations, type, colorByField.name);
const getSpanGroupValue = (span: SpanV3): string =>
getSpanAttribute(span, colorByField.name) || 'unknown';
const resolveSpanColor = (span: SpanV3): string => {
if (span.has_error) {
return 'var(--bg-cherry-500)';
}
return generateColor(
getSpanGroupValue(span),
themeColors.traceDetailColorsV3,
);
};
return {
colorByField,
setColorByField,
aggregations,
getAggregationMap,
getSpanGroupValue,
resolveSpanColor,
availableColorByOptions,
previewFields,
setPreviewFields,
};
}, [
persistedColorByField,
aggregations,
setColorByField,
previewFields,
setPreviewFields,
]);
if (!userPreferences) {
return null;
}
return <TraceContext.Provider value={value}>{children}</TraceContext.Provider>;
}
export function useTraceContext(): TraceContextValue {
const ctx = useContext(TraceContext);
if (!ctx) {
throw new Error('useTraceContext must be used inside TraceProvider');
}
return ctx;
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useRef } from 'react';
import { useMutation } from 'react-query';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { isV3PinnedAttribute } from 'pages/TraceDetailsV3/utils';
import { serializeKeyPath } from 'periscope/components/PrettyView/utils';
import { useAppContext } from 'providers/App/App';
import { SpanV3 } from 'types/api/trace/getTraceV3';
/**
* V2 stored pinned attributes as flat strings (`["http.method"]`).
* V3 stores nested key paths (`['["attributes","http.method"]']`).
*
* On first load with both `userPreferences` and `selectedSpan` available,
* detect a V2-format value in the backend pref and convert it to V3 paths
* using the loaded span's shape. Idempotent: once written in V3 format the
* format check on subsequent loads short-circuits.
*
* Unmappable keys (not present on the loaded span) are dropped — we can't
* determine whether they belong under `attributes`, `resource`, or top-level.
*/
export function useMigratePinnedAttributes(
selectedSpan: SpanV3 | undefined,
): void {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate } = useMutation(updateUserPreferenceAPI);
const ranRef = useRef(false);
useEffect(() => {
if (ranRef.current) {
return;
}
if (!userPreferences || !selectedSpan) {
return;
}
const pref = userPreferences.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
);
const value = (pref?.value as string[] | undefined) ?? [];
if (value.length === 0) {
ranRef.current = true;
return;
}
// Walk every entry — the array may be mixed (e.g. some legacy V2 flat
// keys saved alongside V3 paths). V3 entries pass through unchanged;
// V2 entries get converted via the loaded span; unmappable V2 entries
// are dropped. We only persist when at least one V2 entry was found
// (otherwise the input is already V3-clean).
const next: string[] = [];
let hadV2Entry = false;
for (const entry of value) {
if (isV3PinnedAttribute(entry)) {
next.push(entry);
continue;
}
hadV2Entry = true;
const path = v2KeyToPath(entry, selectedSpan);
if (path) {
next.push(serializeKeyPath(path));
}
// else: unmappable on the loaded span — drop
}
if (!hadV2Entry) {
ranRef.current = true;
return;
}
if (pref) {
updateUserPreferenceInContext({ ...pref, value: next });
}
mutate(
{
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
value: next,
},
{
onSuccess: () => {
ranRef.current = true;
},
onError: () => {
// Roll back the optimistic context update
if (pref) {
updateUserPreferenceInContext({ ...pref, value });
}
},
},
);
}, [userPreferences, selectedSpan, updateUserPreferenceInContext, mutate]);
}
function v2KeyToPath(key: string, span: SpanV3): (string | number)[] | null {
if (span.attributes && key in span.attributes) {
return ['attributes', key];
}
if (span.resource && key in span.resource) {
return ['resource', key];
}
if (key in (span as unknown as Record<string, unknown>)) {
return [key];
}
return null;
}

View File

@@ -12,16 +12,23 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import NoData from 'pages/TraceDetailV2/NoData/NoData';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import {
SpanV3,
TraceDetailV3URLProps,
WaterfallAggregationRequest,
} from 'types/api/trace/getTraceV3';
import { COLOR_BY_FIELDS } from './constants';
import { TraceProvider } from './contexts/TraceContext';
import { AGGREGATIONS } from './utils/aggregations';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall, {
IInterestedSpan,
} from './TraceWaterfall/TraceWaterfall';
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from './TraceWaterfall/types';
import { getAncestorSpanIds } from './TraceWaterfall/utils';
import './TraceDetailsV3.styles.scss';
@@ -79,6 +86,17 @@ function TraceDetailsV3(): JSX.Element {
});
}, [urlQuery]);
// Hardcoded for now — fetch aggregations for all 3 candidate color-by fields
// upfront so a future color-by-field switch doesn't need to refetch.
const waterfallAggregationsRequest = useMemo<WaterfallAggregationRequest[]>(
() =>
COLOR_BY_FIELDS.flatMap((field) => [
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
]),
[],
);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
@@ -86,6 +104,7 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
});
const queryParams = fullDataLoadedRef.current
@@ -94,6 +113,7 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
};
const {
@@ -105,6 +125,7 @@ function TraceDetailsV3(): JSX.Element {
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
aggregations: queryParams.aggregations,
});
const allSpans = traceData?.payload?.spans || [];
@@ -119,6 +140,7 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
};
}
@@ -213,6 +235,23 @@ function TraceDetailsV3(): JSX.Element {
],
);
const traceMetadataForHeader = useMemo(():
| TraceMetadataForHeader
| undefined => {
const payload = traceData?.payload;
if (!payload) {
return undefined;
}
const rootSpan = payload.spans?.find((s) => s.level === 0);
return {
startTimestampMillis: payload.startTimestampMillis,
endTimestampMillis: payload.endTimestampMillis,
rootServiceName: payload.rootServiceName,
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
};
}, [traceData?.payload]);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
@@ -246,104 +285,116 @@ function TraceDetailsV3(): JSX.Element {
);
return (
<div className="trace-details-v3">
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
noData={showNoData}
/>
<TraceProvider aggregations={traceData?.payload?.aggregations}>
<div className="trace-details-v3">
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
traceMetadata={traceMetadataForHeader}
/>
{showNoData ? (
<NoData />
) : (
<>
<div className="trace-details-v3__content">
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
className="trace-details-v3__flame-collapse"
items={[
{
key: 'flame',
label: (
<div className="trace-details-v3__collapse-label">
<span>Flame Graph</span>
{traceData?.payload?.totalSpansCount ? (
<span className="trace-details-v3__collapse-count">
{traceData.payload.totalSpansCount} spans
{traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans."
placement="bottomRight"
autoAdjustOverflow={false}
/>
)}
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
),
},
]}
/>
{showNoData ? (
<NoData />
) : (
<>
<div className="trace-details-v3__content">
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
className="trace-details-v3__flame-collapse"
items={[
{
key: 'flame',
label: (
<div className="trace-details-v3__collapse-label">
<span>Flame Graph</span>
{traceData?.payload?.totalSpansCount ? (
<span className="trace-details-v3__collapse-count">
<span>Spans: {traceData.payload.totalSpansCount}</span>
<span
className={
traceData.payload.totalErrorSpansCount > 0
? 'trace-details-v3__collapse-count-errors'
: undefined
}
>
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
</span>
{traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomRight"
autoAdjustOverflow={false}
/>
)}
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
className={`trace-details-v3__waterfall-collapse${
isWaterfallDocked ? ' trace-details-v3__waterfall-collapse--docked' : ''
}`}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
className={`trace-details-v3__waterfall-collapse${
isWaterfallDocked
? ' trace-details-v3__waterfall-collapse--docked'
: ''
}`}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className="trace-details-v3__docked-span-details">
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap}
/>
</div>
{panelState.isOpen && isDocked && (
<div className="trace-details-v3__docked-span-details">
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{panelState.isOpen && !isDocked && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
)}
</div>
{panelState.isOpen && !isDocked && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap}
/>
)}
</>
)}
</div>
</>
)}
</div>
</TraceProvider>
);
}

View File

@@ -3,10 +3,17 @@ import { SpanV3 } from 'types/api/trace/getTraceV3';
/**
* Look up an attribute from both `resource` and `attributes` on a span.
* Resources are checked first (service.name, k8s.* etc. live there).
*
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
* structurally — the only fields touched are `resource` and `attributes`.
*
* TODO: Remove tagMap fallback when phasing out V2
*/
export function getSpanAttribute(
span: SpanV3,
span: {
resource?: Record<string, string>;
attributes?: Record<string, any>;
},
key: string,
): string | undefined {
return (
@@ -31,3 +38,40 @@ export function hasInfraMetadata(span: SpanV3 | undefined): boolean {
}
return INFRA_METADATA_KEYS.some((key) => getSpanAttribute(span, key));
}
// Top-level fields that exist on the API response only to support waterfall
// rendering. They have no value in the Span Details DataViewer. Drop the whole
// constant + helper once the backend stops emitting them.
const HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW: ReadonlySet<string> = new Set([
'sub_tree_node_count',
'has_children',
'level',
'service.name',
]);
/**
* Shallow-copies the span with waterfall-only fields stripped, for display in
* the Span Details DataViewer.
*/
export function getSpanDisplayData(span: SpanV3): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(span)) {
if (!HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW.has(key)) {
result[key] = value;
}
}
return result;
}
/**
* V3 pinned-attribute entries are JSON-stringified arrays (e.g.
* `'["attributes","http.method"]'`). Legacy V2 entries are flat strings.
* Used to distinguish V3 from V2 entries when reading the persisted value.
*/
export function isV3PinnedAttribute(entry: string): boolean {
try {
return Array.isArray(JSON.parse(entry));
} catch {
return false;
}
}

View File

@@ -0,0 +1,20 @@
import {
WaterfallAggregationResponse,
WaterfallAggregationType,
} from 'types/api/trace/getTraceV3';
export const AGGREGATIONS = {
EXEC_TIME_PCT: 'execution_time_percentage',
SPAN_COUNT: 'span_count',
DURATION: 'duration',
} as const satisfies Record<string, WaterfallAggregationType>;
export function getAggregationMap(
aggregations: WaterfallAggregationResponse[] | undefined,
type: WaterfallAggregationType,
fieldName: string,
): Record<string, number> | undefined {
return aggregations?.find(
(a) => a.aggregation === type && a.field.name === fieldName,
)?.value;
}

View File

@@ -0,0 +1,77 @@
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
FieldContext,
FieldDataType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
/**
* Map the picker's `BaseAutocompleteData.type` (`'tag' | 'resource' | '' | null`)
* to the API's `FieldContext`. Unknown values fall back to `'attribute'`.
*/
function mapFieldContext(type: BaseAutocompleteData['type']): FieldContext {
if (type === 'resource') {
return 'resource';
}
return 'attribute';
}
const DATA_TYPE_MAP: Record<DataTypes, FieldDataType> = {
[DataTypes.String]: 'string',
[DataTypes.bool]: 'bool',
[DataTypes.Int64]: 'int64',
[DataTypes.Float64]: 'float64',
[DataTypes.ArrayString]: '[]string',
[DataTypes.ArrayBool]: '[]bool',
[DataTypes.ArrayInt64]: '[]int64',
[DataTypes.ArrayFloat64]: '[]float64',
[DataTypes.EMPTY]: 'string',
};
function mapFieldDataType(
dataType: BaseAutocompleteData['dataType'],
): FieldDataType {
if (!dataType) {
return 'string';
}
return DATA_TYPE_MAP[dataType] ?? 'string';
}
/**
* Convert a picker-shaped field to the API's `TelemetryFieldKey` shape used
* for `selectFields` on the flamegraph request.
*/
export function toTelemetryFieldKey(
field: BaseAutocompleteData,
): TelemetryFieldKey {
return {
name: field.key,
fieldContext: mapFieldContext(field.type),
fieldDataType: mapFieldDataType(field.dataType),
};
}
/**
* Merge two `TelemetryFieldKey` lists, de-duping by `name`. Earlier entries win
* (so callers can pass user-controlled fields after a baseline list and have
* the baseline's metadata be preserved).
*/
export function mergeTelemetryFieldKeys(
...lists: TelemetryFieldKey[][]
): TelemetryFieldKey[] {
const seen = new Set<string>();
const out: TelemetryFieldKey[] = [];
for (const list of lists) {
for (const f of list) {
if (seen.has(f.name)) {
continue;
}
seen.add(f.name);
out.push(f);
}
}
return out;
}

View File

@@ -23,7 +23,14 @@ const editorOptions: EditorProps['options'] = {
lineHeight: 18,
colorDecorators: true,
scrollBeyondLastLine: false,
scrollbar: { vertical: 'hidden', horizontal: 'hidden' },
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
// Once the editor can't scroll any further, release the wheel event so
// the parent container picks it up. Without this Monaco swallows the
// event at the boundary and outer scroll feels stuck.
alwaysConsumeMouseWheel: false,
},
folding: false,
};

View File

@@ -56,6 +56,13 @@ export interface PrettyViewProps {
searchable?: boolean;
showPinned?: boolean;
drawerKey?: string;
/**
* Controlled list of pinned key paths (each entry is `JSON.stringify(path)`).
* When provided, PrettyView delegates persistence to the caller via
* `onPinnedFieldsChange` and skips its own localStorage I/O.
*/
pinnedFieldsValue?: string[];
onPinnedFieldsChange?: (next: string[]) => void;
}
function PrettyView({
@@ -65,6 +72,8 @@ function PrettyView({
searchable = true,
showPinned = false,
drawerKey = 'default',
pinnedFieldsValue,
onPinnedFieldsChange,
}: PrettyViewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [, setCopy] = useCopyToClipboard();
@@ -75,7 +84,10 @@ function PrettyView({
pinnedEntries,
pinnedData,
displayKeyToForwardPath,
} = usePinnedFields(data, drawerKey);
} = usePinnedFields(data, drawerKey, {
value: pinnedFieldsValue,
onChange: onPinnedFieldsChange,
});
const filteredPinnedData = useMemo(() => {
const trimmed = searchQuery.trim();

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
@@ -42,17 +42,58 @@ export interface UsePinnedFieldsReturn {
displayKeyToForwardPath: Record<string, (string | number)[]>;
}
export interface UsePinnedFieldsOptions {
/**
* Initial / controlled list of serialized key paths.
* When provided, overrides the default localStorage read.
*/
value?: string[];
/**
* Called whenever the pin set changes. When provided, the caller is
* responsible for persistence (e.g. backend user preference).
*/
onChange?: (next: string[]) => void;
}
/**
* Persistence behavior:
* - Controlled (`options.value`/`options.onChange` provided) → caller drives
* state and persistence. localStorage is not touched, regardless of
* `drawerKey`.
* - Uncontrolled with `drawerKey` → reads/writes `pinnedFields:${drawerKey}`
* in localStorage.
* - Uncontrolled without `drawerKey` → in-memory only (no persistence).
*/
function usePinnedFields(
data: AnyRecord,
drawerKey: string,
drawerKey?: string,
options?: UsePinnedFieldsOptions,
): UsePinnedFieldsReturn {
const storageKey = `${STORAGE_PREFIX}:${drawerKey}`;
const controlledValue = options?.value;
const onChange = options?.onChange;
const isControlled = controlledValue !== undefined || onChange !== undefined;
const storageKey =
!isControlled && drawerKey ? `${STORAGE_PREFIX}:${drawerKey}` : null;
// Stored as serialized keyPath arrays (JSON strings)
const [pinnedSerializedKeys, setPinnedSerializedKeys] = useState<Set<string>>(
() => new Set(loadFromStorage(storageKey)),
() => {
if (controlledValue) {
return new Set(controlledValue);
}
if (storageKey) {
return new Set(loadFromStorage(storageKey));
}
return new Set();
},
);
// Sync state with the controlled value when it changes externally.
useEffect(() => {
if (controlledValue) {
setPinnedSerializedKeys(new Set(controlledValue));
}
}, [controlledValue]);
const togglePin = useCallback(
(forwardPath: (string | number)[]): void => {
const serialized = serializeKeyPath(forwardPath);
@@ -63,11 +104,17 @@ function usePinnedFields(
} else {
next.add(serialized);
}
saveToStorage(storageKey, Array.from(next));
const arr = Array.from(next);
if (storageKey) {
saveToStorage(storageKey, arr);
}
if (onChange) {
onChange(arr);
}
return next;
});
},
[storageKey],
[storageKey, onChange],
);
const isPinned = useCallback(

View File

@@ -1,3 +1,5 @@
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export interface TraceDetailFlamegraphURLProps {
id: string;
}
@@ -6,6 +8,7 @@ export interface GetTraceFlamegraphPayloadProps {
traceId: string;
selectedSpanId?: string;
limit?: number;
selectFields?: TelemetryFieldKey[];
}
export interface Event {
@@ -26,6 +29,8 @@ export interface FlamegraphSpan {
name: string;
level: number;
event: Event[];
resource?: Record<string, string>;
attributes?: Record<string, any>;
}
export interface GetTraceFlamegraphSuccessResponse {

View File

@@ -1,9 +1,26 @@
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export type WaterfallAggregationType =
| 'span_count'
| 'execution_time_percentage'
| 'duration';
export interface WaterfallAggregationRequest {
field: TelemetryFieldKey;
aggregation: WaterfallAggregationType;
}
export interface WaterfallAggregationResponse extends WaterfallAggregationRequest {
value: Record<string, number>;
}
export interface GetTraceV3PayloadProps {
traceId: string;
selectedSpanId: string;
uncollapsedSpans: string[];
isSelectedSpanIDUnCollapsed: boolean;
limit?: number; // Optional limit for number of spans to fetch, default can be set in API
aggregations?: WaterfallAggregationRequest[];
}
export interface TraceDetailV3URLProps {
@@ -81,5 +98,5 @@ export interface GetTraceV3SuccessResponse {
totalErrorSpansCount: number;
rootServiceName: string;
rootServiceEntryPoint: string;
serviceNameToTotalDurationMap: Record<string, number>;
aggregations?: WaterfallAggregationResponse[];
}

View File

@@ -34,7 +34,7 @@ describe('extractQueryPairs', () => {
valueEnd: undefined,
valueStart: undefined,
},
isComplete: false,
isComplete: true,
},
{
key: 'name',
@@ -204,7 +204,7 @@ describe('extractQueryPairs', () => {
key: 'active',
operator: 'EXISTS',
value: undefined,
isComplete: false,
isComplete: true,
}),
]);
});
@@ -388,7 +388,7 @@ describe('extractQueryPairs', () => {
expect(result[0].operator).toBe('exists');
expect(result[0].value).toBeUndefined();
expect(result[0].valuesPosition).toStrictEqual([]);
expect(result[0].isComplete).toBe(false);
expect(result[0].isComplete).toBe(true);
expect(result[1].key).toBe('service.name');
expect(result[1].operator).toBe('contains');
expect(result[1].value).toBe('"test"');

View File

@@ -1208,7 +1208,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
isComplete: !!(
currentPair.key &&
currentPair.operator &&
currentPair.value
(currentPair.value || isNonValueOperator(currentPair.operator))
),
} as IQueryPair);
}
@@ -1369,7 +1369,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
isComplete: !!(
currentPair.key &&
currentPair.operator &&
currentPair.value
(currentPair.value || isNonValueOperator(currentPair.operator))
),
} as IQueryPair);
@@ -1414,7 +1414,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
isComplete: !!(
currentPair.key &&
currentPair.operator &&
currentPair.value
(currentPair.value || isNonValueOperator(currentPair.operator))
),
} as IQueryPair);
}

View File

@@ -70,12 +70,39 @@ func (b *traceOperatorCTEBuilder) build(ctx context.Context, requestType qbtypes
selectFromCTE := rootCTEName
if b.operator.ReturnSpansFrom != "" {
selectFromCTE = b.queryToCTEName[b.operator.ReturnSpansFrom]
if selectFromCTE == "" {
sourceQueryCTE := b.queryToCTEName[b.operator.ReturnSpansFrom]
if sourceQueryCTE == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
"returnSpansFrom references query '%s' which has no corresponding CTE",
b.operator.ReturnSpansFrom)
}
filteredCTEName := fmt.Sprintf("__return_from_%s", b.operator.ReturnSpansFrom)
// DISTINCT is essential here. The operator CTE (rootCTEName) holds one row
// per matching *span*, not one row per matching *trace*. A single trace can
// satisfy the operator through multiple spans — e.g. for "A -> B", every
// A-span that is an indirect ancestor of any B-span appears as a separate
// row. If we joined sourceQueryCTE directly against rootCTEName on trace_id,
// each source span would be duplicated once for every operator-matching span
// on the same trace. DISTINCT collapses rootCTEName to one row per trace_id,
// making the join a clean membership test with no fan-out.
matchingTracedSB := sqlbuilder.NewSelectBuilder()
matchingTracedSB.Select("DISTINCT trace_id")
matchingTracedSB.From(rootCTEName)
matchedTracesSQL, matchedTracesArgs := matchingTracedSB.BuildWithFlavor(sqlbuilder.ClickHouse)
filteredSB := sqlbuilder.NewSelectBuilder()
filteredSB.Select("src.*")
filteredSB.From(fmt.Sprintf("%s AS src", sourceQueryCTE))
filteredSB.JoinWithOption(
sqlbuilder.InnerJoin,
fmt.Sprintf("(%s) AS matched_traces", matchedTracesSQL),
"src.trace_id = matched_traces.trace_id",
)
filteredSQL, filteredArgs := filteredSB.BuildWithFlavor(sqlbuilder.ClickHouse, matchedTracesArgs...)
b.addCTE(filteredCTEName, filteredSQL, filteredArgs, []string{sourceQueryCTE, rootCTEName})
selectFromCTE = filteredCTEName
}
finalStmt, err := b.buildFinalQuery(ctx, selectFromCTE, requestType)

View File

@@ -385,6 +385,82 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
expectedErr: nil,
},
{
name: "returnSpansFrom B: A -> B return B spans filtered by operator",
requestType: qbtypes.RequestTypeRaw,
operator: qbtypes.QueryBuilderTraceOperator{
Expression: "A -> B",
ReturnSpansFrom: "B",
Limit: 10,
},
compositeQuery: &qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "service.name = 'gateway'"},
},
},
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "service.name = 'database'"},
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __return_from_B AS (SELECT src.* FROM B AS src INNER JOIN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B) AS matched_traces ON src.trace_id = matched_traces.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM __return_from_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "returnSpansFrom C: (A -> B) && C return C spans filtered by operator",
requestType: qbtypes.RequestTypeRaw,
operator: qbtypes.QueryBuilderTraceOperator{
Expression: "(A -> B) && C",
ReturnSpansFrom: "C",
Limit: 10,
},
compositeQuery: &qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "service.name = 'gateway'"},
},
},
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "service.name = 'database'"},
},
},
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "C",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "service.name = 'auth'"},
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B_AND_C AS (SELECT l.* FROM A_INDIR_DESC_B AS l INNER JOIN C AS r ON l.trace_id = r.trace_id), __return_from_C AS (SELECT src.* FROM C AS src INNER JOIN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B_AND_C) AS matched_traces ON src.trace_id = matched_traces.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM __return_from_C ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "auth", "%service.name%", "%service.name\":\"auth%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
},
}
fm := NewFieldMapper()

View File

@@ -72,6 +72,7 @@ class TraceOperatorQuery:
return_spans_from: str
limit: int | None = None
order: list[OrderBy] | None = None
select_fields: list[TelemetryFieldKey] | None = None
def to_dict(self) -> dict:
spec: dict[str, Any] = {
@@ -83,6 +84,8 @@ class TraceOperatorQuery:
spec["limit"] = self.limit
if self.order:
spec["order"] = [o.to_dict() if hasattr(o, "to_dict") else o for o in self.order]
if self.select_fields:
spec["selectFields"] = [f.to_dict() for f in self.select_fields]
return {"type": "builder_trace_operator", "spec": spec}

View File

@@ -0,0 +1,537 @@
"""
Integration tests for TraceOperatorQuery (builder_trace_operator) through the
/api/v5/query_range endpoint.
Covers:
1. Basic trace operator (A => B) — returns matched spans from the correct trace.
2. Order by a field absent from selectFields — must not return a server error.
Guards against the ClickHouse NOT_FOUND_COLUMN_IN_BLOCK regression where
ordering by a column absent from an outer SELECT caused a query failure.
"""
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
OrderBy,
TelemetryFieldKey,
TraceOperatorQuery,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def _builder_query(name: str, filter_expr: str, limit: int = 100) -> dict:
return {
"type": "builder_query",
"spec": {
"name": name,
"signal": "traces",
"filter": {"expression": filter_expr},
"limit": limit,
},
}
def test_trace_operator_query_basic(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Setup:
Insert one parent span and one child span in the same trace.
Tests:
A => B (parent has a direct child) returns the parent span (returnSpansFrom=A)
from the correct trace.
"""
parent_trace_id = TraceIdGenerator.trace_id()
parent_span_id = TraceIdGenerator.span_id()
child_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=parent_trace_id,
span_id=parent_span_id,
parent_span_id="",
name="parent-op",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-a"},
attributes={"operation.type": "parent"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=2),
trace_id=parent_trace_id,
span_id=child_span_id,
parent_span_id=parent_span_id,
name="child-op",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-a"},
attributes={"operation.type": "child"},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
_builder_query("A", "operation.type = 'parent'"),
_builder_query("B", "operation.type = 'child'"),
TraceOperatorQuery(
name="C",
expression="A => B",
return_spans_from="A",
limit=100,
).to_dict(),
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
rows = results[0].get("rows") or []
assert len(rows) == 1
assert rows[0]["data"]["trace_id"] == parent_trace_id
assert rows[0]["data"]["name"] == "parent-op"
def test_trace_operator_query_order_by_field_not_in_select_fields(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Setup:
Two traces, each with a grandparent → middle → grandchild chain:
Trace 1: grandparent (svc-a, http.method=POST) → middle → grandchild
Trace 2: grandparent (svc-b, http.method=GET) → middle → grandchild
Tests:
A -> B (indirect descendant) with selectFields=[service.name] and
order=[http.method DESC], where http.method is NOT in selectFields.
1. Query succeeds (no NOT_FOUND_COLUMN_IN_BLOCK error from ClickHouse).
2. Results are actually ordered: POST sorts before GET descending, so
svc-a must come before svc-b.
"""
trace_id_1 = TraceIdGenerator.trace_id()
trace_id_2 = TraceIdGenerator.trace_id()
gp_span_id_1 = TraceIdGenerator.span_id()
mid_span_id_1 = TraceIdGenerator.span_id()
gc_span_id_1 = TraceIdGenerator.span_id()
gp_span_id_2 = TraceIdGenerator.span_id()
mid_span_id_2 = TraceIdGenerator.span_id()
gc_span_id_2 = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(
[
# Trace 1 — grandparent has http.method=POST (sorts first in DESC)
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=trace_id_1,
span_id=gp_span_id_1,
parent_span_id="",
name="gp-op",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-a"},
attributes={"operation.type": "grandparent", "http.method": "POST"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=3),
trace_id=trace_id_1,
span_id=mid_span_id_1,
parent_span_id=gp_span_id_1,
name="mid-op",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-a"},
attributes={"operation.type": "middle"},
),
Traces(
timestamp=now - timedelta(seconds=8),
duration=timedelta(seconds=1),
trace_id=trace_id_1,
span_id=gc_span_id_1,
parent_span_id=mid_span_id_1,
name="gc-op",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-a"},
attributes={"operation.type": "grandchild"},
),
# Trace 2 — grandparent has http.method=GET (sorts second in DESC)
Traces(
timestamp=now - timedelta(seconds=7),
duration=timedelta(seconds=5),
trace_id=trace_id_2,
span_id=gp_span_id_2,
parent_span_id="",
name="gp-op",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-b"},
attributes={"operation.type": "grandparent", "http.method": "GET"},
),
Traces(
timestamp=now - timedelta(seconds=6),
duration=timedelta(seconds=3),
trace_id=trace_id_2,
span_id=mid_span_id_2,
parent_span_id=gp_span_id_2,
name="mid-op",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-b"},
attributes={"operation.type": "middle"},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=trace_id_2,
span_id=gc_span_id_2,
parent_span_id=mid_span_id_2,
name="gc-op",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-b"},
attributes={"operation.type": "grandchild"},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
_builder_query("A", "operation.type = 'grandparent'"),
_builder_query("B", "operation.type = 'grandchild'"),
TraceOperatorQuery(
name="C",
expression="A -> B", # indirect descendant
return_spans_from="A",
limit=100,
select_fields=[
TelemetryFieldKey(name="service.name", field_data_type="string", field_context="resource"),
],
order=[
# http.method is intentionally absent from select_fields
OrderBy(
key=TelemetryFieldKey(name="http.method", field_data_type="string", field_context="attribute"),
direction="desc",
),
],
).to_dict(),
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
rows = results[0].get("rows") or []
# Both grandparent spans must be returned
assert len(rows) == 2
# Ordering: POST > GET in DESC — svc-a (POST) must come before svc-b (GET)
assert rows[0]["data"]["service.name"] == "svc-a", f"Expected svc-a (POST) first in http.method DESC order, got {rows[0]['data']['service.name']}"
assert rows[1]["data"]["service.name"] == "svc-b", f"Expected svc-b (GET) second in http.method DESC order, got {rows[1]['data']['service.name']}"
def test_trace_operator_query_order_by_select_field(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Setup:
Two traces each with a parent → child pair; the parents have distinct durations
(5 s and 1 s).
Tests:
A => B with order=[duration_nano DESC] and no explicit selectFields.
1. Query succeeds (no NOT_FOUND_COLUMN_IN_BLOCK error).
2. Longer-duration parent (5 s) appears before the shorter one (1 s).
"""
trace_id_1 = TraceIdGenerator.trace_id()
trace_id_2 = TraceIdGenerator.trace_id()
parent_span_id_1 = TraceIdGenerator.span_id()
child_span_id_1 = TraceIdGenerator.span_id()
parent_span_id_2 = TraceIdGenerator.span_id()
child_span_id_2 = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=trace_id_1,
span_id=parent_span_id_1,
parent_span_id="",
name="parent-long",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-long"},
attributes={"operation.type": "parent"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=1),
trace_id=trace_id_1,
span_id=child_span_id_1,
parent_span_id=parent_span_id_1,
name="child-long",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-long"},
attributes={"operation.type": "child"},
),
Traces(
timestamp=now - timedelta(seconds=8),
duration=timedelta(seconds=1),
trace_id=trace_id_2,
span_id=parent_span_id_2,
parent_span_id="",
name="parent-short",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-short"},
attributes={"operation.type": "parent"},
),
Traces(
timestamp=now - timedelta(seconds=7),
duration=timedelta(seconds=1),
trace_id=trace_id_2,
span_id=child_span_id_2,
parent_span_id=parent_span_id_2,
name="child-short",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-short"},
attributes={"operation.type": "child"},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
_builder_query("A", "operation.type = 'parent'"),
_builder_query("B", "operation.type = 'child'"),
TraceOperatorQuery(
name="C",
expression="A => B",
return_spans_from="A",
limit=100,
order=[
OrderBy(
key=TelemetryFieldKey(name="duration_nano", field_context="span"),
direction="desc",
),
],
).to_dict(),
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
rows = results[0].get("rows") or []
assert len(rows) == 2
# DESC: 5 s parent first, 1 s parent second
assert rows[0]["data"]["name"] == "parent-long", f"Expected parent-long (5s) first in duration_nano DESC, got {rows[0]['data']['name']}"
assert rows[1]["data"]["name"] == "parent-short", f"Expected parent-short (1s) second in duration_nano DESC, got {rows[1]['data']['name']}"
def test_trace_operator_query_order_by_non_core_field_in_select(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Setup:
Two traces each with a parent → child pair; parents have distinct http.method
values (POST and GET).
Tests:
A => B with selectFields=[http.method] and order=[http.method DESC].
1. Query succeeds (no NOT_FOUND_COLUMN_IN_BLOCK error).
2. http.method is present in every result row (it is in selectFields).
3. Results are ordered DESC — POST before GET.
"""
trace_id_1 = TraceIdGenerator.trace_id()
trace_id_2 = TraceIdGenerator.trace_id()
parent_span_id_1 = TraceIdGenerator.span_id()
child_span_id_1 = TraceIdGenerator.span_id()
parent_span_id_2 = TraceIdGenerator.span_id()
child_span_id_2 = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=3),
trace_id=trace_id_1,
span_id=parent_span_id_1,
parent_span_id="",
name="parent-post",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-post"},
attributes={"operation.type": "parent", "http.method": "POST"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=1),
trace_id=trace_id_1,
span_id=child_span_id_1,
parent_span_id=parent_span_id_1,
name="child-post",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-post"},
attributes={"operation.type": "child"},
),
Traces(
timestamp=now - timedelta(seconds=8),
duration=timedelta(seconds=3),
trace_id=trace_id_2,
span_id=parent_span_id_2,
parent_span_id="",
name="parent-get",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-get"},
attributes={"operation.type": "parent", "http.method": "GET"},
),
Traces(
timestamp=now - timedelta(seconds=7),
duration=timedelta(seconds=1),
trace_id=trace_id_2,
span_id=child_span_id_2,
parent_span_id=parent_span_id_2,
name="child-get",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "svc-get"},
attributes={"operation.type": "child"},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
http_method_field = TelemetryFieldKey(
name="http.method",
field_data_type="string",
field_context="attribute",
)
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
_builder_query("A", "operation.type = 'parent'"),
_builder_query("B", "operation.type = 'child'"),
TraceOperatorQuery(
name="C",
expression="A => B",
return_spans_from="A",
limit=100,
select_fields=[http_method_field],
order=[OrderBy(key=http_method_field, direction="desc")],
).to_dict(),
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
rows = results[0].get("rows") or []
assert len(rows) == 2
# http.method must be present in every row (it is in selectFields)
for row in rows:
assert "http.method" in row["data"], f"http.method missing from row: {row['data']}"
# DESC: POST before GET
assert rows[0]["data"]["http.method"] == "POST", f"Expected POST first in http.method DESC, got {rows[0]['data']['http.method']}"
assert rows[1]["data"]["http.method"] == "GET", f"Expected GET second in http.method DESC, got {rows[1]['data']['http.method']}"