Compare commits

...

5 Commits

Author SHA1 Message Date
aks07
9dc87761c1 feat: minor fix 2026-04-16 20:22:17 +05:30
aks07
86a44fad42 Merge branch 'feat/filter-search' of github.com:SigNoz/signoz into feat/dropdown-items 2026-04-16 20:20:41 +05:30
aks07
c88a2d5d90 feat: dropdown added to span details 2026-04-16 15:42:09 +05:30
aks07
ae88edbb5e feat: disable scroll to view for collapse and uncollapse 2026-04-15 22:36:58 +05:30
aks07
7c9484d47b feat: api integration v3 2026-04-15 21:50:02 +05:30
25 changed files with 923 additions and 378 deletions

View File

@@ -1,71 +1,12 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { ApiV3Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { Event, Span } from 'types/api/trace/getTraceV2';
import {
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
SpanV3,
} from 'types/api/trace/getTraceV3';
// Transform a V3 snake_case span to the V2 camelCase Span shape
// V3 WaterfallSpan uses snake_case JSON keys (see pkg/types/tracedetailtypes/waterfall.go)
function transformSpan(raw: any): Span {
const resource: Record<string, string> = raw.resource || {};
const attributes: Record<string, any> = raw.attributes || {};
// Build tagMap from attributes (flattened string representation)
const tagMap: Record<string, string> = {};
Object.entries(attributes).forEach(([k, v]) => {
tagMap[k] = String(v);
});
// Transform events (already camelCase from backend)
const events: Event[] = (raw.events || []).map((e: any) => ({
name: e.name || '',
timeUnixNano: e.timeUnixNano || 0,
attributeMap: e.attributeMap || {},
isError: e.isError || false,
}));
return {
timestamp: raw.timestamp || 0,
durationNano: raw.duration_nano ?? raw.durationNano ?? 0,
spanId: raw.span_id ?? raw.spanId ?? '',
rootSpanId: raw.root_span_id ?? raw.rootSpanId ?? '',
parentSpanId: raw.parent_span_id ?? raw.parentSpanId ?? '',
traceId: raw.trace_id ?? raw.traceId ?? '',
hasError: raw.has_error ?? raw.hasError ?? false,
kind: raw.kind || 0,
serviceName: resource['service.name'] || raw.serviceName || '',
name: raw.name || '',
references: raw.references || null,
tagMap,
event: events,
rootName: raw.root_name ?? raw.rootName ?? '',
statusMessage: raw.status_message ?? raw.statusMessage ?? '',
statusCodeString: raw.status_code_string ?? raw.statusCodeString ?? '',
spanKind: raw.kind_string ?? raw.spanKind ?? '',
hasChildren: raw.has_children ?? raw.hasChildren ?? false,
hasSibling: raw.has_sibling ?? raw.hasSibling ?? false,
subTreeNodeCount: raw.sub_tree_node_count ?? raw.subTreeNodeCount ?? 0,
level: raw.level || 0,
// V3 format fields
attributes: tagMap,
resources: resource,
// Snake_case passthrough fields
http_method: raw.http_method,
http_url: raw.http_url,
http_host: raw.http_host,
db_name: raw.db_name,
db_operation: raw.db_operation,
external_http_method: raw.external_http_method,
external_http_url: raw.external_http_url,
response_status_code: raw.response_status_code,
is_remote: raw.is_remote,
};
}
const getTraceV3 = async (
props: GetTraceV3PayloadProps,
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
@@ -74,6 +15,13 @@ const getTraceV3 = async (
uncollapsedSpans = uncollapsedSpans.filter(
(node) => node !== props.selectedSpanId,
);
} else if (
props.selectedSpanId &&
!uncollapsedSpans.includes(props.selectedSpanId)
) {
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
uncollapsedSpans.push(props.selectedSpanId);
}
const postData: GetTraceV3PayloadProps = {
...props,
@@ -87,7 +35,11 @@ const getTraceV3 = async (
// V3 API wraps response in { status, data }
const rawPayload = (response.data as any).data || response.data;
const spans = (rawPayload.spans || []).map(transformSpan);
// Derive 'service.name' from resource for convenience — only derived field
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
...span,
'service.name': span.resource?.['service.name'] || '',
}));
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
@@ -98,7 +50,6 @@ const getTraceV3 = async (
spans[0].timestamp > 0 &&
startTimestampMillis < spans[0].timestamp / 10
) {
// V3 times are relative — derive absolute times from span data
const durationMillis = endTimestampMillis - startTimestampMillis;
startTimestampMillis = spans[0].timestamp;
endTimestampMillis = startTimestampMillis + durationMillis;

View File

@@ -3,10 +3,16 @@ import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/sonner';
import useUrlQuery from 'hooks/useUrlQuery';
import { Span } from 'types/api/trace/getTraceV2';
// Accepts both V2 (spanId) and V3 (span_id) span shapes
// TODO: Remove V2 (spanId) support when phasing out V2
interface SpanLike {
spanId?: string;
span_id?: string;
}
export const useCopySpanLink = (
span?: Span,
span?: SpanLike,
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
@@ -23,8 +29,9 @@ export const useCopySpanLink = (
urlQuery.delete('spanId');
if (span.spanId) {
urlQuery.set('spanId', span?.spanId);
const id = span.span_id || span.spanId;
if (id) {
urlQuery.set('spanId', id);
}
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;

View File

@@ -1,18 +1,20 @@
import { useCallback, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/button';
import {
Bookmark,
CalendarClock,
ChartBar,
ChartColumnBig,
Copy,
Dock,
Ellipsis,
Link2,
Logs,
PanelBottom,
ScrollText,
Timer,
} from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '@signozhq/ui';
import { Skeleton, Tooltip } from 'antd';
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
@@ -31,18 +33,24 @@ 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 { noop } from 'lodash-es';
import { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
import { ActionMenu, ActionMenuItem } from 'periscope/components/ActionMenu';
import { DataViewer } from 'periscope/components/DataViewer';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { getLeafKeyFromPath } from 'periscope/components/PrettyView/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import AnalyticsPanel from './AnalyticsPanel/AnalyticsPanel';
import { HIGHLIGHTED_OPTIONS } from './config';
import { KEY_ATTRIBUTE_KEYS, SpanDetailVariant } from './constants';
import {
KEY_ATTRIBUTE_KEYS,
SpanDetailVariant,
VISIBLE_ACTIONS,
} from './constants';
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
import {
LinkedSpansPanel,
LinkedSpansToggle,
@@ -56,7 +64,7 @@ import './SpanDetailsPanel.styles.scss';
interface SpanDetailsPanelProps {
panelState: DetailsPanelState;
selectedSpan: Span | undefined;
selectedSpan: SpanV3 | undefined;
variant?: SpanDetailVariant;
onVariantChange?: (variant: SpanDetailVariant) => void;
traceStartTime?: number;
@@ -69,13 +77,75 @@ function SpanDetailsContent({
traceStartTime,
traceEndTime,
}: {
selectedSpan: Span;
selectedSpan: SpanV3;
traceStartTime?: number;
traceEndTime?: number;
}): JSX.Element {
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
const spanAttributeActions = useSpanAttributeActions();
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans(selectedSpan.references);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
// 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(
() =>
spanAttributeActions.map((action) => ({
key: action.value,
label: action.label,
icon: action.icon,
shouldHide: action.shouldHide,
onClick: (context: {
fieldKey: string;
fieldKeyPath: (string | number)[];
fieldValue: unknown;
}): void => {
const leafKey = getLeafKeyFromPath(context.fieldKeyPath, context.fieldKey);
action.callback({
key: leafKey,
value: String(context.fieldValue),
});
},
})),
[spanAttributeActions],
);
const [, setCopy] = useCopyToClipboard();
// Build dropdown menu for key attributes (copy + span actions, no pin)
const buildKeyAttrMenu = useCallback(
(key: string, value: string): ActionMenuItem[] => {
const items: ActionMenuItem[] = [
{
key: 'copy',
label: 'Copy',
icon: <Copy size={12} />,
onClick: (): void => {
setCopy(value);
toast.success('Copied to clipboard', {
richColors: true,
position: 'top-right',
});
},
},
];
spanAttributeActions.forEach((action) => {
if (action.shouldHide && action.shouldHide(key)) {
return;
}
items.push({
key: action.value,
label: action.label,
icon: action.icon,
onClick: (): void => {
action.callback({ key, value });
},
});
});
return items;
},
[spanAttributeActions, setCopy],
);
const {
logs,
@@ -85,8 +155,8 @@ function SpanDetailsContent({
isLogSpanRelated,
hasTraceIdLogs,
} = useSpanContextLogs({
traceId: selectedSpan.traceId,
spanId: selectedSpan.spanId,
traceId: selectedSpan.trace_id,
spanId: selectedSpan.span_id,
timeRange: {
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
@@ -125,7 +195,7 @@ function SpanDetailsContent({
isJSON: false,
} as BaseAutocompleteData,
op: '=',
value: selectedSpan.traceId,
value: selectedSpan.trace_id,
},
],
};
@@ -157,7 +227,7 @@ function SpanDetailsContent({
'_blank',
'noopener,noreferrer',
);
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
}, [selectedSpan.trace_id, traceStartTime, traceEndTime]);
const emptyLogsStateConfig = useMemo(
() => ({
@@ -170,37 +240,23 @@ function SpanDetailsContent({
const keyAttributes = useMemo(() => {
const keys = KEY_ATTRIBUTE_KEYS.traces || [];
const allAttrs: Record<string, string> = {
...(selectedSpan.attributes || selectedSpan.attributes_string),
...(selectedSpan.resources || selectedSpan.resources_string),
...(selectedSpan.http_method && { http_method: selectedSpan.http_method }),
...(selectedSpan.http_url && { http_url: selectedSpan.http_url }),
...(selectedSpan.http_host && { http_host: selectedSpan.http_host }),
...(selectedSpan.db_name && { db_name: selectedSpan.db_name }),
...(selectedSpan.db_operation && {
db_operation: selectedSpan.db_operation,
}),
...(selectedSpan.external_http_method && {
external_http_method: selectedSpan.external_http_method,
}),
...(selectedSpan.external_http_url && {
external_http_url: selectedSpan.external_http_url,
}),
...(selectedSpan.response_status_code && {
response_status_code: selectedSpan.response_status_code,
}),
datetime: dayjs(selectedSpan.timestamp).format('MMM D, YYYY — HH:mm:ss'),
duration: getYAxisFormattedValue(
`${selectedSpan.durationNano / 1000000}`,
'ms',
),
'span.kind': selectedSpan.spanKind,
status_code_string: selectedSpan.statusCodeString,
};
const allAttrs: Record<string, string> = {};
Object.entries(selectedSpan.resource || {}).forEach(([k, v]) => {
allAttrs[k] = String(v);
});
Object.entries(selectedSpan.attributes || {}).forEach(([k, v]) => {
allAttrs[k] = String(v);
});
const span = (selectedSpan as unknown) as Record<string, unknown>;
keys.forEach((key) => {
if (!(key in allAttrs) && span[key] != null && span[key] !== '') {
allAttrs[key] = String(span[key]);
}
});
return keys
.filter((key) => allAttrs[key])
.map((key) => ({ key, value: String(allAttrs[key]) }));
.map((key) => ({ key, value: allAttrs[key] }));
}, [selectedSpan]);
return (
@@ -229,13 +285,13 @@ function SpanDetailsContent({
<div className="span-details-panel__span-info-item">
<Timer size={14} />
<span>
{getYAxisFormattedValue(`${selectedSpan.durationNano / 1000000}`, 'ms')}
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
<>
{' — '}
<strong>
{(
(selectedSpan.durationNano * 100) /
(selectedSpan.duration_nano * 100) /
((traceEndTime - traceStartTime) * 1e6)
).toFixed(2)}
%
@@ -292,7 +348,16 @@ function SpanDetailsContent({
</div>
<div className="span-details-panel__key-attributes-chips">
{keyAttributes.map(({ key, value }) => (
<KeyValueLabel key={key} badgeKey={key} badgeValue={value} />
<ActionMenu
key={key}
items={buildKeyAttrMenu(key, value)}
trigger={['click']}
placement="bottomRight"
>
<div>
<KeyValueLabel badgeKey={key} badgeValue={value} />
</div>
</ActionMenu>
))}
</div>
</div>
@@ -309,7 +374,7 @@ function SpanDetailsContent({
<Bookmark size={14} /> Overview
</TabsTrigger>
<TabsTrigger value="events" variant="secondary">
<ScrollText size={14} /> Events ({selectedSpan.event?.length || 0})
<ScrollText size={14} /> Events ({selectedSpan.events?.length || 0})
</TabsTrigger>
<TabsTrigger value="logs" variant="secondary">
<Logs size={14} /> Logs
@@ -326,20 +391,25 @@ function SpanDetailsContent({
<DataViewer
data={selectedSpan}
drawerKey="trace-details"
prettyViewProps={{ showPinned: true }}
prettyViewProps={{
showPinned: true,
actions: prettyViewCustomActions,
visibleActions: VISIBLE_ACTIONS,
}}
/>
</TabsContent>
<TabsContent value="events">
{/* V2 Events component expects span.event (singular), V3 has span.events (plural) */}
<Events
span={selectedSpan}
span={{ ...selectedSpan, event: selectedSpan.events } as any}
startTime={traceStartTime || 0}
isSearchVisible
/>
</TabsContent>
<TabsContent value="logs">
<SpanLogs
traceId={selectedSpan.traceId}
spanId={selectedSpan.spanId}
traceId={selectedSpan.trace_id}
spanId={selectedSpan.span_id}
timeRange={{
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
@@ -385,14 +455,6 @@ function SpanDetailsPanel({
const headerActions = useMemo((): HeaderAction[] => {
const actions: HeaderAction[] = [
{
key: 'overflow',
component: (
<Button variant="ghost" size="icon" color="secondary" onClick={noop}>
<Ellipsis size={14} />
</Button>
),
},
{
key: 'analytics',
component: (

View File

@@ -13,7 +13,7 @@ import dayjs from 'dayjs';
import useClickOutside from 'hooks/useClickOutside';
import { Check, ChevronDown, ChevronUp, Loader2, PlusIcon } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import './SpanPercentile.styles.scss';
@@ -34,7 +34,7 @@ const timerangeOptions = [1, 2, 4, 6, 12, 24].map((hours) => ({
}));
interface SpanPercentileProps {
selectedSpan: Span;
selectedSpan: SpanV3;
}
function SpanPercentile({ selectedSpan }: SpanPercentileProps): JSX.Element {
@@ -105,9 +105,9 @@ function SpanPercentile({ selectedSpan }: SpanPercentileProps): JSX.Element {
queryKey: [
'getUserPreferenceByPreferenceName',
USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
selectedSpan.spanId,
selectedSpan.span_id,
],
enabled: selectedSpan.tagMap !== undefined,
enabled: selectedSpan.attributes !== undefined,
});
const {
@@ -121,14 +121,14 @@ function SpanPercentile({ selectedSpan }: SpanPercentileProps): JSX.Element {
getSpanPercentiles({
start: startTime || 0,
end: endTime || 0,
spanDuration: selectedSpan.durationNano || 0,
serviceName: selectedSpan.serviceName || '',
spanDuration: selectedSpan.duration_nano || 0,
serviceName: selectedSpan['service.name'] || '',
name: selectedSpan.name || '',
resourceAttributes: selectedResourceAttributes,
}),
queryKey: [
REACT_QUERY_KEY.GET_SPAN_PERCENTILES,
selectedSpan.spanId,
selectedSpan.span_id,
startTime,
endTime,
],
@@ -163,7 +163,7 @@ function SpanPercentile({ selectedSpan }: SpanPercentileProps): JSX.Element {
return (): void => {
clearTimeout(timer);
};
}, [selectedSpan.spanId]);
}, [selectedSpan.span_id]);
useEffect(() => {
if (data?.httpStatusCode !== 200) {
@@ -179,21 +179,34 @@ function SpanPercentile({ selectedSpan }: SpanPercentileProps): JSX.Element {
}
}, [data]);
// Merge resource + attributes to get all span attributes (equivalent to V2 tagMap).
// Stringify all values since the backend expects map[string]string.
const allSpanAttributes = useMemo(() => {
const merged: Record<string, string> = {};
for (const [k, v] of Object.entries(selectedSpan.resource || {})) {
merged[k] = String(v);
}
for (const [k, v] of Object.entries(selectedSpan.attributes || {})) {
merged[k] = String(v);
}
return merged;
}, [selectedSpan.resource, selectedSpan.attributes]);
useEffect(() => {
if (userSelectedResourceAttributes) {
const userList = (userSelectedResourceAttributes?.data
?.value as string[]).map((attr: string) => attr);
let selectedMap: Record<string, string> = {};
userList.forEach((attr: string) => {
selectedMap[attr] = selectedSpan.tagMap?.[attr] || '';
selectedMap[attr] = allSpanAttributes[attr] || '';
});
selectedMap = Object.fromEntries(
Object.entries(selectedMap).filter(
([key]) => selectedSpan.tagMap?.[key] !== undefined,
([key]) => allSpanAttributes[key] !== undefined,
),
);
const resourceAttrs = Object.entries(selectedSpan.tagMap || {}).map(
const resourceAttrs = Object.entries(allSpanAttributes).map(
([key, value]) => ({
key,
value,
@@ -214,7 +227,7 @@ function SpanPercentile({ selectedSpan }: SpanPercentileProps): JSX.Element {
}
if (isErrorUserSelectedResourceAttributes) {
const resourceAttrs = Object.entries(selectedSpan.tagMap || {}).map(
const resourceAttrs = Object.entries(allSpanAttributes).map(
([key, value]) => ({
key,
value,
@@ -229,7 +242,7 @@ function SpanPercentile({ selectedSpan }: SpanPercentileProps): JSX.Element {
}, [
userSelectedResourceAttributes,
isErrorUserSelectedResourceAttributes,
selectedSpan.tagMap,
allSpanAttributes,
]);
const handleResourceAttributeChange = useCallback(
@@ -485,7 +498,7 @@ function SpanPercentile({ selectedSpan }: SpanPercentileProps): JSX.Element {
<Typography.Text className="span-percentile__table-row-value">
(this span){' '}
{getYAxisFormattedValue(
`${selectedSpan.durationNano / 1000000}`,
`${selectedSpan.duration_nano / 1000000}`,
'ms',
)}
</Typography.Text>

View File

@@ -3,7 +3,7 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { Check, ChevronDown, Loader2, PlusIcon } from 'lucide-react';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { UseSpanPercentileReturn } from './useSpanPercentile';
@@ -20,7 +20,7 @@ const timerangeOptions = [1, 2, 4, 6, 12, 24].map((hours) => ({
}));
interface SpanPercentilePanelProps {
selectedSpan: Span;
selectedSpan: SpanV3;
percentile: UseSpanPercentileReturn;
}
@@ -207,7 +207,7 @@ function SpanPercentilePanel({
<Typography.Text className="span-percentile-panel__table-row-value">
(this span){' '}
{getYAxisFormattedValue(
`${selectedSpan.durationNano / 1000000}`,
`${selectedSpan.duration_nano / 1000000}`,
'ms',
)}
</Typography.Text>

View File

@@ -8,7 +8,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { USER_PREFERENCES } from 'constants/userPreferences';
import dayjs from 'dayjs';
import useClickOutside from 'hooks/useClickOutside';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
export interface IResourceAttribute {
key: string;
@@ -51,7 +51,7 @@ export interface UseSpanPercentileReturn {
isFetchingData: boolean;
}
function useSpanPercentile(selectedSpan: Span): UseSpanPercentileReturn {
function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
const [isOpen, setIsOpen] = useState(false);
const [selectedTimeRange, setSelectedTimeRange] = useState(1);
const [
@@ -119,9 +119,9 @@ function useSpanPercentile(selectedSpan: Span): UseSpanPercentileReturn {
queryKey: [
'getUserPreferenceByPreferenceName',
USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
selectedSpan.spanId,
selectedSpan.span_id,
],
enabled: selectedSpan.tagMap !== undefined,
enabled: selectedSpan.attributes !== undefined,
});
const {
@@ -135,14 +135,14 @@ function useSpanPercentile(selectedSpan: Span): UseSpanPercentileReturn {
getSpanPercentiles({
start: startTime || 0,
end: endTime || 0,
spanDuration: selectedSpan.durationNano || 0,
serviceName: selectedSpan.serviceName || '',
spanDuration: selectedSpan.duration_nano || 0,
serviceName: selectedSpan['service.name'] || '',
name: selectedSpan.name || '',
resourceAttributes: selectedResourceAttributes,
}),
queryKey: [
REACT_QUERY_KEY.GET_SPAN_PERCENTILES,
selectedSpan.spanId,
selectedSpan.span_id,
startTime,
endTime,
],
@@ -177,7 +177,7 @@ function useSpanPercentile(selectedSpan: Span): UseSpanPercentileReturn {
return (): void => {
clearTimeout(timer);
};
}, [selectedSpan.spanId]);
}, [selectedSpan.span_id]);
useEffect(() => {
if (data?.httpStatusCode !== 200) {
@@ -193,21 +193,34 @@ function useSpanPercentile(selectedSpan: Span): UseSpanPercentileReturn {
}
}, [data]);
// Merge resource + attributes to get all span attributes (equivalent to V2 tagMap).
// Stringify all values since the backend expects map[string]string.
const allSpanAttributes = useMemo(() => {
const merged: Record<string, string> = {};
for (const [k, v] of Object.entries(selectedSpan.resource || {})) {
merged[k] = String(v);
}
for (const [k, v] of Object.entries(selectedSpan.attributes || {})) {
merged[k] = String(v);
}
return merged;
}, [selectedSpan.resource, selectedSpan.attributes]);
useEffect(() => {
if (userSelectedResourceAttributes) {
const userList = (userSelectedResourceAttributes?.data
?.value as string[]).map((attr: string) => attr);
let selectedMap: Record<string, string> = {};
userList.forEach((attr: string) => {
selectedMap[attr] = selectedSpan.tagMap?.[attr] || '';
selectedMap[attr] = allSpanAttributes[attr] || '';
});
selectedMap = Object.fromEntries(
Object.entries(selectedMap).filter(
([key]) => selectedSpan.tagMap?.[key] !== undefined,
([key]) => allSpanAttributes[key] !== undefined,
),
);
const resourceAttrs = Object.entries(selectedSpan.tagMap || {}).map(
const resourceAttrs = Object.entries(allSpanAttributes).map(
([key, value]) => ({
key,
value,
@@ -228,7 +241,7 @@ function useSpanPercentile(selectedSpan: Span): UseSpanPercentileReturn {
}
if (isErrorUserSelectedResourceAttributes) {
const resourceAttrs = Object.entries(selectedSpan.tagMap || {}).map(
const resourceAttrs = Object.entries(allSpanAttributes).map(
([key, value]) => ({
key,
value,
@@ -244,7 +257,7 @@ function useSpanPercentile(selectedSpan: Span): UseSpanPercentileReturn {
}, [
userSelectedResourceAttributes,
isErrorUserSelectedResourceAttributes,
selectedSpan.tagMap,
allSpanAttributes,
]);
const handleResourceAttributeChange = useCallback(
@@ -281,7 +294,7 @@ function useSpanPercentile(selectedSpan: Span): UseSpanPercentileReturn {
const loading = isLoadingData || isFetchingData;
const percentileValue = Math.floor(spanPercentileData?.percentile || 0);
const duration = getYAxisFormattedValue(
`${selectedSpan.durationNano / 1000000}`,
`${selectedSpan.duration_nano / 1000000}`,
'ms',
);

View File

@@ -1,12 +1,12 @@
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@signozhq/badge';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
interface HighlightedOption {
key: string;
label: string;
render: (span: Span) => ReactNode | null;
render: (span: SpanV3) => ReactNode | null;
}
export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
@@ -14,10 +14,10 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
key: 'service',
label: 'SERVICE',
render: (span): ReactNode | null =>
span.serviceName ? (
span['service.name'] ? (
<Badge color="vanilla">
<span className="span-details-panel__service-dot" />
{span.serviceName}
{span['service.name']}
</Badge>
) : null,
},
@@ -25,23 +25,23 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
key: 'statusCodeString',
label: 'STATUS CODE STRING',
render: (span): ReactNode | null =>
span.statusCodeString ? (
<Badge color="vanilla">{span.statusCodeString}</Badge>
span.status_code_string ? (
<Badge color="vanilla">{span.status_code_string}</Badge>
) : null,
},
{
key: 'traceId',
label: 'TRACE ID',
render: (span): ReactNode | null =>
span.traceId ? (
span.trace_id ? (
<Link
to={{
pathname: `/trace/${span.traceId}`,
pathname: `/trace/${span.trace_id}`,
search: window.location.search,
}}
className="span-details-panel__trace-id"
>
{span.traceId}
{span.trace_id}
</Link>
) : null,
},
@@ -49,6 +49,6 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
key: 'spanKind',
label: 'SPAN KIND',
render: (span): ReactNode | null =>
span.spanKind ? <Badge color="vanilla">{span.spanKind}</Badge> : null,
span.kind_string ? <Badge color="vanilla">{span.kind_string}</Badge> : null,
},
];

View File

@@ -1,3 +1,23 @@
import { SPAN_ACTION } from './hooks/useSpanAttributeActions';
// Action identifiers for built-in PrettyView actions (copy, pin)
export const PRETTY_VIEW_ACTION = {
COPY: 'copy',
PIN: 'pin',
} as const;
// Which actions are visible per node type — drives the entire menu
export const VISIBLE_ACTIONS = {
leaf: [
PRETTY_VIEW_ACTION.COPY,
PRETTY_VIEW_ACTION.PIN,
SPAN_ACTION.FILTER_IN,
SPAN_ACTION.FILTER_OUT,
SPAN_ACTION.GROUP_BY,
],
nested: [PRETTY_VIEW_ACTION.COPY],
} as const;
export enum SpanDetailVariant {
DRAWER = 'drawer',
DIALOG = 'dialog',
@@ -9,9 +29,9 @@ export const KEY_ATTRIBUTE_KEYS: Record<string, string[]> = {
'service.name',
'service.namespace',
'deployment.environment',
'datetime',
'duration',
'span.kind',
'timestamp',
'duration_nano',
'kind_string',
'status_code_string',
'http_method',
'http_url',

View File

@@ -0,0 +1,223 @@
import React, { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { PANEL_TYPES, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { ArrowDownToDot, ArrowUpFromDot } from 'lucide-react';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
export interface SpanAttributeAction {
label: string;
value: string;
icon?: React.ReactNode;
disabled?: boolean;
hidden?: boolean;
callback: (args: { key: string; value: string; dataType?: string }) => void;
/** Returns true if this action should be hidden for the given field key */
shouldHide: (key: string) => boolean;
}
// Keys that should NOT support filter/group-by actions.
// These are system/internal/computed fields, not actual queryable attributes.
export const NON_FILTERABLE_KEYS = new Set([
'datetime',
'duration',
'parent_span_id',
'has_children',
'has_sibling',
'sub_tree_node_count',
'flags',
'trace_state',
'timestamp',
]);
const shouldHideForKey = (key: string): boolean => NON_FILTERABLE_KEYS.has(key);
// Action identifiers
export const SPAN_ACTION = {
FILTER_IN: 'filter-in',
FILTER_OUT: 'filter-out',
GROUP_BY: 'group-by',
} as const;
export function useSpanAttributeActions(): SpanAttributeAction[] {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const queryClient = useQueryClient();
const { notifications } = useNotifications();
const getAutocompleteKey = useCallback(
async (fieldKey: string): Promise<BaseAutocompleteData> => {
const response = await queryClient.fetchQuery(
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
async () =>
getAggregateKeys({
searchText: fieldKey,
aggregateOperator:
currentQuery.builder.queryData[0].aggregateOperator || '',
dataSource: DataSource.TRACES,
aggregateAttribute:
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
}),
);
return chooseAutocompleteFromCustomValue(
response.payload?.attributeKeys || [],
fieldKey,
DataTypes.String,
);
},
[queryClient, currentQuery.builder.queryData],
);
const handleFilter = useCallback(
async (
{ key, value }: { key: string; value: string },
operator: string,
): Promise<void> => {
try {
const autocompleteKey = await getAutocompleteKey(key);
const resolvedOperator = getOperatorValue(operator);
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => {
const cleanedFilters = (item.filters?.items || []).filter(
(f) => f.key?.key !== autocompleteKey.key,
);
const newFilters = [
...cleanedFilters,
{
id: uuid(),
key: autocompleteKey,
op: resolvedOperator,
value,
},
];
const converted = convertFiltersToExpressionWithExistingQuery(
{ items: newFilters, op: item.filters?.op || 'AND' },
item.filter?.expression || '',
);
return {
...item,
dataSource: DataSource.TRACES,
filters: converted.filters,
filter: converted.filter,
};
}),
},
};
redirectWithQueryBuilderData(
nextQuery,
{ panelTypes: PANEL_TYPES.LIST },
ROUTES.TRACES_EXPLORER,
undefined,
true,
);
} catch {
notifications.error({ message: SOMETHING_WENT_WRONG });
}
},
[
currentQuery,
getAutocompleteKey,
redirectWithQueryBuilderData,
notifications,
],
);
const handleFilterIn = useCallback(
(args: { key: string; value: string }): void => {
handleFilter(args, '=');
},
[handleFilter],
);
const handleFilterOut = useCallback(
(args: { key: string; value: string }): void => {
handleFilter(args, '!=');
},
[handleFilter],
);
const handleGroupBy = useCallback(
async ({ key }: { key: string }): Promise<void> => {
try {
const autocompleteKey = await getAutocompleteKey(key);
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => ({
...item,
dataSource: DataSource.TRACES,
groupBy: [...item.groupBy, autocompleteKey],
})),
},
};
redirectWithQueryBuilderData(
nextQuery,
{ panelTypes: PANEL_TYPES.TIME_SERIES },
ROUTES.TRACES_EXPLORER,
undefined,
true,
);
} catch {
notifications.error({ message: SOMETHING_WENT_WRONG });
}
},
[
currentQuery,
getAutocompleteKey,
redirectWithQueryBuilderData,
notifications,
],
);
return [
{
label: 'Filter for value',
value: SPAN_ACTION.FILTER_IN,
icon: React.createElement(ArrowDownToDot, {
size: 14,
style: { transform: 'rotate(90deg)' },
}),
callback: handleFilterIn,
shouldHide: shouldHideForKey,
},
{
label: 'Filter out value',
value: SPAN_ACTION.FILTER_OUT,
icon: React.createElement(ArrowUpFromDot, {
size: 14,
style: { transform: 'rotate(90deg)' },
}),
callback: handleFilterOut,
shouldHide: shouldHideForKey,
},
{
label: 'Group by attribute',
value: SPAN_ACTION.GROUP_BY,
icon: React.createElement(GroupByIcon),
callback: handleGroupBy,
shouldHide: shouldHideForKey,
},
];
}

View File

@@ -3,7 +3,7 @@ import { Popover } from 'antd';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
@@ -51,7 +51,7 @@ export function SpanTooltipContent({
}
interface SpanHoverCardProps {
span: Span;
span: SpanV3;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
@@ -61,11 +61,14 @@ function SpanHoverCard({
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const durationMs = span.durationNano / 1e6;
const durationMs = span.duration_nano / 1e6;
const relativeStartMs = span.timestamp - traceMetadata.startTime;
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
if (span.hasError) {
let color = generateColor(
span['service.name'],
themeColors.traceDetailColorsV3,
);
if (span.has_error) {
color = 'var(--bg-cherry-500)';
}
@@ -76,7 +79,7 @@ function SpanHoverCard({
<SpanTooltipContent
spanName={span.name}
color={color}
hasError={span.hasError}
hasError={span.has_error}
relativeStartMs={relativeStartMs}
durationMs={durationMs}
/>

View File

@@ -63,24 +63,26 @@ function TraceDetailsHeader({
maxCharacters={100}
/>
{!noData && (
<div className="trace-details-header__filter">
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
/>
</div>
<>
<div className="trace-details-header__filter">
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
/>
</div>
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Old View
</Button>
</>
)}
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Old View
</Button>
</div>
);
}

View File

@@ -131,7 +131,8 @@ describe('Canvas Draw Utils', () => {
selectedSpanId: 'sel',
});
expect(ctx.createPattern).toHaveBeenCalled();
// Selected spans get solid l2-background fill + dashed border
expect(ctx.fill).toHaveBeenCalled();
expect(ctx.setLineDash).toHaveBeenCalledWith(DASHED_BORDER_LINE_DASH);
expect(ctx.strokeStyle).toBe('#2F80ED');
expect(ctx.lineWidth).toBe(2);
@@ -139,7 +140,7 @@ describe('Canvas Draw Utils', () => {
expect(ctx.setLineDash).toHaveBeenLastCalledWith([]);
});
it('uses stripe pattern + solid stroke + 1px when hovered (not selected)', () => {
it('uses solid l2-background fill + solid stroke + 1px when hovered (not selected)', () => {
const ctx = createMockCtx();
const spanRectsArray: {
span: typeof MOCK_SPAN;
@@ -165,7 +166,7 @@ describe('Canvas Draw Utils', () => {
hoveredSpanId: 'hov',
});
expect(ctx.createPattern).toHaveBeenCalled();
expect(ctx.fill).toHaveBeenCalled();
expect(ctx.setLineDash).not.toHaveBeenCalled();
expect(ctx.lineWidth).toBe(1);
expect(ctx.stroke).toHaveBeenCalled();
@@ -446,11 +447,9 @@ describe('Canvas Draw Utils', () => {
});
});
describe('createStripePattern (via drawSpanBar)', () => {
it('uses pattern when createPattern returns non-null', () => {
describe('solid l2-background fill for selected/hovered spans', () => {
it('uses solid fill for hovered span', () => {
const ctx = createMockCtx();
const mockPattern = {} as CanvasPattern;
(ctx.createPattern as jest.Mock).mockReturnValue(mockPattern);
drawSpanBar({
ctx,
@@ -467,14 +466,12 @@ describe('Canvas Draw Utils', () => {
hoveredSpanId: 'p',
});
expect(ctx.createPattern).toHaveBeenCalled();
expect(ctx.fillStyle).toBe(mockPattern);
expect(ctx.fill).toHaveBeenCalled();
expect(ctx.stroke).toHaveBeenCalled();
});
it('skips fill when createPattern returns null', () => {
it('uses solid fill + dashed border for selected span', () => {
const ctx = createMockCtx();
(ctx.createPattern as jest.Mock).mockReturnValue(null);
drawSpanBar({
ctx,
@@ -491,7 +488,7 @@ describe('Canvas Draw Utils', () => {
selectedSpanId: 'p',
});
expect(ctx.fill).not.toHaveBeenCalled();
expect(ctx.fill).toHaveBeenCalled();
expect(ctx.stroke).toHaveBeenCalled();
});
});

View File

@@ -19,7 +19,7 @@ import {
useFunnelContext,
} from 'pages/TracesFunnels/FunnelContext';
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { FunnelData } from 'types/api/traceFunnels';
import './AddSpanToFunnelModal.styles.scss';
@@ -38,7 +38,7 @@ function FunnelDetailsView({
triggerDiscard,
}: {
funnel: FunnelData;
span: Span;
span: SpanV3;
triggerAutoSave: boolean;
showNotifications: boolean;
onChangesDetected: (hasChanges: boolean) => void;
@@ -71,7 +71,7 @@ function FunnelDetailsView({
<FunnelConfiguration
funnel={funnel}
isTraceDetailsPage
span={span}
span={span as any}
triggerAutoSave={triggerAutoSave}
showNotifications={showNotifications}
/>
@@ -82,7 +82,7 @@ function FunnelDetailsView({
interface AddSpanToFunnelModalProps {
isOpen: boolean;
onClose: () => void;
span: Span;
span: SpanV3;
}
function AddSpanToFunnelModal({
@@ -200,7 +200,7 @@ function AddSpanToFunnelModal({
</div>
);
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
const renderDetailsView = ({ span }: { span: SpanV3 }): JSX.Element => (
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
<Button
type="text"

View File

@@ -3,7 +3,7 @@ import { Skeleton } from 'antd';
import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { GetTraceV2SuccessResponse, Span } from 'types/api/trace/getTraceV2';
import { GetTraceV3SuccessResponse, SpanV3 } from 'types/api/trace/getTraceV3';
import { TraceWaterfallStates } from './constants';
import Error from './TraceWaterfallStates/Error/Error';
@@ -15,21 +15,22 @@ import './TraceWaterfall.styles.scss';
export interface IInterestedSpan {
spanId: string;
isUncollapsed: boolean;
scrollToSpan?: boolean;
}
interface ITraceWaterfallProps {
traceId: string;
uncollapsedNodes: string[];
traceData:
| SuccessResponse<GetTraceV2SuccessResponse, unknown>
| SuccessResponse<GetTraceV3SuccessResponse, unknown>
| ErrorResponse
| undefined;
isFetchingTraceData: boolean;
errorFetchingTraceData: unknown;
interestedSpanId: IInterestedSpan;
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
selectedSpan: SpanV3 | undefined;
setSelectedSpan: Dispatch<SetStateAction<SpanV3 | undefined>>;
filteredSpanIds: string[];
isFilterActive: boolean;
}

View File

@@ -35,7 +35,7 @@ import {
ListPlus,
} from 'lucide-react';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
@@ -56,13 +56,13 @@ interface ITraceMetadata {
hasMissingSpans: boolean;
}
interface ISuccessProps {
spans: Span[];
spans: SpanV3[];
traceMetadata: ITraceMetadata;
interestedSpanId: IInterestedSpan;
uncollapsedNodes: string[];
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
selectedSpan: SpanV3 | undefined;
setSelectedSpan: Dispatch<SetStateAction<SpanV3 | undefined>>;
filteredSpanIds: string[];
isFilterActive: boolean;
isFetching?: boolean;
@@ -79,28 +79,31 @@ const SpanOverview = memo(function SpanOverview({
traceMetadata,
onAddSpanToFunnel,
}: {
span: Span;
span: SpanV3;
isSpanCollapsed: boolean;
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
selectedSpan: Span | undefined;
handleSpanClick: (span: Span) => void;
selectedSpan: SpanV3 | undefined;
handleSpanClick: (span: SpanV3) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
traceMetadata: ITraceMetadata;
onAddSpanToFunnel: (span: Span) => void;
onAddSpanToFunnel: (span: SpanV3) => void;
}): JSX.Element {
const isRootSpan = span.level === 0;
const { onSpanCopy } = useCopySpanLink(span);
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
if (span.hasError) {
let color = generateColor(
span['service.name'],
themeColors.traceDetailColorsV3,
);
if (span.has_error) {
color = `var(--bg-cherry-500)`;
}
// Smart highlighting logic
const isMatching =
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
const isSelected = selectedSpan?.spanId === span.spanId;
isFilterActive && (filteredSpanIds || []).includes(span.span_id);
const isSelected = selectedSpan?.span_id === span.span_id;
const isDimmed = isFilterActive && !isMatching && !isSelected;
const isHighlighted = isFilterActive && isMatching && !isSelected;
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
@@ -130,7 +133,8 @@ const SpanOverview = memo(function SpanOverview({
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.hasSibling && lvl === span.level - 1;
const isLastChildParentLine =
!span.has_sibling && lvl === span.level - 1;
return (
<div
key={lvl}
@@ -159,14 +163,14 @@ const SpanOverview = memo(function SpanOverview({
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
{/* Expand/collapse arrow + child count (only for spans with children) */}
{span.hasChildren && (
{span.has_children && (
<>
<span
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();
handleCollapseUncollapse(span.spanId, !isSpanCollapsed);
handleCollapseUncollapse(span.span_id, !isSpanCollapsed);
}}
>
{isSpanCollapsed ? (
@@ -176,14 +180,14 @@ const SpanOverview = memo(function SpanOverview({
)}
</span>
<span className="subtree-count">
<Badge color="vanilla">{span.subTreeNodeCount}</Badge>
<Badge color="vanilla">{span.sub_tree_node_count}</Badge>
</span>
</>
)}
{/* Colored service dot */}
<span
className={cx('tree-icon', { 'is-error': span.hasError })}
className={cx('tree-icon', { 'is-error': span.has_error })}
style={{ backgroundColor: color }}
/>
@@ -220,32 +224,35 @@ export const SpanDuration = memo(function SpanDuration({
filteredSpanIds,
isFilterActive,
}: {
span: Span;
span: SpanV3;
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
handleSpanClick: (span: Span) => void;
selectedSpan: SpanV3 | undefined;
handleSpanClick: (span: SpanV3) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
}): JSX.Element {
const { time, timeUnitName } = convertTimeToRelevantUnit(
span.durationNano / 1e6,
span.duration_nano / 1e6,
);
const spread = traceMetadata.endTime - traceMetadata.startTime;
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
const width = (span.durationNano * 1e2) / (spread * 1e6);
const width = (span.duration_nano * 1e2) / (spread * 1e6);
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
let color = generateColor(
span['service.name'],
themeColors.traceDetailColorsV3,
);
let rgbColor = colorToRgb(color);
if (span.hasError) {
if (span.has_error) {
color = `var(--bg-cherry-500)`;
rgbColor = '239, 68, 68';
}
const isMatching =
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
const isSelected = selectedSpan?.spanId === span.spanId;
isFilterActive && (filteredSpanIds || []).includes(span.span_id);
const isSelected = selectedSpan?.span_id === span.span_id;
const isDimmed = isFilterActive && !isMatching && !isSelected;
const isHighlighted = isFilterActive && isMatching && !isSelected;
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
@@ -281,9 +288,9 @@ export const SpanDuration = memo(function SpanDuration({
</span>
</div>
</SpanHoverCard>
{span.event?.map((event) => {
{span.events?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const spanDurationMs = span.durationNano / 1e6;
const spanDurationMs = span.duration_nano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
@@ -299,7 +306,7 @@ export const SpanDuration = memo(function SpanDuration({
.join(', ')})`;
return (
<Popover
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
key={`${span.span_id}-event-${event.name}-${event.timeUnixNano}`}
content={
<EventTooltipContent
eventName={event.name}
@@ -331,7 +338,7 @@ export const SpanDuration = memo(function SpanDuration({
});
// table config
const columnDefHelper = createColumnHelper<Span>();
const columnDefHelper = createColumnHelper<SpanV3>();
const ROW_HEIGHT = 28;
const DEFAULT_SIDEBAR_WIDTH = 450;
@@ -390,7 +397,11 @@ function Success(props: ISuccessProps): JSX.Element {
const handleCollapseUncollapse = useCallback(
(spanId: string, collapse: boolean) => {
setInterestedSpanId({ spanId, isUncollapsed: !collapse });
setInterestedSpanId({
spanId,
isUncollapsed: !collapse,
scrollToSpan: false,
});
},
[setInterestedSpanId],
);
@@ -413,7 +424,7 @@ function Success(props: ISuccessProps): JSX.Element {
let targetLevel: number | null = null;
if (hoveredId) {
const hoveredIdx = spans.findIndex((s) => s.spanId === hoveredId);
const hoveredIdx = spans.findIndex((s) => s.span_id === hoveredId);
if (
hoveredIdx >= capturedRange.startIndex &&
hoveredIdx <= capturedRange.endIndex
@@ -454,7 +465,7 @@ function Success(props: ISuccessProps): JSX.Element {
// do not trigger for trace root as nothing to fetch above
if (spans[0].level !== 0) {
setInterestedSpanId({
spanId: spans[0].spanId,
spanId: spans[0].span_id,
isUncollapsed: false,
});
}
@@ -463,7 +474,7 @@ function Success(props: ISuccessProps): JSX.Element {
if (range?.endIndex === spans.length - 1 && instance.isScrolling) {
setInterestedSpanId({
spanId: spans[spans.length - 1].spanId,
spanId: spans[spans.length - 1].span_id,
isUncollapsed: false,
});
}
@@ -475,9 +486,9 @@ function Success(props: ISuccessProps): JSX.Element {
false,
);
const [selectedSpanToAddToFunnel, setSelectedSpanToAddToFunnel] = useState<
Span | undefined
SpanV3 | undefined
>(undefined);
const handleAddSpanToFunnel = useCallback((span: Span): void => {
const handleAddSpanToFunnel = useCallback((span: SpanV3): void => {
setIsAddSpanToFunnelModalOpen(true);
setSelectedSpanToAddToFunnel(span);
}, []);
@@ -486,10 +497,10 @@ function Success(props: ISuccessProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const handleSpanClick = useCallback(
(span: Span): void => {
(span: SpanV3): void => {
setSelectedSpan(span);
if (span?.spanId) {
urlQuery.set('spanId', span?.spanId);
if (span?.span_id) {
urlQuery.set('spanId', span?.span_id);
}
safeNavigate({ search: urlQuery.toString() });
@@ -508,7 +519,7 @@ function Success(props: ISuccessProps): JSX.Element {
span={cellProps.row.original}
handleCollapseUncollapse={handleCollapseUncollapse}
isSpanCollapsed={
!uncollapsedNodes.includes(cellProps.row.original.spanId)
!uncollapsedNodes.includes(cellProps.row.original.span_id)
}
selectedSpan={selectedSpan}
handleSpanClick={handleSpanClick}
@@ -563,29 +574,32 @@ function Success(props: ISuccessProps): JSX.Element {
);
}, [spans, sidebarWidth]);
// Scroll to interested span
// Scroll to interested span — only when scrollToSpan is true (URL nav, flamegraph click, initial load)
// Skip for collapse/uncollapse to avoid jarring scroll jumps
useEffect(() => {
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
const idx = spans.findIndex(
(span) => span.spanId === interestedSpanId.spanId,
(span) => span.span_id === interestedSpanId.spanId,
);
if (idx !== -1) {
setTimeout(() => {
virtualizerRef.current?.scrollToIndex(idx, {
align: 'center',
behavior: 'auto',
});
if (interestedSpanId.scrollToSpan !== false) {
setTimeout(() => {
virtualizerRef.current?.scrollToIndex(idx, {
align: 'center',
behavior: 'auto',
});
// Auto-scroll sidebar horizontally to show the span name
const span = spans[idx];
const sidebarScrollEl = scrollContainerRef.current?.querySelector(
'.resizable-box__content',
);
if (sidebarScrollEl) {
const targetScrollLeft = Math.max(0, span.level * CONNECTOR_WIDTH - 40);
sidebarScrollEl.scrollLeft = targetScrollLeft;
}
}, 400);
// Auto-scroll sidebar horizontally to show the span name
const span = spans[idx];
const sidebarScrollEl = scrollContainerRef.current?.querySelector(
'.resizable-box__content',
);
if (sidebarScrollEl) {
const targetScrollLeft = Math.max(0, span.level * CONNECTOR_WIDTH - 40);
sidebarScrollEl.scrollLeft = targetScrollLeft;
}
}, 400);
}
setSelectedSpan(spans[idx]);
}
@@ -671,8 +685,8 @@ function Success(props: ISuccessProps): JSX.Element {
return (
<tr
key={String(virtualRow.key)}
data-testid={`cell-0-${span.spanId}`}
data-span-id={span.spanId}
data-testid={`cell-0-${span.span_id}`}
data-span-id={span.span_id}
className="span-tree-row"
style={{
position: 'absolute',
@@ -682,7 +696,7 @@ function Success(props: ISuccessProps): JSX.Element {
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
}}
onMouseEnter={(): void => handleRowMouseEnter(span.spanId)}
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
onMouseLeave={handleRowMouseLeave}
>
{row.getVisibleCells().map((cell) => (
@@ -704,8 +718,8 @@ function Success(props: ISuccessProps): JSX.Element {
return (
<div
key={String(virtualRow.key)}
data-testid={`cell-1-${span.spanId}`}
data-span-id={span.spanId}
data-testid={`cell-1-${span.span_id}`}
data-span-id={span.span_id}
className="timeline-row"
style={{
position: 'absolute',
@@ -715,7 +729,7 @@ function Success(props: ISuccessProps): JSX.Element {
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
}}
onMouseEnter={(): void => handleRowMouseEnter(span.spanId)}
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
onMouseLeave={handleRowMouseLeave}
>
<SpanDuration

View File

@@ -1,6 +1,6 @@
import useUrlQuery from 'hooks/useUrlQuery';
import { fireEvent, render, screen } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { SpanDuration } from '../Success';
@@ -23,28 +23,38 @@ jest.mock('@signozhq/badge', () => ({
Badge: jest.fn(),
}));
const mockSpan: Span = {
spanId: 'test-span-id',
const mockSpan: SpanV3 = {
span_id: 'test-span-id',
name: 'test-span',
serviceName: 'test-service',
durationNano: 1160000, // 1ms in nano
'service.name': 'test-service',
duration_nano: 1160000,
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
parent_span_id: 'test-parent-span-id',
trace_id: 'test-trace-id',
has_error: false,
kind: 0,
references: [],
tagMap: {},
event: [],
rootName: 'test-root-name',
statusMessage: 'test-status-message',
statusCodeString: 'test-status-code-string',
spanKind: 'test-span-kind',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
kind_string: 'test-span-kind',
attributes: {},
resource: {},
events: [],
status_message: 'test-status-message',
status_code: 0,
status_code_string: 'test-status-code-string',
has_children: false,
has_sibling: false,
sub_tree_node_count: 0,
level: 0,
http_method: '',
http_url: '',
http_host: '',
db_name: '',
db_operation: '',
external_http_method: '',
external_http_url: '',
response_status_code: '',
is_remote: '',
flags: 0,
trace_state: '',
};
const mockTraceMetadata = {
@@ -146,7 +156,7 @@ describe('SpanDuration', () => {
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
filteredSpanIds={[mockSpan.span_id]}
isFilterActive
/>,
);
@@ -184,7 +194,7 @@ describe('SpanDuration', () => {
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
filteredSpanIds={[mockSpan.span_id]}
isFilterActive
/>,
);

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import Success from '../Success';
@@ -140,28 +140,38 @@ const mockTraceMetadata = {
hasMissingSpans: false,
};
const createMockSpan = (spanId: string, level = 1): Span => ({
spanId,
traceId: 'test-trace-id',
rootSpanId: 'span-1',
parentSpanId: level === 0 ? '' : 'span-1',
const createMockSpan = (spanId: string, level = 1): SpanV3 => ({
span_id: spanId,
trace_id: 'test-trace-id',
parent_span_id: level === 0 ? '' : 'span-1',
name: `Test Span ${spanId}`,
serviceName: 'test-service',
'service.name': 'test-service',
timestamp: mockTraceMetadata.startTime + level * 100000,
durationNano: 50000000,
duration_nano: 50000000,
level,
hasError: false,
has_error: false,
kind: 1,
references: [],
tagMap: {},
event: [],
rootName: 'Test Root Span',
statusMessage: '',
statusCodeString: 'OK',
spanKind: 'server',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 1,
kind_string: 'server',
attributes: {},
resource: {},
events: [],
status_message: '',
status_code: 0,
status_code_string: 'OK',
has_children: false,
has_sibling: false,
sub_tree_node_count: 1,
http_method: '',
http_url: '',
http_host: '',
db_name: '',
db_operation: '',
external_http_method: '',
external_http_url: '',
response_status_code: '',
is_remote: '',
flags: 0,
trace_state: '',
});
const mockSpans = [
@@ -172,7 +182,7 @@ const mockSpans = [
// Shared TestComponent for all tests
function TestComponent(): JSX.Element {
const [selectedSpan, setSelectedSpan] = React.useState<Span | undefined>(
const [selectedSpan, setSelectedSpan] = React.useState<SpanV3 | undefined>(
undefined,
);
@@ -181,7 +191,7 @@ function TestComponent(): JSX.Element {
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
uncollapsedNodes={mockSpans.map((s) => s.span_id)}
setInterestedSpanId={jest.fn()}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
@@ -214,7 +224,7 @@ describe('Span Click User Flows', () => {
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
uncollapsedNodes={mockSpans.map((s) => s.span_id)}
setInterestedSpanId={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}
@@ -393,7 +403,7 @@ describe('Span Click User Flows', () => {
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
uncollapsedNodes={mockSpans.map((s) => s.span_id)}
setInterestedSpanId={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}

View File

@@ -4,12 +4,12 @@ import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV3 from 'hooks/trace/useGetTraceV2';
import useGetTraceV3 from 'hooks/trace/useGetTraceV3';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import NoData from 'pages/TraceDetailV2/NoData/NoData';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { Span, TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
@@ -23,16 +23,17 @@ import TraceWaterfall, {
import './TraceDetailsV3.styles.scss';
function TraceDetailsV3(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV2URLProps>();
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
scrollToSpan: true,
}),
);
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
const [selectedSpan, setSelectedSpan] = useState<Span>();
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState(false);
@@ -67,6 +68,7 @@ function TraceDetailsV3(): JSX.Element {
setInterestedSpanId({
spanId,
isUncollapsed: true,
scrollToSpan: true,
});
}, [urlQuery]);

View File

@@ -1,12 +1,17 @@
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
/**
* Look up an attribute from both `resources` and `attributes` on a span.
* Look up an attribute from both `resource` and `attributes` on a span.
* Resources are checked first (service.name, k8s.* etc. live there).
* Falls back to V2 fields (tagMap) if V3 fields are not present.
* TODO: Remove tagMap fallback when phasing out V2
*/
export function getSpanAttribute(span: Span, key: string): string | undefined {
return span.resources?.[key] || span.attributes?.[key] || span.tagMap?.[key];
export function getSpanAttribute(
span: SpanV3,
key: string,
): string | undefined {
return (
span.resource?.[key] || span.attributes?.[key] || (span as any).tagMap?.[key]
);
}
const INFRA_METADATA_KEYS = [
@@ -18,9 +23,9 @@ const INFRA_METADATA_KEYS = [
/**
* Check if span has infrastructure metadata (k8s/host).
* Works with both V2 (tagMap) and V3 (resources/attributes) spans.
* TODO: Remove tagMap fallback when phasing out V2
*/
export function hasInfraMetadata(span: Span | undefined): boolean {
export function hasInfraMetadata(span: SpanV3 | undefined): boolean {
if (!span) {
return false;
}

View File

@@ -0,0 +1,24 @@
.action-menu {
.ant-dropdown-menu {
padding: 4px !important;
}
.ant-dropdown-menu-item {
gap: 8px;
.ant-dropdown-menu-item-icon {
display: inline-flex !important;
align-items: center;
justify-content: center;
min-width: 14px;
height: 14px;
flex-shrink: 0;
margin-inline-end: 0 !important;
svg {
width: 14px;
height: 14px;
}
}
}
}

View File

@@ -0,0 +1,61 @@
import React from 'react';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import './ActionMenu.styles.scss';
export interface ActionMenuItem {
key: string;
label: React.ReactNode;
icon?: React.ReactNode;
disabled?: boolean;
onClick: () => void;
}
interface ActionMenuProps {
items: ActionMenuItem[];
trigger?: ('click' | 'hover' | 'contextMenu')[];
placement?:
| 'bottomLeft'
| 'bottomRight'
| 'topLeft'
| 'topRight'
| 'bottom'
| 'top';
children: React.ReactNode;
}
function ActionMenu({
items,
trigger = ['click'],
placement = 'bottomLeft',
children,
}: ActionMenuProps): JSX.Element {
const menuItems: MenuProps['items'] = items.map((item) => ({
key: item.key,
label: item.label,
icon: item.icon,
disabled: item.disabled,
onClick: (): void => {
item.onClick();
},
}));
return (
<Dropdown
rootClassName="action-menu"
menu={{
items: menuItems,
onClick: (e): void => {
e.domEvent.stopPropagation();
},
}}
trigger={trigger}
placement={placement}
>
{children}
</Dropdown>
);
}
export default ActionMenu;

View File

@@ -0,0 +1,2 @@
export type { ActionMenuItem } from './ActionMenu';
export { default as ActionMenu } from './ActionMenu';

View File

@@ -4,15 +4,14 @@ import { useCopyToClipboard } from 'react-use';
import { Copy, Ellipsis, Pin, PinOff } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import type { MenuProps } from 'antd';
// TODO: Replace antd Dropdown with @signozhq/ui component when moving to design library
import { Dropdown } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ActionMenu, ActionMenuItem } from 'periscope/components/ActionMenu';
import { darkTheme, lightTheme, themeExtension } from './constants';
import usePinnedFields from './hooks/usePinnedFields';
import useSearchFilter, { filterTree } from './hooks/useSearchFilter';
import {
getLeafKeyFromPath,
keyPathToDisplayString,
keyPathToForward,
serializeKeyPath,
@@ -32,12 +31,20 @@ export interface PrettyViewAction {
label: React.ReactNode;
icon?: React.ReactNode;
onClick: (context: FieldContext) => void;
/** If provided, action is hidden when this returns true for the field key */
shouldHide?: (key: string) => boolean;
}
export interface VisibleActionsConfig {
leaf: readonly string[];
nested: readonly string[];
}
export interface PrettyViewProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Record<string, any>;
actions?: PrettyViewAction[];
visibleActions?: VisibleActionsConfig;
searchable?: boolean;
showPinned?: boolean;
drawerKey?: string;
@@ -46,6 +53,7 @@ export interface PrettyViewProps {
function PrettyView({
data,
actions,
visibleActions,
searchable = true,
showPinned = false,
drawerKey = 'default',
@@ -86,32 +94,43 @@ function PrettyView({
[],
);
const isActionVisible = useCallback(
(actionKey: string, isNested: boolean): boolean => {
if (!visibleActions) {
return true;
}
const list = isNested ? visibleActions.nested : visibleActions.leaf;
return list.includes(actionKey);
},
[visibleActions],
);
const buildMenuItems = useCallback(
(context: FieldContext): MenuProps['items'] => {
// todo: drive dropdown through config.
const copyItem = {
key: 'copy',
label: 'Copy',
icon: <Copy size={12} />,
onClick: (): void => {
const text =
typeof context.fieldValue === 'object'
? JSON.stringify(context.fieldValue, null, 2)
: String(context.fieldValue);
setCopy(text);
toast.success('Copied to clipboard', {
richColors: true,
position: 'top-right',
});
},
};
(context: FieldContext): ActionMenuItem[] => {
const items: ActionMenuItem[] = [];
const items: NonNullable<MenuProps['items']> = [copyItem];
// Copy action
if (isActionVisible('copy', context.isNested)) {
items.push({
key: 'copy',
label: 'Copy',
icon: <Copy size={12} />,
onClick: (): void => {
const text =
typeof context.fieldValue === 'object'
? JSON.stringify(context.fieldValue, null, 2)
: String(context.fieldValue);
setCopy(text);
toast.success('Copied to clipboard', {
richColors: true,
position: 'top-right',
});
},
});
}
// Pin action only for leaf nodes
if (!context.isNested) {
// Resolve the correct forward path — pinned tree uses display keys
// which don't match the original serialized path
// Pin action
if (isActionVisible('pin', context.isNested) && !context.isNested) {
const resolvedPath =
displayKeyToForwardPath[context.fieldKey] || context.fieldKeyPath;
const serialized = serializeKeyPath(resolvedPath);
@@ -127,10 +146,20 @@ function PrettyView({
});
}
// Custom actions (filter, group, etc.)
if (actions && actions.length > 0) {
//todo: why this divider?
items.push({ type: 'divider' as const, key: 'divider' });
actions.forEach((action) => {
const leafKey = getLeafKeyFromPath(
context.fieldKeyPath,
context.fieldKey,
displayKeyToForwardPath,
);
const visibleCustomActions = actions.filter(
(action) =>
isActionVisible(action.key, context.isNested) &&
!(action.shouldHide && action.shouldHide(leafKey)),
);
visibleCustomActions.forEach((action) => {
items.push({
key: action.key,
label: action.label,
@@ -144,7 +173,7 @@ function PrettyView({
return items;
},
[actions, isPinned, togglePin, displayKeyToForwardPath],
[actions, isActionVisible, isPinned, togglePin, displayKeyToForwardPath],
);
const renderWithActions = useCallback(
@@ -171,19 +200,7 @@ function PrettyView({
return (
<span className="pretty-view__value-row">
<span>{content}</span>
<Dropdown
menu={{
items: menuItems,
onClick: (e): void => {
e.domEvent.stopPropagation();
},
}}
trigger={['click']}
placement="bottomLeft"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement || document.body
}
>
<ActionMenu items={menuItems} trigger={['click']} placement="bottomLeft">
<span
className="pretty-view__actions"
onClick={(e): void => e.stopPropagation()}
@@ -192,7 +209,7 @@ function PrettyView({
>
<Ellipsis size={12} />
</span>
</Dropdown>
</ActionMenu>
</span>
);
},

View File

@@ -43,3 +43,41 @@ export function deserializeKeyPath(
return null;
}
}
/**
* Extract the actual attribute key from a field key path.
* Normal tree: fieldKeyPath = ['resource', 'service.name'] → last element = 'service.name'
* Pinned tree: fieldKeyPath = ['resource.service.name'] → resolve via displayKeyToForwardPath
* to get the original path ['resource', 'service.name'], then take last element.
*
* @param displayKeyToForwardPath - Optional map from display keys to original forward paths
* (from usePinnedFields). Required for correct pinned item resolution when keys contain dots.
*/
export function getLeafKeyFromPath(
fieldKeyPath: (string | number)[],
fieldKey: string,
displayKeyToForwardPath?: Record<string, (string | number)[]>,
): string {
// Normal tree: multiple path segments, last is the leaf key
if (fieldKeyPath.length > 1) {
return String(fieldKeyPath[fieldKeyPath.length - 1]);
}
// Pinned tree: single display key — resolve via map if available
if (fieldKeyPath.length === 1) {
const pathStr = String(fieldKeyPath[0]);
if (displayKeyToForwardPath) {
const resolvedPath = displayKeyToForwardPath[pathStr];
if (resolvedPath && resolvedPath.length > 0) {
return String(resolvedPath[resolvedPath.length - 1]);
}
}
// Fallback: split on dot and drop first segment (parent object name)
const parts = pathStr.split('.');
return parts.length > 1 ? parts.slice(1).join('.') : pathStr;
}
return fieldKey;
}

View File

@@ -5,10 +5,80 @@ export interface GetTraceV3PayloadProps {
isSelectedSpanIDUnCollapsed: boolean;
}
// Re-export shared types from V2 until V3 response diverges
export type {
Event,
GetTraceV2SuccessResponse as GetTraceV3SuccessResponse,
Span,
TraceDetailV2URLProps as TraceDetailV3URLProps,
} from './getTraceV2';
export interface TraceDetailV3URLProps {
id: string;
}
// Event shape — same in V2 and V3 (already camelCase from backend)
export interface EventV3 {
name: string;
timeUnixNano: number;
attributeMap: Record<string, string>;
isError: boolean;
}
// V3 span — snake_case fields matching the API response directly.
// 'service.name' is the only derived field, computed once in getTraceV3.tsx.
export interface SpanV3 {
// Identity
span_id: string;
trace_id: string;
parent_span_id: string;
// Timing
timestamp: number;
duration_nano: number;
// Naming
name: string;
'service.name': string;
// Status
has_error: boolean;
status_message: string;
status_code: number;
status_code_string: string;
// Metadata
kind: number;
kind_string: string;
// Tree structure
has_children: boolean;
has_sibling: boolean;
sub_tree_node_count: number;
level: number;
// Attributes & Resources
attributes: Record<string, any>;
resource: Record<string, string>;
// Events
events: EventV3[];
// V3 direct fields
http_method: string;
http_url: string;
http_host: string;
db_name: string;
db_operation: string;
external_http_method: string;
external_http_url: string;
response_status_code: string;
is_remote: string;
flags: number;
trace_state: string;
}
export interface GetTraceV3SuccessResponse {
spans: SpanV3[];
hasMissingSpans: boolean;
uncollapsedSpans: string[];
startTimestampMillis: number;
endTimestampMillis: number;
totalSpansCount: number;
totalErrorSpansCount: number;
rootServiceName: string;
rootServiceEntryPoint: string;
serviceNameToTotalDurationMap: Record<string, number>;
}