mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-16 17:00:28 +01:00
Compare commits
5 Commits
feat/filte
...
feat/dropd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dc87761c1 | ||
|
|
86a44fad42 | ||
|
|
c88a2d5d90 | ||
|
|
ae88edbb5e | ||
|
|
7c9484d47b |
@@ -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;
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
frontend/src/periscope/components/ActionMenu/ActionMenu.tsx
Normal file
61
frontend/src/periscope/components/ActionMenu/ActionMenu.tsx
Normal 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;
|
||||
2
frontend/src/periscope/components/ActionMenu/index.ts
Normal file
2
frontend/src/periscope/components/ActionMenu/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { ActionMenuItem } from './ActionMenu';
|
||||
export { default as ActionMenu } from './ActionMenu';
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user