mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-12 21:20:30 +01:00
Compare commits
20 Commits
fix/recurr
...
traceop-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dafa81f3b4 | ||
|
|
a992a13f56 | ||
|
|
79b36abbd7 | ||
|
|
7f6bdcbb8c | ||
|
|
181c307d1a | ||
|
|
becdd4d3b4 | ||
|
|
de0311201a | ||
|
|
1804bfe802 | ||
|
|
357444c94e | ||
|
|
a8598f3bfa | ||
|
|
bca71f9a33 | ||
|
|
c93660357d | ||
|
|
5651e3b7a8 | ||
|
|
cf2cfbc7d4 | ||
|
|
a969c38224 | ||
|
|
b892a0f0a5 | ||
|
|
4d47762eba | ||
|
|
77396a0bb3 | ||
|
|
28c05e1bab | ||
|
|
2b9e383994 |
@@ -29,6 +29,18 @@ if (!HTMLElement.prototype.scrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = function (): void {};
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver === 'undefined') {
|
||||
class IntersectionObserverMock {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
(window as any).IntersectionObserver = IntersectionObserverMock;
|
||||
}
|
||||
|
||||
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
|
||||
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
|
||||
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),
|
||||
|
||||
@@ -40,6 +40,7 @@ const getTraceV3 = async (
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
timestamp: span.time_unix,
|
||||
}));
|
||||
|
||||
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
|
||||
|
||||
@@ -44,7 +44,11 @@ function HttpStatusBadge({
|
||||
|
||||
const color = getStatusCodeColor(numericStatusCode);
|
||||
|
||||
return <Badge color={color}>{statusCode}</Badge>;
|
||||
return (
|
||||
<Badge color={color} variant="outline">
|
||||
{statusCode}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default HttpStatusBadge;
|
||||
|
||||
@@ -38,6 +38,7 @@ export enum LOCALSTORAGE {
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
|
||||
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
}
|
||||
|
||||
@@ -3,5 +3,7 @@ export const USER_PREFERENCES = {
|
||||
NAV_SHORTCUTS: 'nav_shortcuts',
|
||||
LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
|
||||
SPAN_DETAILS_PREVIEW_ATTRIBUTES: 'span_details_preview_attributes',
|
||||
SPAN_DETAILS_COLOR_BY_ATTRIBUTE: 'span_details_color_by_attribute',
|
||||
SPAN_PERCENTILE_RESOURCE_ATTRIBUTES: 'span_percentile_resource_attributes',
|
||||
};
|
||||
|
||||
@@ -218,7 +218,7 @@ function SpanLogs({
|
||||
<Virtuoso
|
||||
className="span-logs-virtuoso"
|
||||
key="span-logs-virtuoso"
|
||||
style={logs.length <= 35 ? { height: `calc(${logs.length} * 22px)` } : {}}
|
||||
style={{ height: '100%' }}
|
||||
data={logs}
|
||||
totalCount={logs.length}
|
||||
itemContent={getItemContent}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
.span-logs {
|
||||
margin-inline: 16px;
|
||||
height: calc(100% - 64px - 55px - 56px);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&-virtuoso {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
&-list-container {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.logs-loading-skeleton {
|
||||
height: 100%;
|
||||
|
||||
@@ -68,6 +68,10 @@
|
||||
border-left: unset;
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
|
||||
.new-view-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.second-row {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Skeleton, Tooltip } from 'antd';
|
||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import removeLocalStorageKey from 'api/browser/localstorage/remove';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import dayjs from 'dayjs';
|
||||
import history from 'lib/history';
|
||||
@@ -60,18 +64,51 @@ function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const isOnOldRoute = !!useRouteMatch({
|
||||
path: ROUTES.TRACE_DETAIL_OLD,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const handleSwitchToNewView = (): void => {
|
||||
removeLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW);
|
||||
history.replace({
|
||||
pathname: `/trace/${traceID}`,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
state: location.state,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="trace-metadata">
|
||||
<section className="metadata-info">
|
||||
<div className="first-row">
|
||||
<Button className="previous-btn" onClick={handlePreviousBtnClick}>
|
||||
<ArrowLeft size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className="previous-btn"
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={handlePreviousBtnClick}
|
||||
/>
|
||||
<div className="trace-name">
|
||||
<DraftingCompass size={14} className="drafting" />
|
||||
<Typography.Text className="trace-id">Trace ID</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
|
||||
{isOnOldRoute && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
className="new-view-btn"
|
||||
onClick={handleSwitchToNewView}
|
||||
>
|
||||
New view
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDataLoading && (
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
|
||||
import { useLocalStorage } from '../useLocalStorage';
|
||||
|
||||
@@ -19,59 +10,25 @@ interface UsePinnedAttributesReturn {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing pinned span attributes with backend persistence.
|
||||
* Falls back to localStorage during initial load and handles migration.
|
||||
* V2 trace-details pinned-attributes hook. localStorage-only.
|
||||
*
|
||||
* @param availableAttributes - Object keys of the current span's flattened attributes
|
||||
* @returns Object with pinned state, toggle function, and check function
|
||||
* NOTE: V2 used to also persist to the user-pref API
|
||||
* (`span_details_pinned_attributes`) but V3 now owns that key with a different
|
||||
* (nested-path) format. V2 is isolated to localStorage so it doesn't fight V3
|
||||
* over the same backend value. See `useMigratePinnedAttributes` in V3.
|
||||
*/
|
||||
export function usePinnedAttributes(
|
||||
availableAttributes: string[],
|
||||
): UsePinnedAttributesReturn {
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
// API mutation for updating preferences
|
||||
const { mutate: updateUserPreferenceMutation } = useMutation(
|
||||
updateUserPreference,
|
||||
{
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Local state for optimistic updates
|
||||
const [pinnedKeys, setPinnedKeys] = useState<string[]>([]);
|
||||
|
||||
// Get localStorage fallback for initial load
|
||||
const [localStoragePinnedKeys] = useLocalStorage<string[]>(
|
||||
const [pinnedKeys, setPinnedKeys] = useLocalStorage<string[]>(
|
||||
LOCALSTORAGE.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
[],
|
||||
);
|
||||
|
||||
// Initialize from user preferences when loaded
|
||||
useEffect(() => {
|
||||
if (userPreferences !== null) {
|
||||
const preference = userPreferences.find(
|
||||
(pref) => pref.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
);
|
||||
|
||||
if (preference?.value) {
|
||||
// use backend data
|
||||
setPinnedKeys(preference.value as string[]);
|
||||
} else if (localStoragePinnedKeys.length > 0) {
|
||||
// use local storage data
|
||||
setPinnedKeys(localStoragePinnedKeys);
|
||||
}
|
||||
}
|
||||
}, [userPreferences, localStoragePinnedKeys]);
|
||||
|
||||
// Create pinned attributes state from stored keys, filtering by available attributes
|
||||
const pinnedAttributes = useMemo(
|
||||
(): Record<string, boolean> =>
|
||||
pinnedKeys.reduce(
|
||||
(acc, key) => {
|
||||
// Only include if the attribute exists in the current span
|
||||
if (availableAttributes.includes(key)) {
|
||||
acc[key] = true;
|
||||
}
|
||||
@@ -82,38 +39,17 @@ export function usePinnedAttributes(
|
||||
[pinnedKeys, availableAttributes],
|
||||
);
|
||||
|
||||
// Toggle pin state for an attribute
|
||||
const togglePin = useCallback(
|
||||
(attributeKey: string): void => {
|
||||
const currentlyPinned = pinnedKeys.includes(attributeKey);
|
||||
const newPinnedKeys = currentlyPinned
|
||||
? pinnedKeys.filter((key) => key !== attributeKey)
|
||||
: [...pinnedKeys, attributeKey];
|
||||
|
||||
// Optimistically update local state for instant UI feedback
|
||||
setPinnedKeys(newPinnedKeys);
|
||||
|
||||
updateUserPreferenceInContext({
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
value: newPinnedKeys,
|
||||
} as UserPreference);
|
||||
|
||||
// Save to localStorage immediately for offline resilience
|
||||
setLocalStorageApi(
|
||||
LOCALSTORAGE.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
JSON.stringify(newPinnedKeys),
|
||||
setPinnedKeys((prev) =>
|
||||
prev.includes(attributeKey)
|
||||
? prev.filter((k) => k !== attributeKey)
|
||||
: [...prev, attributeKey],
|
||||
);
|
||||
|
||||
// Make the API call in the background
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
value: newPinnedKeys,
|
||||
});
|
||||
},
|
||||
[pinnedKeys, updateUserPreferenceInContext, updateUserPreferenceMutation],
|
||||
[setPinnedKeys],
|
||||
);
|
||||
|
||||
// Check if an attribute is pinned
|
||||
const isPinned = useCallback(
|
||||
(attributeKey: string): boolean => pinnedAttributes[attributeKey] === true,
|
||||
[pinnedAttributes],
|
||||
|
||||
@@ -12,8 +12,11 @@ const useGetTraceFlamegraph = (
|
||||
): UseLicense =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceFlamegraph(props),
|
||||
// if any of the props changes then we need to trigger an API call as the older data will be obsolete
|
||||
queryKey: [REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH, props.traceId],
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
|
||||
props.traceId,
|
||||
props.selectFields,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
@@ -15,6 +15,7 @@ const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.isSelectedSpanIDUnCollapsed,
|
||||
props.aggregations,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { Redirect, useParams } from 'react-router-dom';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import TraceDetailsV3 from '../TraceDetailsV3';
|
||||
|
||||
export default function TraceDetailV3Page(): JSX.Element {
|
||||
const { id } = useParams<TraceDetailV2URLProps>();
|
||||
const preferOld =
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true';
|
||||
|
||||
if (preferOld) {
|
||||
return (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: `/trace-old/${id}`,
|
||||
search: window.location.search,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <TraceDetailsV3 />;
|
||||
}
|
||||
|
||||
@@ -10,16 +10,14 @@ import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
|
||||
import { useTraceContext } from '../../contexts/TraceContext';
|
||||
import { AGGREGATIONS } from '../../utils/aggregations';
|
||||
|
||||
import './AnalyticsPanel.styles.scss';
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
serviceExecTime?: Record<string, number>;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
// TODO: Re-enable when backend provides per-service span counts
|
||||
// spans?: Span[];
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 350;
|
||||
@@ -30,41 +28,46 @@ const PANEL_MARGIN_BOTTOM = 50;
|
||||
function AnalyticsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
serviceExecTime = {},
|
||||
traceStartTime = 0,
|
||||
traceEndTime = 0,
|
||||
}: AnalyticsPanelProps): JSX.Element | null {
|
||||
const spread = traceEndTime - traceStartTime;
|
||||
const { getAggregationMap } = useTraceContext();
|
||||
|
||||
const execTimePct = useMemo(
|
||||
() => getAggregationMap(AGGREGATIONS.EXEC_TIME_PCT),
|
||||
[getAggregationMap],
|
||||
);
|
||||
|
||||
const spanCounts = useMemo(
|
||||
() => getAggregationMap(AGGREGATIONS.SPAN_COUNT),
|
||||
[getAggregationMap],
|
||||
);
|
||||
|
||||
const execTimeRows = useMemo(() => {
|
||||
if (spread <= 0) {
|
||||
if (!execTimePct) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(serviceExecTime)
|
||||
.map(([service, duration]) => ({
|
||||
service,
|
||||
percentage: (duration * 100) / spread,
|
||||
color: generateColor(service, themeColors.traceDetailColorsV3),
|
||||
return Object.entries(execTimePct)
|
||||
.map(([group, percentage]) => ({
|
||||
group,
|
||||
percentage,
|
||||
color: generateColor(group, themeColors.traceDetailColorsV3),
|
||||
}))
|
||||
.sort((a, b) => b.percentage - a.percentage);
|
||||
}, [serviceExecTime, spread]);
|
||||
}, [execTimePct]);
|
||||
|
||||
// const spanCountRows = useMemo(() => {
|
||||
// const counts: Record<string, number> = {};
|
||||
// for (const span of spans) {
|
||||
// const name = span.serviceName || 'unknown';
|
||||
// counts[name] = (counts[name] || 0) + 1;
|
||||
// }
|
||||
// return Object.entries(counts)
|
||||
// .map(([service, count]) => ({
|
||||
// service,
|
||||
// count,
|
||||
// color: generateColor(service, themeColors.traceDetailColorsV3),
|
||||
// }))
|
||||
// .sort((a, b) => b.count - a.count);
|
||||
// }, [spans]);
|
||||
|
||||
// const maxSpanCount = spanCountRows[0]?.count || 1;
|
||||
const spanCountRows = useMemo(() => {
|
||||
if (!spanCounts) {
|
||||
return [];
|
||||
}
|
||||
const max = Math.max(...Object.values(spanCounts), 1);
|
||||
return Object.entries(spanCounts)
|
||||
.map(([group, count]) => ({
|
||||
group,
|
||||
count,
|
||||
max,
|
||||
color: generateColor(group, themeColors.traceDetailColorsV3),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [spanCounts]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
@@ -103,11 +106,9 @@ function AnalyticsPanel({
|
||||
<TabsTrigger value="exec-time" variant="secondary">
|
||||
% exec time
|
||||
</TabsTrigger>
|
||||
{/* TODO: Enable when backend provides per-service span counts
|
||||
<TabsTrigger value="spans" variant="secondary">
|
||||
Spans
|
||||
</TabsTrigger>
|
||||
*/}
|
||||
</TabsList>
|
||||
|
||||
<div className="analytics-panel__tabs-scroll">
|
||||
@@ -116,17 +117,17 @@ function AnalyticsPanel({
|
||||
{execTimeRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.service}-dot`}
|
||||
key={`${row.group}-dot`}
|
||||
className="analytics-panel__dot"
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span
|
||||
key={`${row.service}-name`}
|
||||
key={`${row.group}-name`}
|
||||
className="analytics-panel__service-name"
|
||||
>
|
||||
{row.service}
|
||||
{row.group}
|
||||
</span>
|
||||
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
|
||||
<div key={`${row.group}-bar`} className="analytics-panel__bar-cell">
|
||||
<div className="analytics-panel__bar">
|
||||
<div
|
||||
className="analytics-panel__bar-fill"
|
||||
@@ -145,28 +146,27 @@ function AnalyticsPanel({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* TODO: Enable when backend provides per-service span counts
|
||||
<TabsContent value="spans">
|
||||
<div className="analytics-panel__list">
|
||||
{spanCountRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.service}-dot`}
|
||||
key={`${row.group}-dot`}
|
||||
className="analytics-panel__dot"
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span
|
||||
key={`${row.service}-name`}
|
||||
key={`${row.group}-name`}
|
||||
className="analytics-panel__service-name"
|
||||
>
|
||||
{row.service}
|
||||
{row.group}
|
||||
</span>
|
||||
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
|
||||
<div key={`${row.group}-bar`} className="analytics-panel__bar-cell">
|
||||
<div className="analytics-panel__bar">
|
||||
<div
|
||||
className="analytics-panel__bar-fill"
|
||||
style={{
|
||||
width: `${(row.count / maxSpanCount) * 100}%`,
|
||||
width: `${(row.count / row.max) * 100}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
@@ -179,7 +179,6 @@ function AnalyticsPanel({
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
*/}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
|
||||
@@ -64,9 +64,13 @@
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
[role='tabpanel'] {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +116,8 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--success-500);
|
||||
background: var(--accent-forest);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__trace-id {
|
||||
@@ -123,6 +128,17 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__trace-id-copy {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&__key-attributes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -41,7 +41,12 @@ import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
|
||||
import { useMigratePinnedAttributes } from 'pages/TraceDetailsV3/hooks/useMigratePinnedAttributes';
|
||||
import {
|
||||
getSpanAttribute,
|
||||
getSpanDisplayData,
|
||||
hasInfraMetadata,
|
||||
} from 'pages/TraceDetailsV3/utils';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
@@ -59,6 +64,7 @@ import {
|
||||
VISIBLE_ACTIONS,
|
||||
} from './constants';
|
||||
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
|
||||
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
LinkedSpansToggle,
|
||||
@@ -77,7 +83,6 @@ interface SpanDetailsPanelProps {
|
||||
onVariantChange?: (variant: SpanDetailVariant) => void;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
serviceExecTime?: Record<string, number>;
|
||||
}
|
||||
|
||||
function SpanDetailsContent({
|
||||
@@ -94,6 +99,17 @@ function SpanDetailsContent({
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
// One-time conversion of any V2-format value still living in the
|
||||
// `span_details_pinned_attributes` user pref into V3 nested-path format.
|
||||
useMigratePinnedAttributes(selectedSpan);
|
||||
const { value: pinnedFieldsValue, onChange: onPinnedFieldsChange } =
|
||||
useTracePinnedFields();
|
||||
|
||||
const spanDisplayData = useMemo(
|
||||
() => getSpanDisplayData(selectedSpan),
|
||||
[selectedSpan],
|
||||
);
|
||||
|
||||
// Map span attribute actions to PrettyView actions format.
|
||||
// Use the last key in fieldKeyPath (the actual attribute key), not the full display path.
|
||||
const prettyViewCustomActions = useMemo(
|
||||
@@ -391,12 +407,14 @@ function SpanDetailsContent({
|
||||
<div className="span-details-panel__tabs-scroll">
|
||||
<TabsContent value="overview">
|
||||
<DataViewer
|
||||
data={selectedSpan}
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
prettyViewProps={{
|
||||
showPinned: true,
|
||||
actions: prettyViewCustomActions,
|
||||
visibleActions: VISIBLE_ACTIONS,
|
||||
pinnedFieldsValue,
|
||||
onPinnedFieldsChange,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -451,7 +469,6 @@ function SpanDetailsPanel({
|
||||
onVariantChange,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
serviceExecTime,
|
||||
}: SpanDetailsPanelProps): JSX.Element {
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
|
||||
@@ -558,9 +575,6 @@ function SpanDetailsPanel({
|
||||
<AnalyticsPanel
|
||||
isOpen={isAnalyticsOpen}
|
||||
onClose={(): void => setIsAnalyticsOpen(false)}
|
||||
serviceExecTime={serviceExecTime}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -594,6 +608,7 @@ function SpanDetailsPanel({
|
||||
isOpen={panelState.isOpen}
|
||||
className="span-details-panel"
|
||||
width={PANEL_WIDTH}
|
||||
minWidth={480}
|
||||
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
|
||||
defaultPosition={{
|
||||
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Link, useRouteMatch } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
interface TraceIdFieldProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a span's trace id. When the user is already on the trace detail
|
||||
* page for this trace, clicking the id copies it to the clipboard (the
|
||||
* "navigate" affordance would be a no-op). Otherwise, falls back to the
|
||||
* existing link to the trace detail page.
|
||||
*/
|
||||
export function TraceIdField({ span }: TraceIdFieldProps): JSX.Element {
|
||||
const match = useRouteMatch<{ id: string }>({
|
||||
path: ROUTES.TRACE_DETAIL,
|
||||
exact: true,
|
||||
});
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
|
||||
const isCurrentTrace = match?.params.id === span.trace_id;
|
||||
|
||||
if (isCurrentTrace) {
|
||||
const handleCopy = (): void => {
|
||||
setCopy(span.trace_id);
|
||||
toast.success('Trace ID copied to clipboard', {
|
||||
position: 'top-right',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
className="span-details-panel__trace-id-copy"
|
||||
onClick={handleCopy}
|
||||
title="Click to copy trace ID"
|
||||
>
|
||||
{span.trace_id}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={{
|
||||
pathname: `/trace/${span.trace_id}`,
|
||||
search: window.location.search,
|
||||
}}
|
||||
className="span-details-panel__trace-id"
|
||||
>
|
||||
{span.trace_id}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceIdField } from './TraceIdField';
|
||||
|
||||
interface HighlightedOption {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -33,17 +34,7 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
|
||||
key: 'traceId',
|
||||
label: 'TRACE ID',
|
||||
render: (span): ReactNode | null =>
|
||||
span.trace_id ? (
|
||||
<Link
|
||||
to={{
|
||||
pathname: `/trace/${span.trace_id}`,
|
||||
search: window.location.search,
|
||||
}}
|
||||
className="span-details-panel__trace-id"
|
||||
>
|
||||
{span.trace_id}
|
||||
</Link>
|
||||
) : null,
|
||||
span.trace_id ? <TraceIdField span={span} /> : null,
|
||||
},
|
||||
{
|
||||
key: 'spanKind',
|
||||
@@ -51,4 +42,12 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
|
||||
render: (span): ReactNode | null =>
|
||||
span.kind_string ? <Badge color="vanilla">{span.kind_string}</Badge> : null,
|
||||
},
|
||||
{
|
||||
key: 'statusMessage',
|
||||
label: 'STATUS MESSAGE',
|
||||
render: (span): ReactNode | null =>
|
||||
span.status_message ? (
|
||||
<Badge color="vanilla">{span.status_message}</Badge>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { isV3PinnedAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
interface UseTracePinnedFieldsReturn {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads/writes V3 trace-details pinned attributes from the user-preference
|
||||
* `span_details_pinned_attributes` (cross-device sync). Drops legacy V2-format
|
||||
* entries from the rendered set so PrettyView never tries to render an
|
||||
* un-parseable path while the migration is in flight.
|
||||
*
|
||||
* Migration of V2 → V3 format is handled separately by
|
||||
* `useMigratePinnedAttributes`.
|
||||
*/
|
||||
export function useTracePinnedFields(): UseTracePinnedFieldsReturn {
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { mutate } = useMutation(updateUserPreferenceAPI);
|
||||
|
||||
const value = useMemo<string[]>(() => {
|
||||
const pref = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
);
|
||||
const arr = (pref?.value as string[] | undefined) ?? [];
|
||||
return arr.filter(isV3PinnedAttribute);
|
||||
}, [userPreferences]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(next: string[]) => {
|
||||
const existing = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
);
|
||||
if (existing) {
|
||||
updateUserPreferenceInContext({ ...existing, value: next });
|
||||
}
|
||||
mutate({
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
value: next,
|
||||
});
|
||||
},
|
||||
[userPreferences, updateUserPreferenceInContext, mutate],
|
||||
);
|
||||
|
||||
return { value, onChange };
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function EventTooltipContent({
|
||||
{eventName}
|
||||
</div>
|
||||
<div className="event-tooltip-content__time">
|
||||
{toFixed(time, 2)} {timeUnitName} from start
|
||||
{toFixed(time, 2)} {timeUnitName} since span start
|
||||
</div>
|
||||
{Object.keys(attributeMap).length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
.span-hover-card-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
// Invisible 1 px anchor mounted inside the scrollable waterfall body. Its
|
||||
// `top` is updated by the parent to track the hovered row's Y; its `left`
|
||||
// is the sidebar/timeline boundary so the popover always opens at the same
|
||||
// X regardless of which row is hovered.
|
||||
.span-hover-card-anchor {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.span-hover-card-popover {
|
||||
// Event-dot tooltip is rendered while the SpanDetailsPanel may be docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index 50.
|
||||
// Hover card may be rendered while the SpanDetailsPanel is docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
|
||||
--tooltip-z-index: 1000;
|
||||
|
||||
.ant-popover-inner {
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Flamegraph tooltip — rendered as a portal, uses same semantic tokens.
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { memo, ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { Popover } from 'antd';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { useMemo } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './SpanHoverCard.styles.scss';
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
/**
|
||||
* Span-level fields that the tooltip always shows (as the colored title or
|
||||
* one of the status/start/duration rows). Preview rows for these keys are
|
||||
* filtered out to avoid duplication.
|
||||
*/
|
||||
export const RESERVED_PREVIEW_KEYS: ReadonlySet<string> = new Set([
|
||||
'name',
|
||||
'has_error',
|
||||
'timestamp',
|
||||
'duration_nano',
|
||||
]);
|
||||
|
||||
export interface SpanPreviewRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SpanTooltipContentProps {
|
||||
@@ -19,6 +36,7 @@ export interface SpanTooltipContentProps {
|
||||
hasError: boolean;
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -27,6 +45,7 @@ export function SpanTooltipContent({
|
||||
hasError,
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
@@ -37,104 +56,118 @@ export function SpanTooltipContent({
|
||||
{spanName}
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
Status: {hasError ? 'error' : 'ok'}
|
||||
status: {hasError ? 'error' : 'ok'}
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
Start: {toFixed(relativeStartMs, 2)} ms
|
||||
start: {toFixed(relativeStartMs, 2)} ms
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
Duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
</div>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<div className="span-hover-card-content__preview">
|
||||
{previewRows.map((row) => (
|
||||
<div key={row.key} className="span-hover-card-content__row">
|
||||
<span className="span-hover-card-content__preview-key">{row.key}:</span>{' '}
|
||||
<span className="span-hover-card-content__preview-value">
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpanHoverCardProps {
|
||||
span: SpanV3;
|
||||
traceMetadata: ITraceMetadata;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Single hover card anchored at a fixed X (sidebar/timeline boundary). The
|
||||
* Y of the anchor is derived from the hovered span's index in the list,
|
||||
* so the card slides vertically in place rather than jumping with the cursor.
|
||||
*
|
||||
* Mount this inside the scrollable waterfall body so `anchorTop` is in
|
||||
* content coordinates — Radix portals the content layer out automatically.
|
||||
*/
|
||||
export interface SpanHoverCardProps {
|
||||
hoveredSpanId: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
anchorLeft: number;
|
||||
rowHeight: number;
|
||||
spans: SpanV3[];
|
||||
traceStartTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy hover card — only mounts the expensive antd Popover when the user
|
||||
* actually hovers over the element (after a short delay). During fast scrolling,
|
||||
* rows mount and unmount without ever creating a Popover instance, avoiding
|
||||
* expensive DOM/effect overhead from antd Tooltip/Trigger internals.
|
||||
*/
|
||||
const SpanHoverCard = memo(function SpanHoverCard({
|
||||
span,
|
||||
traceMetadata,
|
||||
children,
|
||||
export function SpanHoverCard({
|
||||
hoveredSpanId,
|
||||
onOpenChange,
|
||||
anchorLeft,
|
||||
rowHeight,
|
||||
spans,
|
||||
traceStartTime,
|
||||
}: SpanHoverCardProps): JSX.Element {
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { previewFields, resolveSpanColor } = useTraceContext();
|
||||
|
||||
const handleMouseEnter = useCallback((): void => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setShowPopover(true);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
const hoverCardData = useMemo(() => {
|
||||
if (!hoveredSpanId) {
|
||||
return null;
|
||||
}
|
||||
setShowPopover(false);
|
||||
}, []);
|
||||
const idx = spans.findIndex((s) => s.span_id === hoveredSpanId);
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
const span = spans[idx];
|
||||
const previewRows: SpanPreviewRow[] = previewFields
|
||||
.filter((f) => !RESERVED_PREVIEW_KEYS.has(f.key))
|
||||
.map((f) => {
|
||||
const value = getSpanAttribute(span, f.key);
|
||||
return value !== undefined && value !== ''
|
||||
? { key: f.key, value: String(value) }
|
||||
: null;
|
||||
})
|
||||
.filter((r): r is SpanPreviewRow => r !== null);
|
||||
|
||||
if (!showPopover) {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
|
||||
<span
|
||||
className="span-hover-card-wrapper"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const durationMs = span.duration_nano / 1e6;
|
||||
const relativeStartMs = span.timestamp - traceMetadata.startTime;
|
||||
|
||||
let color = generateColor(
|
||||
span['service.name'],
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
if (span.has_error) {
|
||||
color = 'var(--bg-cherry-500)';
|
||||
}
|
||||
return {
|
||||
anchorTop: idx * rowHeight,
|
||||
tooltip: {
|
||||
spanName: span.name,
|
||||
color: resolveSpanColor(span),
|
||||
hasError: span.has_error,
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
},
|
||||
};
|
||||
}, [
|
||||
hoveredSpanId,
|
||||
spans,
|
||||
previewFields,
|
||||
resolveSpanColor,
|
||||
rowHeight,
|
||||
traceStartTime,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open
|
||||
content={
|
||||
<SpanTooltipContent
|
||||
spanName={span.name}
|
||||
color={color}
|
||||
hasError={span.has_error}
|
||||
relativeStartMs={relativeStartMs}
|
||||
durationMs={durationMs}
|
||||
/>
|
||||
}
|
||||
trigger="hover"
|
||||
rootClassName="span-hover-card-popover"
|
||||
autoAdjustOverflow
|
||||
arrow={false}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setShowPopover(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
|
||||
<span className="span-hover-card-wrapper" onMouseLeave={handleMouseLeave}>
|
||||
{children}
|
||||
</span>
|
||||
</Popover>
|
||||
<TooltipProvider>
|
||||
<Tooltip open={hoverCardData !== null} onOpenChange={onOpenChange}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="span-hover-card-anchor"
|
||||
style={{
|
||||
top: hoverCardData?.anchorTop ?? 0,
|
||||
left: anchorLeft,
|
||||
height: rowHeight,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="span-hover-card-popover"
|
||||
>
|
||||
{hoverCardData && <SpanTooltipContent {...hoverCardData.tooltip} />}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
export default SpanHoverCard;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
.trace-details-header-wrapper {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trace-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -16,13 +21,53 @@
|
||||
&.trace-v3-filter-row {
|
||||
padding: 0;
|
||||
}
|
||||
max-width: 850px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&:not(&--expanded) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
max-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__old-view-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__sub-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 4px 16px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__sub-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__separator {
|
||||
color: var(--l2-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__entry-point-badge {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__skeleton .ant-skeleton-input {
|
||||
width: 160px !important;
|
||||
min-height: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton } from 'antd';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import history from 'lib/history';
|
||||
import { ArrowLeft } from '@signozhq/icons';
|
||||
import { ArrowLeft, CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import FieldsSettings from '../components/FieldsSettings/FieldsSettings';
|
||||
import { useTraceContext } from '../contexts/TraceContext';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
|
||||
import './TraceDetailsHeader.styles.scss';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
interface FilterMetadata {
|
||||
startTime: number;
|
||||
@@ -17,20 +29,53 @@ interface FilterMetadata {
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export interface TraceMetadataForHeader {
|
||||
startTimestampMillis: number;
|
||||
endTimestampMillis: number;
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
rootSpanStatusCode: string;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
filterMetadata: FilterMetadata;
|
||||
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
noData?: boolean;
|
||||
isDataLoaded?: boolean;
|
||||
traceMetadata?: TraceMetadataForHeader;
|
||||
}
|
||||
|
||||
const SKELETON_COUNT = 3;
|
||||
|
||||
function DetailsLoader(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: SKELETON_COUNT }).map((_, i) => (
|
||||
<Skeleton.Input
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
active
|
||||
size="small"
|
||||
className="trace-details-header__skeleton"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceDetailsHeader({
|
||||
filterMetadata,
|
||||
onFilteredSpansChange,
|
||||
noData,
|
||||
isDataLoaded,
|
||||
traceMetadata,
|
||||
}: TraceDetailsHeaderProps): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV2URLProps>();
|
||||
const [showTraceDetails, setShowTraceDetails] = useState(true);
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
const { previewFields, setPreviewFields } = useTraceContext();
|
||||
|
||||
const handleSwitchToOldView = useCallback((): void => {
|
||||
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW, 'true');
|
||||
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
|
||||
history.replace(oldUrl);
|
||||
}, [traceID]);
|
||||
@@ -49,42 +94,127 @@ function TraceDetailsHeader({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleTraceDetails = useCallback((): void => {
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const durationMs = traceMetadata
|
||||
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
|
||||
: 0;
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className="trace-details-header">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="trace-details-header__back-btn"
|
||||
onClick={handlePreviousBtnClick}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</Button>
|
||||
<KeyValueLabel
|
||||
badgeKey="Trace ID"
|
||||
badgeValue={traceID || ''}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
{!noData && (
|
||||
<>
|
||||
<div className="trace-details-header__filter">
|
||||
<Filters
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
traceID={filterMetadata.traceId}
|
||||
onFilteredSpansChange={onFilteredSpansChange}
|
||||
<div className="trace-details-header-wrapper">
|
||||
<div className="trace-details-header">
|
||||
{!isFilterExpanded && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
className="trace-details-header__back-btn"
|
||||
onClick={handlePreviousBtnClick}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</Button>
|
||||
<KeyValueLabel
|
||||
badgeKey="Trace ID"
|
||||
badgeValue={traceID || ''}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="trace-details-header__old-view-btn"
|
||||
onClick={handleSwitchToOldView}
|
||||
>
|
||||
Old View
|
||||
</Button>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
{isDataLoaded && (
|
||||
<>
|
||||
<div
|
||||
className={`trace-details-header__filter${
|
||||
isFilterExpanded ? ' trace-details-header__filter--expanded' : ''
|
||||
}`}
|
||||
>
|
||||
<Filters
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
traceID={filterMetadata.traceId}
|
||||
onFilteredSpansChange={onFilteredSpansChange}
|
||||
isExpanded={isFilterExpanded}
|
||||
onExpand={(): void => setIsFilterExpanded(true)}
|
||||
onCollapse={(): void => setIsFilterExpanded(false)}
|
||||
/>
|
||||
</div>
|
||||
{!isFilterExpanded && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="trace-details-header__old-view-btn"
|
||||
onClick={handleSwitchToOldView}
|
||||
>
|
||||
Old View
|
||||
</Button>
|
||||
<TraceOptionsMenu
|
||||
showTraceDetails={showTraceDetails}
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showTraceDetails && (
|
||||
<div className="trace-details-header__sub-header">
|
||||
{traceMetadata ? (
|
||||
<>
|
||||
<span className="trace-details-header__sub-item">
|
||||
<Server size={13} />
|
||||
{traceMetadata.rootServiceName}
|
||||
<span className="trace-details-header__separator">—</span>
|
||||
<span className="trace-details-header__entry-point-badge">
|
||||
{traceMetadata.rootServiceEntryPoint}
|
||||
</span>
|
||||
</span>
|
||||
<span className="trace-details-header__sub-item">
|
||||
<Timer size={13} />
|
||||
{parseFloat(formattedDuration.toFixed(2))} {timeUnitName}
|
||||
</span>
|
||||
<span className="trace-details-header__sub-item">
|
||||
<CalendarClock size={13} />
|
||||
{dayjs(traceMetadata.startTimestampMillis).format(
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
</span>
|
||||
{traceMetadata.rootSpanStatusCode && (
|
||||
<HttpStatusBadge statusCode={traceMetadata.rootSpanStatusCode} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPreviewFieldsOpen && (
|
||||
<FloatingPanel
|
||||
isOpen
|
||||
width={350}
|
||||
height={window.innerHeight - 100}
|
||||
defaultPosition={{
|
||||
x: window.innerWidth - 350 - 100,
|
||||
y: 50,
|
||||
}}
|
||||
enableResizing={false}
|
||||
>
|
||||
<FieldsSettings
|
||||
title="Preview fields"
|
||||
fields={previewFields}
|
||||
onFieldsChange={setPreviewFields}
|
||||
onClose={(): void => setIsPreviewFieldsOpen(false)}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</FloatingPanel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { MenuItem } from '@signozhq/ui';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
|
||||
import { useTraceContext } from '../contexts/TraceContext';
|
||||
|
||||
interface TraceOptionsMenuProps {
|
||||
showTraceDetails: boolean;
|
||||
onToggleTraceDetails: () => void;
|
||||
onOpenPreviewFields: () => void;
|
||||
}
|
||||
|
||||
function TraceOptionsMenu({
|
||||
showTraceDetails,
|
||||
onToggleTraceDetails,
|
||||
onOpenPreviewFields,
|
||||
}: TraceOptionsMenuProps): JSX.Element {
|
||||
const { colorByField, setColorByField, availableColorByOptions } =
|
||||
useTraceContext();
|
||||
|
||||
const menuItems: MenuItem[] = useMemo(() => {
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'toggle-trace-details',
|
||||
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
|
||||
onClick: onToggleTraceDetails,
|
||||
},
|
||||
{
|
||||
key: 'preview-fields',
|
||||
label: 'Preview fields',
|
||||
onClick: onOpenPreviewFields,
|
||||
},
|
||||
];
|
||||
|
||||
// Only show the "Colour by" submenu if there's an actual choice to make.
|
||||
if (availableColorByOptions.length > 1) {
|
||||
items.push({
|
||||
key: 'colour-by',
|
||||
label: 'Colour by',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
label: 'COLOUR BY',
|
||||
children: [
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: colorByField.name,
|
||||
onChange: (name: string): void => {
|
||||
const next = availableColorByOptions.find(
|
||||
(o) => o.field.name === name,
|
||||
);
|
||||
if (next) {
|
||||
setColorByField(next.field);
|
||||
}
|
||||
},
|
||||
children: availableColorByOptions.map((opt) => ({
|
||||
type: 'radio',
|
||||
key: opt.field.name,
|
||||
label: opt.label,
|
||||
value: opt.field.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
showTraceDetails,
|
||||
onToggleTraceDetails,
|
||||
onOpenPreviewFields,
|
||||
colorByField.name,
|
||||
setColorByField,
|
||||
availableColorByOptions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Ellipsis size={14} />}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceOptionsMenu;
|
||||
@@ -48,6 +48,10 @@
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__collapse-count-errors {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
&__flame-collapse {
|
||||
flex-shrink: 0;
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
hasError={tooltipContent.status === 'error'}
|
||||
relativeStartMs={tooltipContent.startMs}
|
||||
durationMs={tooltipContent.durationMs}
|
||||
previewRows={tooltipContent.previewRows}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
|
||||
@@ -5,7 +5,13 @@ import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { COLOR_BY_FIELDS } from '../constants';
|
||||
import { useTraceContext } from '../contexts/TraceContext';
|
||||
import Error from '../TraceWaterfall/TraceWaterfallStates/Error/Error';
|
||||
import {
|
||||
mergeTelemetryFieldKeys,
|
||||
toTelemetryFieldKey,
|
||||
} from '../utils/previewFields';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from './constants';
|
||||
import FlamegraphCanvas from './FlamegraphCanvas';
|
||||
import { useVisualLayoutWorker } from './hooks/useVisualLayoutWorker';
|
||||
@@ -44,6 +50,19 @@ function TraceFlamegraph({
|
||||
[history, search],
|
||||
);
|
||||
|
||||
const { previewFields } = useTraceContext();
|
||||
|
||||
// Color-by fields baseline + user-picked preview fields. De-duped by `name`,
|
||||
// color-by entries first so their canonical metadata wins on collision.
|
||||
const flamegraphSelectFields = useMemo(
|
||||
() =>
|
||||
mergeTelemetryFieldKeys(
|
||||
COLOR_BY_FIELDS,
|
||||
previewFields.map(toTelemetryFieldKey),
|
||||
),
|
||||
[previewFields],
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
@@ -52,6 +71,7 @@ function TraceFlamegraph({
|
||||
traceId,
|
||||
// selectedSpanId: firstSpanAtFetchLevel,
|
||||
limit: FLAMEGRAPH_SPAN_LIMIT,
|
||||
selectFields: flamegraphSelectFields,
|
||||
});
|
||||
|
||||
const spans = useMemo(
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import type React from 'react';
|
||||
import React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
|
||||
import { TraceProvider } from '../../contexts/TraceContext';
|
||||
import { useFlamegraphHover } from '../hooks/useFlamegraphHover';
|
||||
import type { SpanRect } from '../types';
|
||||
import { MOCK_SPAN, MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return (
|
||||
<AllTheProviders>
|
||||
<TraceProvider aggregations={undefined}>{children}</TraceProvider>
|
||||
</AllTheProviders>
|
||||
);
|
||||
}
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 800;
|
||||
canvas.height = 400;
|
||||
canvas.getBoundingClientRect = jest.fn(
|
||||
jest.spyOn(canvas, 'getBoundingClientRect').mockImplementation(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left: 0,
|
||||
@@ -59,7 +69,9 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('sets hoveredSpanId and tooltipContent when hovering on span', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
@@ -76,7 +88,9 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('clears hover when moving to empty area', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
@@ -99,7 +113,9 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('clears hover on mouse leave', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
@@ -117,7 +133,9 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('suppresses click when drag distance exceeds threshold', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDownForClick({
|
||||
@@ -137,7 +155,9 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('calls onSpanClick when clicking on span', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick({
|
||||
@@ -150,7 +170,9 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('uses clientX/clientY for tooltip positioning', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
@@ -164,7 +186,9 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('does not update hover during drag', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
defaultArgs.isDraggingRef.current = true;
|
||||
|
||||
act(() => {
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getSpanColor } from '../utils';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { getFlamegraphSpanGroupValue, getSpanColor } from '../utils';
|
||||
import { MOCK_SPAN } from './testUtils';
|
||||
|
||||
const mockGenerateColor = jest.fn();
|
||||
@@ -8,6 +10,17 @@ jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
mockGenerateColor(key, colorMap),
|
||||
}));
|
||||
|
||||
const SERVICE_FIELD: TelemetryFieldKey = {
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
};
|
||||
const HOST_FIELD: TelemetryFieldKey = {
|
||||
name: 'host.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
};
|
||||
|
||||
describe('Presentation / Styling Utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -15,16 +28,17 @@ describe('Presentation / Styling Utils', () => {
|
||||
});
|
||||
|
||||
describe('getSpanColor', () => {
|
||||
it('uses generated service color for normal span', () => {
|
||||
it('uses generated colour from groupValue for normal span', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: false },
|
||||
isDarkMode: false,
|
||||
groupValue: 'my-bucket',
|
||||
});
|
||||
|
||||
expect(mockGenerateColor).toHaveBeenCalledWith(
|
||||
MOCK_SPAN.serviceName,
|
||||
'my-bucket',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(color).toBe('#1890ff');
|
||||
@@ -36,6 +50,7 @@ describe('Presentation / Styling Utils', () => {
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: true },
|
||||
isDarkMode: false,
|
||||
groupValue: 'my-bucket',
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(220, 38, 38)');
|
||||
@@ -47,21 +62,50 @@ describe('Presentation / Styling Utils', () => {
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: true },
|
||||
isDarkMode: true,
|
||||
groupValue: 'my-bucket',
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(239, 68, 68)');
|
||||
});
|
||||
});
|
||||
|
||||
it('passes serviceName to generateColor', () => {
|
||||
getSpanColor({
|
||||
span: { ...MOCK_SPAN, serviceName: 'my-service' },
|
||||
isDarkMode: false,
|
||||
});
|
||||
|
||||
expect(mockGenerateColor).toHaveBeenCalledWith(
|
||||
'my-service',
|
||||
expect.any(Object),
|
||||
describe('getFlamegraphSpanGroupValue', () => {
|
||||
it('returns resource[field.name] when present', () => {
|
||||
const value = getFlamegraphSpanGroupValue(
|
||||
{
|
||||
serviceName: 'legacy',
|
||||
resource: { 'service.name': 'svc-from-resource' },
|
||||
},
|
||||
SERVICE_FIELD,
|
||||
);
|
||||
expect(value).toBe('svc-from-resource');
|
||||
});
|
||||
|
||||
it('falls back to top-level serviceName for service.name when resource is empty', () => {
|
||||
const value = getFlamegraphSpanGroupValue(
|
||||
{ serviceName: 'svc-legacy', resource: {} },
|
||||
SERVICE_FIELD,
|
||||
);
|
||||
expect(value).toBe('svc-legacy');
|
||||
});
|
||||
|
||||
it('returns "unknown" for non-service fields when resource is missing', () => {
|
||||
const value = getFlamegraphSpanGroupValue(
|
||||
{ serviceName: 'svc', resource: {} },
|
||||
HOST_FIELD,
|
||||
);
|
||||
expect(value).toBe('unknown');
|
||||
});
|
||||
|
||||
it('reads host.name from resource when present', () => {
|
||||
const value = getFlamegraphSpanGroupValue(
|
||||
{
|
||||
serviceName: 'svc',
|
||||
resource: { 'host.name': 'host-1' },
|
||||
},
|
||||
HOST_FIELD,
|
||||
);
|
||||
expect(value).toBe('host-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface ConnectorLine {
|
||||
childRow: number;
|
||||
timestampMs: number;
|
||||
serviceName: string;
|
||||
// Snapshot of the child span's resource so draw-time can resolve the
|
||||
// `colorByField` group value without crossing the worker boundary.
|
||||
resource?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface VisualLayout {
|
||||
@@ -357,6 +360,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
|
||||
childRow,
|
||||
timestampMs: child.timestamp,
|
||||
serviceName: child.serviceName,
|
||||
resource: child.resource,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { RefObject, useCallback, useMemo, useRef } from 'react';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { ConnectorLine } from '../computeVisualLayout';
|
||||
import { EventRect, SpanRect } from '../types';
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
drawSpanBar,
|
||||
FlamegraphRowMetrics,
|
||||
getFlamegraphRowMetrics,
|
||||
getFlamegraphSpanGroupValue,
|
||||
getSpanColor,
|
||||
} from '../utils';
|
||||
|
||||
@@ -51,6 +54,7 @@ interface DrawLevelArgs {
|
||||
selectedSpanId: string | undefined;
|
||||
hoveredSpanId: string;
|
||||
isDarkMode: boolean;
|
||||
colorByField: TelemetryFieldKey;
|
||||
spanRectsArray: SpanRect[];
|
||||
eventRectsArray: EventRect[];
|
||||
metrics: FlamegraphRowMetrics;
|
||||
@@ -71,6 +75,7 @@ function drawLevel(args: DrawLevelArgs): void {
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
colorByField,
|
||||
spanRectsArray,
|
||||
eventRectsArray,
|
||||
metrics,
|
||||
@@ -112,7 +117,8 @@ function drawLevel(args: DrawLevelArgs): void {
|
||||
// Minimum 1px width so tiny spans remain visible
|
||||
width = clamp(width, 1, Infinity);
|
||||
|
||||
const color = getSpanColor({ span, isDarkMode });
|
||||
const groupValue = getFlamegraphSpanGroupValue(span, colorByField);
|
||||
const color = getSpanColor({ span, isDarkMode, groupValue });
|
||||
|
||||
const isDimmedByFilter =
|
||||
!!isFilterActiveInLevel &&
|
||||
@@ -148,6 +154,7 @@ interface DrawConnectorLinesArgs {
|
||||
cssWidth: number;
|
||||
viewportHeight: number;
|
||||
metrics: FlamegraphRowMetrics;
|
||||
colorByField: TelemetryFieldKey;
|
||||
}
|
||||
|
||||
function drawConnectorLines(args: DrawConnectorLinesArgs): void {
|
||||
@@ -160,6 +167,7 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
|
||||
cssWidth,
|
||||
viewportHeight,
|
||||
metrics,
|
||||
colorByField,
|
||||
} = args;
|
||||
|
||||
ctx.save();
|
||||
@@ -185,10 +193,11 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
|
||||
continue;
|
||||
}
|
||||
|
||||
const color = generateColor(
|
||||
conn.serviceName,
|
||||
themeColors.traceDetailColorsV3,
|
||||
const groupValue = getFlamegraphSpanGroupValue(
|
||||
{ serviceName: conn.serviceName, resource: conn.resource },
|
||||
colorByField,
|
||||
);
|
||||
const color = generateColor(groupValue, themeColors.traceDetailColorsV3);
|
||||
ctx.strokeStyle = color;
|
||||
|
||||
const x = clamp(xFrac * cssWidth, 0, cssWidth);
|
||||
@@ -228,11 +237,10 @@ export function useFlamegraphDraw(
|
||||
const eventRectsRefInternal = useRef<EventRect[]>([]);
|
||||
const eventRectsRef = eventRectsRefProp ?? eventRectsRefInternal;
|
||||
|
||||
const { colorByField } = useTraceContext();
|
||||
|
||||
const filteredSpanIdsSet = useMemo(
|
||||
() =>
|
||||
isFilterActive && filteredSpanIds && filteredSpanIds.length > 0
|
||||
? new Set(filteredSpanIds)
|
||||
: null,
|
||||
() => (isFilterActive && filteredSpanIds ? new Set(filteredSpanIds) : null),
|
||||
[filteredSpanIds, isFilterActive],
|
||||
);
|
||||
|
||||
@@ -285,6 +293,7 @@ export function useFlamegraphDraw(
|
||||
cssWidth,
|
||||
viewportHeight,
|
||||
metrics,
|
||||
colorByField,
|
||||
});
|
||||
|
||||
const spanRectsArray: SpanRect[] = [];
|
||||
@@ -309,6 +318,7 @@ export function useFlamegraphDraw(
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
colorByField,
|
||||
spanRectsArray,
|
||||
eventRectsArray,
|
||||
metrics,
|
||||
@@ -335,6 +345,7 @@ export function useFlamegraphDraw(
|
||||
hoveredSpanId,
|
||||
hoveredEventKey,
|
||||
isDarkMode,
|
||||
colorByField,
|
||||
filteredSpanIdsSet,
|
||||
isFilterActive,
|
||||
]);
|
||||
|
||||
@@ -8,11 +8,18 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
|
||||
import { RESERVED_PREVIEW_KEYS } from 'pages/TraceDetailsV3/SpanHoverCard/SpanHoverCard';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { EventRect, SpanRect } from '../types';
|
||||
import { ITraceMetadata } from '../types';
|
||||
import { getSpanColor } from '../utils';
|
||||
import {
|
||||
getFlamegraphServiceName,
|
||||
getFlamegraphSpanGroupValue,
|
||||
getSpanColor,
|
||||
} from '../utils';
|
||||
|
||||
function getCanvasPointer(
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -69,6 +76,11 @@ export interface EventTooltipData {
|
||||
attributeMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SpanPreviewRowData {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface TooltipContent {
|
||||
serviceName: string;
|
||||
spanName: string;
|
||||
@@ -78,6 +90,7 @@ export interface TooltipContent {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
spanColor: string;
|
||||
previewRows?: SpanPreviewRowData[];
|
||||
event?: EventTooltipData;
|
||||
}
|
||||
|
||||
@@ -125,6 +138,25 @@ export function useFlamegraphHover(
|
||||
null,
|
||||
);
|
||||
|
||||
const { colorByField, previewFields } = useTraceContext();
|
||||
|
||||
const buildPreviewRows = useCallback(
|
||||
(span: FlamegraphSpan): SpanPreviewRowData[] =>
|
||||
previewFields
|
||||
.filter((field) => !RESERVED_PREVIEW_KEYS.has(field.key))
|
||||
.map((field) => {
|
||||
const value = getSpanAttribute(
|
||||
{ resource: span.resource, attributes: span.attributes },
|
||||
field.key,
|
||||
);
|
||||
return value !== undefined && value !== ''
|
||||
? { key: field.key, value: String(value) }
|
||||
: null;
|
||||
})
|
||||
.filter((r): r is SpanPreviewRowData => r !== null),
|
||||
[previewFields],
|
||||
);
|
||||
|
||||
const isZoomed =
|
||||
viewStartTs !== traceMetadata.startTime ||
|
||||
viewEndTs !== traceMetadata.endTime;
|
||||
@@ -171,14 +203,18 @@ export function useFlamegraphHover(
|
||||
setHoveredEventKey(`${span.spanId}-${event.name}-${event.timeUnixNano}`);
|
||||
setHoveredSpanId(span.spanId);
|
||||
setTooltipContent({
|
||||
serviceName: span.serviceName || '',
|
||||
serviceName: getFlamegraphServiceName(span),
|
||||
spanName: span.name || 'unknown',
|
||||
status: span.hasError ? 'error' : 'ok',
|
||||
startMs: span.timestamp - traceMetadata.startTime,
|
||||
durationMs: span.durationNano / 1e6,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
spanColor: getSpanColor({ span, isDarkMode }),
|
||||
spanColor: getSpanColor({
|
||||
span,
|
||||
isDarkMode,
|
||||
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
|
||||
}),
|
||||
event: {
|
||||
name: event.name,
|
||||
timeOffsetMs: eventTimeMs - span.timestamp,
|
||||
@@ -200,14 +236,19 @@ export function useFlamegraphHover(
|
||||
setHoveredEventKey(null);
|
||||
setHoveredSpanId(span.spanId);
|
||||
setTooltipContent({
|
||||
serviceName: span.serviceName || '',
|
||||
serviceName: getFlamegraphServiceName(span),
|
||||
spanName: span.name || 'unknown',
|
||||
status: span.hasError ? 'error' : 'ok',
|
||||
startMs: span.timestamp - traceMetadata.startTime,
|
||||
durationMs: span.durationNano / 1e6,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
spanColor: getSpanColor({ span, isDarkMode }),
|
||||
spanColor: getSpanColor({
|
||||
span,
|
||||
isDarkMode,
|
||||
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
|
||||
}),
|
||||
previewRows: buildPreviewRows(span),
|
||||
});
|
||||
updateCursor(canvas, span);
|
||||
} else {
|
||||
@@ -225,6 +266,8 @@ export function useFlamegraphHover(
|
||||
isDraggingRef,
|
||||
updateCursor,
|
||||
isDarkMode,
|
||||
colorByField,
|
||||
buildPreviewRows,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
DASHED_BORDER_LINE_DASH,
|
||||
@@ -66,14 +68,47 @@ export function getFlamegraphRowMetrics(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the displayed service.name for a flamegraph span. Used by tooltips
|
||||
* (service identity, independent of the active colour-by field). Prefers
|
||||
* `resource['service.name']` with legacy top-level `serviceName` fallback.
|
||||
*/
|
||||
export function getFlamegraphServiceName(
|
||||
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
|
||||
): string {
|
||||
return getSpanAttribute(span, 'service.name') || span.serviceName || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the value used to bucket a flamegraph span by colour for the given
|
||||
* field. Prefers `resource[field.name]` (new contract from `selectFields`).
|
||||
* For `service.name`, falls back to the legacy top-level `serviceName` when
|
||||
* resource is empty (backward-compat with backends that haven't shipped
|
||||
* `selectFields` yet). For other fields, falls back to `'unknown'`.
|
||||
*/
|
||||
export function getFlamegraphSpanGroupValue(
|
||||
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
|
||||
field: TelemetryFieldKey,
|
||||
): string {
|
||||
const fromAttribute = getSpanAttribute(span, field.name);
|
||||
if (fromAttribute) {
|
||||
return fromAttribute;
|
||||
}
|
||||
if (field.name === 'service.name') {
|
||||
return span.serviceName || 'unknown';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
interface GetSpanColorArgs {
|
||||
span: FlamegraphSpan;
|
||||
isDarkMode: boolean;
|
||||
groupValue: string;
|
||||
}
|
||||
|
||||
export function getSpanColor(args: GetSpanColorArgs): string {
|
||||
const { span, isDarkMode } = args;
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
|
||||
const { span, isDarkMode, groupValue } = args;
|
||||
let color = generateColor(groupValue, themeColors.traceDetailColorsV3);
|
||||
|
||||
if (span.hasError) {
|
||||
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
@@ -211,7 +246,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
// Alpha is applied to bar + events only; label is drawn after restoring alpha to 1
|
||||
// so text stays readable against the faded bar.
|
||||
if (shouldDim) {
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.globalAlpha = 0.15;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
@@ -11,13 +11,9 @@ import NoData from './TraceWaterfallStates/NoData/NoData';
|
||||
import Success from './TraceWaterfallStates/Success/Success';
|
||||
import { getVisibleSpans } from './utils';
|
||||
|
||||
import './TraceWaterfall.styles.scss';
|
||||
import { IInterestedSpan } from './types';
|
||||
|
||||
export interface IInterestedSpan {
|
||||
spanId: string;
|
||||
isUncollapsed: boolean;
|
||||
scrollToSpan?: boolean;
|
||||
}
|
||||
import './TraceWaterfall.styles.scss';
|
||||
|
||||
interface ITraceWaterfallProps {
|
||||
traceId: string;
|
||||
|
||||
@@ -3,18 +3,114 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
&.expanded {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filter-search-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.query-builder-search-v2 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// --- Collapsed pill ---
|
||||
.filter-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
max-width: 220px;
|
||||
min-width: 120px;
|
||||
height: 32px;
|
||||
background: var(--l1-background);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-background);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Collapsed pill popover ---
|
||||
.filter-pill-popover {
|
||||
max-width: 400px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__expression {
|
||||
font-family: 'Geist Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-all;
|
||||
padding: 6px 8px;
|
||||
background: var(--l2-background);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// --- ToggleGroup override: size to content, don't stretch items ---
|
||||
[class*='toggle-group'] {
|
||||
flex-shrink: 0;
|
||||
|
||||
[class*='toggle-group-item'] {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Collapse button ---
|
||||
.filter-collapse-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// --- Highlight errors toggle ---
|
||||
.highlight-errors-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// --- Prev/next navigation ---
|
||||
.pre-next-toggle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
|
||||
.ant-typography {
|
||||
&__count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: auto;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
@@ -30,14 +126,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
.filter-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
|
||||
&--error {
|
||||
color: var(--destructive);
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button, Spin, Tooltip } from 'antd';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Info,
|
||||
Loader,
|
||||
Search,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Switch, ToggleGroup, ToggleGroupItem, toast } from '@signozhq/ui';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { LoaderCircle, Info, ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import {
|
||||
DataSource,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import { BASE_FILTER_QUERY } from './constants';
|
||||
import { useHighlightErrors } from './hooks/useHighlightErrors';
|
||||
import {
|
||||
SpanCategory,
|
||||
useSpanCategoryFilter,
|
||||
} from './hooks/useSpanCategoryFilter';
|
||||
|
||||
import './Filters.styles.scss';
|
||||
|
||||
@@ -44,6 +69,7 @@ function prepareQuery(filters: TagFilter, traceID: string): Query {
|
||||
},
|
||||
],
|
||||
},
|
||||
selectColumns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -55,31 +81,87 @@ function Filters({
|
||||
endTime,
|
||||
traceID,
|
||||
onFilteredSpansChange = (): void => {},
|
||||
isExpanded,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
}: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceID: string;
|
||||
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
isExpanded: boolean;
|
||||
onExpand: () => void;
|
||||
onCollapse: () => void;
|
||||
}): JSX.Element {
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const [filters, setFilters] = useState<TagFilter>(
|
||||
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
|
||||
);
|
||||
const [expression, setExpression] = useState<string>('');
|
||||
const [noData, setNoData] = useState<boolean>(false);
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
|
||||
const expressionRef = useRef<string>('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(value: TagFilter): void => {
|
||||
if (value.items.length === 0) {
|
||||
const runQuery = useCallback(
|
||||
(value: string): void => {
|
||||
const items = convertExpressionToFilters(value);
|
||||
setFilters({ items, op: 'AND' });
|
||||
// Clear results when expression produces no filters
|
||||
if (items.length === 0) {
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], false);
|
||||
setCurrentSearchedIndex(0);
|
||||
setNoData(false);
|
||||
}
|
||||
setFilters(value);
|
||||
},
|
||||
[onFilteredSpansChange],
|
||||
);
|
||||
|
||||
// onChange fires on every keystroke — only store the expression, don't trigger API
|
||||
const handleExpressionChange = useCallback(
|
||||
(value: string): void => {
|
||||
setExpression(value);
|
||||
expressionRef.current = value;
|
||||
// Clear results when expression is emptied
|
||||
if (!value.trim()) {
|
||||
setFilters({ items: [], op: 'AND' });
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], false);
|
||||
setCurrentSearchedIndex(0);
|
||||
setNoData(false);
|
||||
}
|
||||
},
|
||||
[onFilteredSpansChange],
|
||||
);
|
||||
|
||||
// onRun fires on Ctrl+Enter
|
||||
const handleRunQuery = useCallback(
|
||||
(value: string): void => {
|
||||
runQuery(value);
|
||||
},
|
||||
[runQuery],
|
||||
);
|
||||
|
||||
// Run query on blur (click outside the filter input)
|
||||
const handleBlur = useCallback((): void => {
|
||||
runQuery(expressionRef.current);
|
||||
}, [runQuery]);
|
||||
|
||||
// Expression-based filter hooks
|
||||
const filterProps = {
|
||||
expression,
|
||||
filters,
|
||||
setExpression,
|
||||
expressionRef,
|
||||
runQuery,
|
||||
};
|
||||
const { isHighlightErrors, handleToggle: handleToggleHighlightErrors } =
|
||||
useHighlightErrors(filterProps);
|
||||
const { selectedCategory, categories, handleCategoryChange } =
|
||||
useSpanCategoryFilter(filterProps);
|
||||
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
@@ -110,14 +192,14 @@ function Filters({
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
limit: 10000,
|
||||
},
|
||||
selectColumns: [
|
||||
{
|
||||
key: 'name',
|
||||
key: 'spanID',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'name--string--tag--true',
|
||||
id: 'spanId--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
],
|
||||
@@ -147,58 +229,187 @@ function Filters({
|
||||
setCurrentSearchedIndex(0);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
const isFilterActive = filters.items.length > 0;
|
||||
setNoData(false);
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], isFilterActive);
|
||||
setCurrentSearchedIndex(0);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="trace-v3-filter-row">
|
||||
<QueryBuilderSearchV2
|
||||
query={{
|
||||
...BASE_FILTER_QUERY,
|
||||
filters,
|
||||
}}
|
||||
onChange={handleFilterChange}
|
||||
hideSpanScopeSelector={false}
|
||||
skipQueryBuilderRedirect
|
||||
selectProps={{ listHeight: 125 }}
|
||||
const highlightErrorsToggle = (
|
||||
<div className="highlight-errors-toggle">
|
||||
<Typography.Text>Highlight errors</Typography.Text>
|
||||
<Switch
|
||||
color="cherry"
|
||||
value={isHighlightErrors}
|
||||
onChange={handleToggleHighlightErrors}
|
||||
/>
|
||||
{filteredSpanIds.length > 0 && (
|
||||
<div className="pre-next-toggle">
|
||||
<Typography.Text>
|
||||
{currentSearchedIndex + 1} / {filteredSpanIds.length}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
icon={<ChevronUp size={14} />}
|
||||
disabled={currentSearchedIndex === 0}
|
||||
type="text"
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex - 1);
|
||||
setCurrentSearchedIndex((prev) => prev - 1);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ChevronDown size={14} />}
|
||||
type="text"
|
||||
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex + 1);
|
||||
setCurrentSearchedIndex((prev) => prev + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isFetching && (
|
||||
<Spin indicator={<LoaderCircle className="animate-spin" />} size="small" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const statusIndicators = (
|
||||
<>
|
||||
{isFetching && <Loader className="animate-spin" />}
|
||||
{error && (
|
||||
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
|
||||
<Info size={14} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="filter-status filter-status--error">
|
||||
<Info />
|
||||
API error
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(error as AxiosError)?.message || 'Something went wrong'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{noData && (
|
||||
<Typography.Text className="no-results">No results found</Typography.Text>
|
||||
{!error && noData && (
|
||||
<Typography.Text className="filter-status">
|
||||
No results found
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// --- COLLAPSED VIEW ---
|
||||
if (!isExpanded) {
|
||||
const pill = (
|
||||
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
|
||||
<div className="filter-pill" onClick={onExpand}>
|
||||
<Search size={12} />
|
||||
<span className="filter-pill__text">{expression || 'Search...'}</span>
|
||||
{expression && <span className="filter-pill__indicator" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="trace-v3-filter-row collapsed">
|
||||
{expression ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{pill}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<div className="filter-pill-popover">
|
||||
<div className="filter-pill-popover__header">
|
||||
<Typography.Text>Search query</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void => {
|
||||
setCopy(expression);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: false,
|
||||
position: 'top-right',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="filter-pill-popover__expression">{expression}</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
pill
|
||||
)}
|
||||
{highlightErrorsToggle}
|
||||
{statusIndicators}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- EXPANDED VIEW ---
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="trace-v3-filter-row expanded">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={selectedCategory}
|
||||
onChange={(value): void => {
|
||||
if (value) {
|
||||
handleCategoryChange(value as SpanCategory);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<ToggleGroupItem key={category} value={category}>
|
||||
{category}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="filter-search-container"
|
||||
ref={containerRef}
|
||||
onBlur={(e): void => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
|
||||
handleBlur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<QuerySearch
|
||||
queryData={{
|
||||
...BASE_FILTER_QUERY,
|
||||
filters,
|
||||
filter: { expression },
|
||||
}}
|
||||
onChange={handleExpressionChange}
|
||||
onRun={handleRunQuery}
|
||||
dataSource={DataSource.TRACES}
|
||||
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
|
||||
/>
|
||||
</div>
|
||||
{filteredSpanIds.length > 0 && (
|
||||
<div className="pre-next-toggle">
|
||||
<Typography.Text className="pre-next-toggle__count">
|
||||
{currentSearchedIndex + 1} / {filteredSpanIds.length}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
disabled={currentSearchedIndex === 0}
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex - 1);
|
||||
setCurrentSearchedIndex((prev) => prev - 1);
|
||||
}}
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex + 1);
|
||||
setCurrentSearchedIndex((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="filter-collapse-btn"
|
||||
onClick={onCollapse}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
{highlightErrorsToggle}
|
||||
{statusIndicators}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* Shared props for expression-based filter hooks.
|
||||
* Each hook reads the current expression + derived filters,
|
||||
* and manipulates the expression via remove/add pattern.
|
||||
*/
|
||||
export interface ExpressionFilterProps {
|
||||
expression: string;
|
||||
filters: TagFilter;
|
||||
setExpression: (expr: string) => void;
|
||||
expressionRef: MutableRefObject<string>;
|
||||
runQuery: (expr: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: update expression state, ref, and trigger query.
|
||||
*/
|
||||
export function applyExpression(
|
||||
newExpression: string,
|
||||
props: Pick<
|
||||
ExpressionFilterProps,
|
||||
'setExpression' | 'expressionRef' | 'runQuery'
|
||||
>,
|
||||
): void {
|
||||
props.setExpression(newExpression);
|
||||
props.expressionRef.current = newExpression;
|
||||
props.runQuery(newExpression);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
|
||||
import { applyExpression, ExpressionFilterProps } from './types';
|
||||
|
||||
interface UseHighlightErrorsReturn {
|
||||
isHighlightErrors: boolean;
|
||||
handleToggle: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const ERROR_KEY = 'has_error';
|
||||
|
||||
export function useHighlightErrors(
|
||||
props: ExpressionFilterProps,
|
||||
): UseHighlightErrorsReturn {
|
||||
const { expression, filters, setExpression, expressionRef, runQuery } = props;
|
||||
|
||||
// Derive from filters (only updates after runQuery, not on every keystroke)
|
||||
const isHighlightErrors = useMemo(
|
||||
() =>
|
||||
filters.items.some(
|
||||
(item) =>
|
||||
item.key?.key === ERROR_KEY &&
|
||||
(item.value === true || item.value === 'true'),
|
||||
),
|
||||
[filters],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(checked: boolean): void => {
|
||||
// Always remove existing has_error first (whatever its value)
|
||||
let newExpr = removeKeysFromExpression(expression, [ERROR_KEY]);
|
||||
// Add back if turning ON
|
||||
if (checked) {
|
||||
newExpr = newExpr.trim()
|
||||
? `${newExpr.trim()} AND has_error = true`
|
||||
: `has_error = true`;
|
||||
}
|
||||
applyExpression(newExpr, { setExpression, expressionRef, runQuery });
|
||||
},
|
||||
[expression, setExpression, expressionRef, runQuery],
|
||||
);
|
||||
|
||||
return { isHighlightErrors, handleToggle };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
|
||||
import { applyExpression, ExpressionFilterProps } from './types';
|
||||
|
||||
export type SpanCategory =
|
||||
| 'All'
|
||||
| 'Database'
|
||||
| 'Functions'
|
||||
| 'HTTP'
|
||||
| 'Jobs'
|
||||
| 'LLM';
|
||||
|
||||
export const SPAN_CATEGORIES: readonly SpanCategory[] = [
|
||||
'All',
|
||||
'Database',
|
||||
'Functions',
|
||||
'HTTP',
|
||||
'Jobs',
|
||||
'LLM',
|
||||
];
|
||||
|
||||
// Map each category to the attribute key it filters on
|
||||
const CATEGORY_KEYS: Record<Exclude<SpanCategory, 'All'>, string> = {
|
||||
Database: 'db.system',
|
||||
HTTP: 'http.method',
|
||||
Functions: 'kind_string',
|
||||
Jobs: 'messaging.system',
|
||||
LLM: 'gen_ai.request.model',
|
||||
};
|
||||
|
||||
// All category keys — used for bulk removal when switching categories
|
||||
const ALL_CATEGORY_KEYS = Object.values(CATEGORY_KEYS);
|
||||
|
||||
// The expression clause to add for each category
|
||||
const CATEGORY_EXPRESSIONS: Record<Exclude<SpanCategory, 'All'>, string> = {
|
||||
Database: 'db.system exists',
|
||||
HTTP: 'http.method exists',
|
||||
Functions: "kind_string = 'Internal'",
|
||||
Jobs: 'messaging.system exists',
|
||||
LLM: 'gen_ai.request.model exists',
|
||||
};
|
||||
|
||||
interface UseSpanCategoryFilterReturn {
|
||||
selectedCategory: SpanCategory;
|
||||
categories: readonly SpanCategory[];
|
||||
handleCategoryChange: (category: SpanCategory) => void;
|
||||
}
|
||||
|
||||
export function useSpanCategoryFilter(
|
||||
props: ExpressionFilterProps,
|
||||
): UseSpanCategoryFilterReturn {
|
||||
const { expression, filters, setExpression, expressionRef, runQuery } = props;
|
||||
|
||||
// Derive active category from filters (only updates after runQuery)
|
||||
const selectedCategory = useMemo((): SpanCategory => {
|
||||
for (const [category, key] of Object.entries(CATEGORY_KEYS)) {
|
||||
if (filters.items.some((item) => item.key?.key === key)) {
|
||||
return category as SpanCategory;
|
||||
}
|
||||
}
|
||||
return 'All';
|
||||
}, [filters]);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(category: SpanCategory): void => {
|
||||
// Remove ALL category keys first
|
||||
let newExpr = removeKeysFromExpression(expression, ALL_CATEGORY_KEYS);
|
||||
// Add the selected category clause (unless "All")
|
||||
if (category !== 'All') {
|
||||
const clause = CATEGORY_EXPRESSIONS[category];
|
||||
newExpr = newExpr.trim() ? `${newExpr.trim()} AND ${clause}` : clause;
|
||||
}
|
||||
applyExpression(newExpr, { setExpression, expressionRef, runQuery });
|
||||
},
|
||||
[expression, setExpression, expressionRef, runQuery],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedCategory,
|
||||
categories: SPAN_CATEGORIES,
|
||||
handleCategoryChange,
|
||||
};
|
||||
}
|
||||
@@ -131,6 +131,24 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Invisible IntersectionObserver targets pinned at the top and bottom of
|
||||
// the virtualized content. See `useBoundaryPagination`.
|
||||
.waterfall-load-more-sentinel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
|
||||
&--top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&--bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.waterfall-sidebar {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
@@ -243,7 +261,7 @@
|
||||
}
|
||||
|
||||
&.dimmed-span {
|
||||
opacity: 0.4;
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,7 +333,7 @@
|
||||
|
||||
.tree-connector {
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
width: 14px;
|
||||
height: 50%;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
@@ -323,6 +341,16 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Reserved horizontal space for the chevron — present on every row,
|
||||
// filled only when the span has children. Keeps sibling icons aligned.
|
||||
.tree-arrow-slot {
|
||||
width: 18px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tree-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -339,6 +367,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Reserved horizontal space for the subtree-count badge — same reason.
|
||||
// Right-aligns the badge inside so single-digit counts don't push the
|
||||
// icon left of where multi-digit counts would put it.
|
||||
.subtree-count-slot {
|
||||
min-width: 20px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.subtree-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -539,7 +579,7 @@
|
||||
}
|
||||
|
||||
.dimmed-span {
|
||||
opacity: 0.4;
|
||||
opacity: 0.15;
|
||||
}
|
||||
.highlighted-span {
|
||||
opacity: 1;
|
||||
|
||||
@@ -27,12 +27,11 @@ import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
|
||||
import cx from 'classnames';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { colorToRgb, generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
@@ -41,15 +40,17 @@ import {
|
||||
Link,
|
||||
ListPlus,
|
||||
} from '@signozhq/icons';
|
||||
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
|
||||
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
|
||||
import { useCrosshair } from 'pages/TraceDetailsV3/hooks/useCrosshair';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { EventV3, SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
|
||||
import SpanHoverCard from '../../../SpanHoverCard/SpanHoverCard';
|
||||
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
|
||||
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||
import { IInterestedSpan } from '../../TraceWaterfall';
|
||||
import { IInterestedSpan } from '../../types';
|
||||
|
||||
import './Success.styles.scss';
|
||||
|
||||
@@ -76,7 +77,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseEnter = useCallback((): void => {
|
||||
timerRef.current = setTimeout(() => setShowPopover(true), 150);
|
||||
timerRef.current = setTimeout(() => setShowPopover(true), 200);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
@@ -133,7 +134,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
|
||||
});
|
||||
|
||||
// css config
|
||||
const CONNECTOR_WIDTH = 20;
|
||||
const CONNECTOR_WIDTH = 30;
|
||||
const VERTICAL_CONNECTOR_WIDTH = 1;
|
||||
|
||||
interface SpanStateClasses {
|
||||
@@ -194,8 +195,9 @@ const SpanOverview = memo(function SpanOverview({
|
||||
selectedSpan,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
traceMetadata,
|
||||
onAddSpanToFunnel,
|
||||
onHoverEnter,
|
||||
onHoverLeave,
|
||||
}: {
|
||||
span: SpanV3;
|
||||
isSpanCollapsed: boolean;
|
||||
@@ -204,19 +206,15 @@ const SpanOverview = memo(function SpanOverview({
|
||||
handleSpanClick: (span: SpanV3) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
traceMetadata: ITraceMetadata;
|
||||
onAddSpanToFunnel: (span: SpanV3) => void;
|
||||
onHoverEnter: (spanId: string) => void;
|
||||
onHoverLeave: () => void;
|
||||
}): JSX.Element {
|
||||
const isRootSpan = span.level === 0;
|
||||
const { onSpanCopy } = useCopySpanLink(span);
|
||||
const { resolveSpanColor } = useTraceContext();
|
||||
|
||||
let color = generateColor(
|
||||
span['service.name'],
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
if (span.has_error) {
|
||||
color = `var(--bg-cherry-500)`;
|
||||
}
|
||||
const color = resolveSpanColor(span);
|
||||
|
||||
// Smart highlighting logic
|
||||
const {
|
||||
@@ -232,6 +230,9 @@ const SpanOverview = memo(function SpanOverview({
|
||||
isFilterActive,
|
||||
);
|
||||
|
||||
// All siblings at the same level share the same indent so the "same X =
|
||||
// same level" visual rule holds. Parent/child distinction is conveyed by
|
||||
// the chevron and the L-connector, not by an icon-X offset.
|
||||
const indentWidth = isRootSpan ? 0 : span.level * CONNECTOR_WIDTH;
|
||||
|
||||
const handleFunnelClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
@@ -240,126 +241,128 @@ const SpanOverview = memo(function SpanOverview({
|
||||
};
|
||||
|
||||
return (
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className={cx('span-overview', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
{/* Tree connector lines — always draw vertical lines at all ancestor levels + L-connector */}
|
||||
{!isRootSpan &&
|
||||
Array.from({ length: span.level }, (_, i) => {
|
||||
const lvl = i + 1;
|
||||
const xPos = (lvl - 1) * CONNECTOR_WIDTH + 9;
|
||||
if (lvl < span.level) {
|
||||
// Stop the line at 50% for the last child's parent level
|
||||
const isLastChildParentLine =
|
||||
!span.has_sibling && lvl === span.level - 1;
|
||||
return (
|
||||
<div
|
||||
key={lvl}
|
||||
className="tree-line"
|
||||
style={{
|
||||
left: xPos,
|
||||
top: 0,
|
||||
width: 1,
|
||||
height: isLastChildParentLine ? '50%' : '100%',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
<div
|
||||
className={cx('span-overview', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
onMouseEnter={(): void => onHoverEnter(span.span_id)}
|
||||
onMouseLeave={(): void => onHoverLeave()}
|
||||
>
|
||||
{/* Tree connector lines — always draw vertical lines at all ancestor levels + L-connector */}
|
||||
{!isRootSpan &&
|
||||
Array.from({ length: span.level }, (_, i) => {
|
||||
const lvl = i + 1;
|
||||
const xPos = (lvl - 1) * CONNECTOR_WIDTH + 9;
|
||||
if (lvl < span.level) {
|
||||
// Stop the line at 50% for the last child's parent level
|
||||
const isLastChildParentLine = !span.has_sibling && lvl === span.level - 1;
|
||||
return (
|
||||
<div key={lvl}>
|
||||
<div
|
||||
className="tree-line"
|
||||
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
|
||||
/>
|
||||
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
|
||||
</div>
|
||||
<div
|
||||
key={lvl}
|
||||
className="tree-line"
|
||||
style={{
|
||||
left: xPos,
|
||||
top: 0,
|
||||
width: 1,
|
||||
height: isLastChildParentLine ? '50%' : '100%',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
}
|
||||
return (
|
||||
<div key={lvl}>
|
||||
<div
|
||||
className="tree-line"
|
||||
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
|
||||
/>
|
||||
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Indent spacer */}
|
||||
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
|
||||
{/* Indent spacer */}
|
||||
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
|
||||
|
||||
{/* Expand/collapse arrow + child count (only for spans with children) */}
|
||||
{/* Expand/collapse arrow + child count slots — always render the
|
||||
slots, fill them only when the span has children. Reserving the
|
||||
horizontal space on leaf rows aligns sibling icons regardless
|
||||
of whether each sibling is a parent or a leaf. */}
|
||||
<span className="tree-arrow-slot">
|
||||
{span.has_children && (
|
||||
<>
|
||||
<span
|
||||
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
|
||||
onClick={(event): void => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleCollapseUncollapse(span.span_id, !isSpanCollapsed);
|
||||
}}
|
||||
>
|
||||
{isSpanCollapsed ? (
|
||||
<ChevronRight size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
)}
|
||||
</span>
|
||||
<span className="subtree-count">
|
||||
<Badge color="vanilla">{span.sub_tree_node_count}</Badge>
|
||||
</span>
|
||||
</>
|
||||
<span
|
||||
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
|
||||
onClick={(event): void => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleCollapseUncollapse(span.span_id, !isSpanCollapsed);
|
||||
}}
|
||||
>
|
||||
{isSpanCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="subtree-count-slot">
|
||||
{span.has_children && (
|
||||
<span className="subtree-count">
|
||||
<Badge color="vanilla">{span.sub_tree_node_count}</Badge>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Colored service dot */}
|
||||
<span
|
||||
className={cx('tree-icon', { 'is-error': span.has_error })}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{/* Colored service dot */}
|
||||
<span
|
||||
className={cx('tree-icon', { 'is-error': span.has_error })}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
|
||||
{/* Span name + service name */}
|
||||
<span className="tree-label">
|
||||
{span.name}
|
||||
<span className="tree-service-name">{span['service.name']}</span>
|
||||
</span>
|
||||
{/* Span name + service name */}
|
||||
<span className="tree-label">
|
||||
{span.name}
|
||||
<span className="tree-service-name">{span['service.name']}</span>
|
||||
</span>
|
||||
|
||||
{/* Action buttons — shown on hover via CSS, right-aligned */}
|
||||
<span className="span-row-actions">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="span-action-btn"
|
||||
onClick={onSpanCopy}
|
||||
>
|
||||
<Link size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="span-action-tooltip">
|
||||
Copy Span Link
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="span-action-btn"
|
||||
onClick={handleFunnelClick}
|
||||
>
|
||||
<ListPlus size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="span-action-tooltip">
|
||||
Add to Trace Funnel
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
</div>
|
||||
</SpanHoverCard>
|
||||
{/* Action buttons — shown on hover via CSS, right-aligned */}
|
||||
<span className="span-row-actions">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="span-action-btn"
|
||||
onClick={onSpanCopy}
|
||||
>
|
||||
<Link size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="span-action-tooltip">
|
||||
Copy Span Link
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="span-action-btn"
|
||||
onClick={handleFunnelClick}
|
||||
>
|
||||
<ListPlus size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="span-action-tooltip">
|
||||
Add to Trace Funnel
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -386,16 +389,10 @@ export const SpanDuration = memo(function SpanDuration({
|
||||
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
|
||||
const width = (span.duration_nano * 1e2) / (spread * 1e6);
|
||||
|
||||
let color = generateColor(
|
||||
span['service.name'],
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
let rgbColor = colorToRgb(color);
|
||||
|
||||
if (span.has_error) {
|
||||
color = `var(--bg-cherry-500)`;
|
||||
rgbColor = '239, 68, 68';
|
||||
}
|
||||
const { resolveSpanColor } = useTraceContext();
|
||||
const color = resolveSpanColor(span);
|
||||
// `resolveSpanColor` returns a CSS variable for errors; `colorToRgb` can't parse it.
|
||||
const rgbColor = span.has_error ? '239, 68, 68' : colorToRgb(color);
|
||||
|
||||
const {
|
||||
isSelected,
|
||||
@@ -420,27 +417,25 @@ export const SpanDuration = memo(function SpanDuration({
|
||||
})}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className="span-bar"
|
||||
style={
|
||||
{
|
||||
left: `${leftOffset}%`,
|
||||
width: `${width}%`,
|
||||
'--span-color': color,
|
||||
'--span-color-rgb': rgbColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="span-info">
|
||||
<span className="span-name">{span.name}</span>
|
||||
<span className="span-duration-text">{`${toFixed(
|
||||
time,
|
||||
2,
|
||||
)} ${timeUnitName}`}</span>
|
||||
</span>
|
||||
</div>
|
||||
</SpanHoverCard>
|
||||
<div
|
||||
className="span-bar"
|
||||
style={
|
||||
{
|
||||
left: `${leftOffset}%`,
|
||||
width: `${width}%`,
|
||||
'--span-color': color,
|
||||
'--span-color-rgb': rgbColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="span-info">
|
||||
<span className="span-name">{span.name}</span>
|
||||
<span className="span-duration-text">{`${toFixed(
|
||||
time,
|
||||
2,
|
||||
)} ${timeUnitName}`}</span>
|
||||
</span>
|
||||
</div>
|
||||
{span.events?.map((event) => {
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const spanDurationMs = span.duration_nano / 1e6;
|
||||
@@ -506,6 +501,17 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
const prevHoveredSpanIdRef = useRef<string | null>(null);
|
||||
const autoScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const {
|
||||
topSentinelRef: loadMoreTopSentinelRef,
|
||||
bottomSentinelRef: loadMoreBottomSentinelRef,
|
||||
} = useBoundaryPagination({
|
||||
scrollContainerRef,
|
||||
spans,
|
||||
isFetching,
|
||||
isFullDataLoaded,
|
||||
setInterestedSpanId,
|
||||
});
|
||||
|
||||
const {
|
||||
cursorXPercent,
|
||||
cursorX,
|
||||
@@ -531,15 +537,32 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
prevHoveredSpanIdRef.current = spanId;
|
||||
}, []);
|
||||
|
||||
// Hover-card state — single popover anchored at the sidebar/timeline
|
||||
// boundary, Y tracks the hovered row. Set after a 500 ms debounce so fast
|
||||
// scrolls/cursor sweeps don't fire the card.
|
||||
const [hoveredSpanId, setHoveredSpanId] = useState<string | null>(null);
|
||||
const hoverDelayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleRowMouseEnter = useCallback(
|
||||
(spanId: string): void => {
|
||||
applyHoverClass(spanId);
|
||||
if (hoverDelayTimerRef.current) {
|
||||
clearTimeout(hoverDelayTimerRef.current);
|
||||
}
|
||||
hoverDelayTimerRef.current = setTimeout(() => {
|
||||
setHoveredSpanId(spanId);
|
||||
}, 500);
|
||||
},
|
||||
[applyHoverClass],
|
||||
);
|
||||
|
||||
const handleRowMouseLeave = useCallback((): void => {
|
||||
applyHoverClass(null);
|
||||
if (hoverDelayTimerRef.current) {
|
||||
clearTimeout(hoverDelayTimerRef.current);
|
||||
hoverDelayTimerRef.current = null;
|
||||
}
|
||||
setHoveredSpanId(null);
|
||||
}, [applyHoverClass]);
|
||||
|
||||
const handleCollapseUncollapse = useCallback(
|
||||
@@ -555,14 +578,15 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
// Backend mode: trigger API call (current behavior)
|
||||
setInterestedSpanId({
|
||||
spanId,
|
||||
isUncollapsed: !collapse,
|
||||
scrollToSpan: false,
|
||||
});
|
||||
}
|
||||
// Backend mode: trigger API call (current behavior)
|
||||
// keeping this for both mode to support scroll to view to function well.
|
||||
// interestedspan would not make api call in frontend mode so it is safe to use for both mode.
|
||||
setInterestedSpanId({
|
||||
spanId,
|
||||
isUncollapsed: !collapse,
|
||||
scrollToSpan: false,
|
||||
});
|
||||
},
|
||||
[isFullDataLoaded, setLocalUncollapsedNodes, setInterestedSpanId],
|
||||
);
|
||||
@@ -615,32 +639,8 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
}
|
||||
}, 20);
|
||||
}
|
||||
|
||||
// In frontend mode all data is already loaded, no need to fetch more.
|
||||
// In backend mode, skip auto-fetch when under 500 spans (nothing more to paginate).
|
||||
if (isFullDataLoaded || spans.length < 500) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (range?.startIndex === 0 && instance.isScrolling) {
|
||||
// do not trigger for trace root as nothing to fetch above
|
||||
if (spans[0].level !== 0) {
|
||||
setInterestedSpanId({
|
||||
spanId: spans[0].span_id,
|
||||
isUncollapsed: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (range?.endIndex === spans.length - 1 && instance.isScrolling) {
|
||||
setInterestedSpanId({
|
||||
spanId: spans[spans.length - 1].span_id,
|
||||
isUncollapsed: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[spans, setInterestedSpanId],
|
||||
[spans],
|
||||
);
|
||||
|
||||
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] =
|
||||
@@ -685,10 +685,11 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
}
|
||||
selectedSpan={selectedSpan}
|
||||
handleSpanClick={handleSpanClick}
|
||||
traceMetadata={traceMetadata}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
onAddSpanToFunnel={handleAddSpanToFunnel}
|
||||
onHoverEnter={handleRowMouseEnter}
|
||||
onHoverLeave={handleRowMouseLeave}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
@@ -698,12 +699,13 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
uncollapsedNodes,
|
||||
isFullDataLoaded,
|
||||
localUncollapsedNodes,
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
handleSpanClick,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
handleAddSpanToFunnel,
|
||||
handleRowMouseEnter,
|
||||
handleRowMouseLeave,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -780,6 +782,12 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const leftRows = leftTable.getRowModel().rows;
|
||||
|
||||
const handleHoverCardOpenChange = useCallback((open: boolean): void => {
|
||||
if (!open) {
|
||||
setHoveredSpanId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="success-content">
|
||||
{traceMetadata.hasMissingSpans && (
|
||||
@@ -833,6 +841,24 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Top / bottom sentinels: each transition into the viewport
|
||||
fires a load-more via useBoundaryPagination. */}
|
||||
<div
|
||||
ref={loadMoreTopSentinelRef}
|
||||
className="waterfall-load-more-sentinel waterfall-load-more-sentinel--top"
|
||||
/>
|
||||
<div
|
||||
ref={loadMoreBottomSentinelRef}
|
||||
className="waterfall-load-more-sentinel waterfall-load-more-sentinel--bottom"
|
||||
/>
|
||||
<SpanHoverCard
|
||||
hoveredSpanId={hoveredSpanId}
|
||||
onOpenChange={handleHoverCardOpenChange}
|
||||
anchorLeft={sidebarWidth}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
spans={spans}
|
||||
traceStartTime={traceMetadata.startTime}
|
||||
/>
|
||||
{/* Left panel - table with horizontal scroll */}
|
||||
<ResizableBox
|
||||
direction="horizontal"
|
||||
@@ -942,8 +968,8 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
height: ROW_HEIGHT,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
|
||||
onMouseLeave={handleRowMouseLeave}
|
||||
onMouseEnter={(): void => applyHoverClass(span.span_id)}
|
||||
onMouseLeave={(): void => applyHoverClass(null)}
|
||||
>
|
||||
<SpanDuration
|
||||
span={span}
|
||||
|
||||
@@ -2,8 +2,12 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceProvider } from '../../../../contexts/TraceContext';
|
||||
import { SpanDuration } from '../Success';
|
||||
|
||||
const renderWithTraceProvider: typeof render = (ui, options) =>
|
||||
render(<TraceProvider aggregations={undefined}>{ui}</TraceProvider>, options);
|
||||
|
||||
// Constants to avoid string duplication
|
||||
const SPAN_DURATION_TEXT = '1.16 ms';
|
||||
const SPAN_DURATION_CLASS = '.span-duration';
|
||||
@@ -19,6 +23,9 @@ jest.mock('components/TimelineV3/TimelineV3', () => ({
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/useUrlQuery');
|
||||
jest.mock('@signozhq/ui', () => ({
|
||||
Badge: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSpan: SpanV3 = {
|
||||
span_id: 'test-span-id',
|
||||
@@ -88,7 +95,7 @@ describe('SpanDuration', () => {
|
||||
it('calls handleSpanClick when clicked', () => {
|
||||
const mockHandleSpanClick = jest.fn();
|
||||
|
||||
render(
|
||||
renderWithTraceProvider(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -108,7 +115,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('shows action buttons on hover', () => {
|
||||
render(
|
||||
renderWithTraceProvider(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -129,7 +136,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('applies interested-span class when span is selected', () => {
|
||||
render(
|
||||
renderWithTraceProvider(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -147,7 +154,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('applies highlighted-span class when span matches filter', () => {
|
||||
render(
|
||||
renderWithTraceProvider(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -166,7 +173,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('applies dimmed-span class when span does not match filter', () => {
|
||||
render(
|
||||
renderWithTraceProvider(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -185,7 +192,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
|
||||
render(
|
||||
renderWithTraceProvider(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -205,7 +212,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
|
||||
render(
|
||||
renderWithTraceProvider(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -226,7 +233,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('applies interested-span class when span is selected and no filter is active', () => {
|
||||
render(
|
||||
renderWithTraceProvider(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -247,7 +254,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('dims span when filter is active but no matches found', () => {
|
||||
render(
|
||||
renderWithTraceProvider(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
|
||||
@@ -2,8 +2,16 @@ import React from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceProvider } from '../../../../contexts/TraceContext';
|
||||
import Success from '../Success';
|
||||
|
||||
const renderWithTraceProvider: typeof render = (ui, options, customOptions) =>
|
||||
render(
|
||||
<TraceProvider aggregations={undefined}>{ui}</TraceProvider>,
|
||||
options,
|
||||
customOptions,
|
||||
);
|
||||
|
||||
// Mock the required hooks with proper typing
|
||||
const mockSafeNavigate = jest.fn() as jest.MockedFunction<
|
||||
(params: { search: string }) => void
|
||||
@@ -222,7 +230,7 @@ describe('Span Click User Flows', () => {
|
||||
it('clicking span updates URL with spanId parameter', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
renderWithTraceProvider(
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -261,7 +269,7 @@ describe('Span Click User Flows', () => {
|
||||
it('clicking span duration visually selects the span', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestComponent />, undefined, {
|
||||
renderWithTraceProvider(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
@@ -300,7 +308,7 @@ describe('Span Click User Flows', () => {
|
||||
it('both click areas produce the same visual result', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestComponent />, undefined, {
|
||||
renderWithTraceProvider(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
@@ -360,7 +368,7 @@ describe('Span Click User Flows', () => {
|
||||
it('clicking different spans updates selection correctly', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestComponent />, undefined, {
|
||||
renderWithTraceProvider(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
@@ -404,7 +412,7 @@ describe('Span Click User Flows', () => {
|
||||
mockUrlQuery.set('existingParam', 'existingValue');
|
||||
mockUrlQuery.set('anotherParam', 'anotherValue');
|
||||
|
||||
render(
|
||||
renderWithTraceProvider(
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { RefObject, useEffect, useRef } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { IInterestedSpan } from '../types';
|
||||
|
||||
const MIN_SPANS_FOR_PAGINATION = 500;
|
||||
|
||||
interface UseBoundaryPaginationProps {
|
||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||
spans: SpanV3[];
|
||||
isFetching: boolean | undefined;
|
||||
isFullDataLoaded: boolean;
|
||||
setInterestedSpanId: (next: IInterestedSpan) => void;
|
||||
}
|
||||
|
||||
interface UseBoundaryPaginationResult {
|
||||
topSentinelRef: RefObject<HTMLDivElement>;
|
||||
bottomSentinelRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives load-more on a virtualized list via two `IntersectionObserver`
|
||||
* sentinels (top + bottom of the inner content). The observer is created
|
||||
* once and reads live state through refs — recreating it would re-fire
|
||||
* IO's mandatory initial-intersection callback for sentinels still in view
|
||||
* and produce a fetch spiral on every data update.
|
||||
*
|
||||
* Returns the two refs the caller must attach to its sentinel `<div>`s.
|
||||
*/
|
||||
export function useBoundaryPagination({
|
||||
scrollContainerRef,
|
||||
spans,
|
||||
isFetching,
|
||||
isFullDataLoaded,
|
||||
setInterestedSpanId,
|
||||
}: UseBoundaryPaginationProps): UseBoundaryPaginationResult {
|
||||
const topSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const spansRef = useRef<SpanV3[]>(spans);
|
||||
const isFetchingRef = useRef<boolean | undefined>(isFetching);
|
||||
const isFullDataLoadedRef = useRef<boolean>(isFullDataLoaded);
|
||||
|
||||
useEffect(() => {
|
||||
spansRef.current = spans;
|
||||
}, [spans]);
|
||||
useEffect(() => {
|
||||
isFetchingRef.current = isFetching;
|
||||
}, [isFetching]);
|
||||
useEffect(() => {
|
||||
isFullDataLoadedRef.current = isFullDataLoaded;
|
||||
}, [isFullDataLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollContainerRef.current;
|
||||
if (!root) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const seenInitial = new WeakSet<Element>();
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!seenInitial.has(entry.target)) {
|
||||
seenInitial.add(entry.target);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!entry.isIntersecting ||
|
||||
isFetchingRef.current ||
|
||||
isFullDataLoadedRef.current ||
|
||||
spansRef.current.length < MIN_SPANS_FOR_PAGINATION
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.target === bottomSentinelRef.current) {
|
||||
const lastSpan = spansRef.current[spansRef.current.length - 1];
|
||||
if (lastSpan) {
|
||||
setInterestedSpanId({
|
||||
spanId: lastSpan.span_id,
|
||||
isUncollapsed: false,
|
||||
});
|
||||
}
|
||||
} else if (entry.target === topSentinelRef.current) {
|
||||
const firstSpan = spansRef.current[0];
|
||||
if (firstSpan && firstSpan.level !== 0) {
|
||||
setInterestedSpanId({
|
||||
spanId: firstSpan.span_id,
|
||||
isUncollapsed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ root, threshold: 0 },
|
||||
);
|
||||
|
||||
if (bottomSentinelRef.current) {
|
||||
observer.observe(bottomSentinelRef.current);
|
||||
}
|
||||
if (topSentinelRef.current) {
|
||||
observer.observe(topSentinelRef.current);
|
||||
}
|
||||
|
||||
return (): void => observer.disconnect();
|
||||
}, [scrollContainerRef, setInterestedSpanId]);
|
||||
|
||||
return { topSentinelRef, bottomSentinelRef };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface IInterestedSpan {
|
||||
spanId: string;
|
||||
isUncollapsed: boolean;
|
||||
scrollToSpan?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
function SortableField({
|
||||
field,
|
||||
onRemove,
|
||||
allowDrag,
|
||||
}: {
|
||||
field: BaseAutocompleteData;
|
||||
onRemove: (field: BaseAutocompleteData) => void;
|
||||
allowDrag: boolean;
|
||||
}): JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: field.key });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`fs-field-item ${allowDrag ? 'drag-enabled' : 'drag-disabled'}`}
|
||||
>
|
||||
<div {...attributes} {...listeners} className="drag-handle">
|
||||
{allowDrag && <GripVertical size={14} />}
|
||||
<span className="fs-field-key">{field.key}</span>
|
||||
</div>
|
||||
<Button
|
||||
className="remove-field-btn periscope-btn"
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
onClick={(): void => onRemove(field)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddedFieldsProps {
|
||||
inputValue: string;
|
||||
fields: BaseAutocompleteData[];
|
||||
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
|
||||
}
|
||||
|
||||
function AddedFields({
|
||||
inputValue,
|
||||
fields,
|
||||
onFieldsChange,
|
||||
}: AddedFieldsProps): JSX.Element {
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = fields.findIndex((f) => f.key === active.id);
|
||||
const newIndex = fields.findIndex((f) => f.key === over.id);
|
||||
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFields = useMemo(
|
||||
() =>
|
||||
fields.filter((f) => f.key.toLowerCase().includes(inputValue.toLowerCase())),
|
||||
[fields, inputValue],
|
||||
);
|
||||
|
||||
const handleRemove = (field: BaseAutocompleteData): void => {
|
||||
onFieldsChange(fields.filter((f) => f.key !== field.key));
|
||||
};
|
||||
|
||||
const allowDrag = inputValue.length === 0;
|
||||
|
||||
return (
|
||||
<div className="fs-section fs-added">
|
||||
<div className="fs-section-header">ADDED FIELDS</div>
|
||||
<div className="fs-added-list">
|
||||
<OverlayScrollbar>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{filteredFields.length === 0 ? (
|
||||
<div className="fs-no-values">No values found</div>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={fields.map((f) => f.key)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
disabled={!allowDrag}
|
||||
>
|
||||
{filteredFields.map((field) => (
|
||||
<SortableField
|
||||
key={field.key}
|
||||
field={field}
|
||||
onRemove={handleRemove}
|
||||
allowDrag={allowDrag}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</DndContext>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddedFields;
|
||||
@@ -0,0 +1,161 @@
|
||||
.fields-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
overflow: hidden;
|
||||
|
||||
.fs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.fs-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fs-close-icon {
|
||||
cursor: pointer;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fs-search {
|
||||
.fs-search-input {
|
||||
background-color: var(--l1-background);
|
||||
height: 40px;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fs-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.fs-added {
|
||||
max-height: 40%;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&.fs-other {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fs-section-header {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.fs-added-list {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fs-other-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.ant-skeleton-input {
|
||||
width: 300px;
|
||||
margin: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.fs-no-values {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fs-limit-hint {
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.fs-field-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.fs-field-key {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.drag-enabled {
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
&.drag-disabled {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
&.other-field-item {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.remove-field-btn,
|
||||
.add-field-btn {
|
||||
padding: 4px 10px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l2-background);
|
||||
|
||||
.remove-field-btn,
|
||||
.add-field-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fs-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import AddedFields from './AddedFields';
|
||||
import OtherFields from './OtherFields';
|
||||
|
||||
import './FieldsSettings.styles.scss';
|
||||
|
||||
const MAX_FIELDS_DEFAULT = 10;
|
||||
|
||||
interface FieldsSettingsProps {
|
||||
title: string;
|
||||
// Picker's native shape (`BaseAutocompleteData`) is preserved end-to-end so
|
||||
// downstream consumers (flamegraph `selectFields`, hover popovers) get full
|
||||
// field metadata without a lossy conversion at add-time.
|
||||
fields: BaseAutocompleteData[];
|
||||
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
|
||||
onClose: () => void;
|
||||
dataSource: DataSource;
|
||||
maxFields?: number;
|
||||
}
|
||||
|
||||
function FieldsSettings({
|
||||
title,
|
||||
fields,
|
||||
onFieldsChange,
|
||||
onClose,
|
||||
dataSource,
|
||||
maxFields = MAX_FIELDS_DEFAULT,
|
||||
}: FieldsSettingsProps): JSX.Element {
|
||||
// Local draft state — changes here don't persist until Save
|
||||
const [draftFields, setDraftFields] = useState<BaseAutocompleteData[]>(fields);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState('');
|
||||
|
||||
const debouncedUpdate = useDebouncedFn((value) => {
|
||||
setDebouncedInputValue(value as string);
|
||||
}, 400);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const value = e.target.value.trim().toLowerCase();
|
||||
setInputValue(value);
|
||||
debouncedUpdate(value);
|
||||
},
|
||||
[debouncedUpdate],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(field: BaseAutocompleteData): void => {
|
||||
if (draftFields.length >= maxFields) {
|
||||
return;
|
||||
}
|
||||
if (draftFields.some((f) => f.key === field.key)) {
|
||||
return;
|
||||
}
|
||||
setDraftFields((prev) => [...prev, field]);
|
||||
},
|
||||
[draftFields, maxFields],
|
||||
);
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
onFieldsChange(draftFields);
|
||||
toast.success('Saved successfully', {
|
||||
position: 'top-right',
|
||||
});
|
||||
onClose();
|
||||
}, [draftFields, onFieldsChange, onClose]);
|
||||
|
||||
const handleDiscard = useCallback((): void => {
|
||||
setDraftFields(fields);
|
||||
}, [fields]);
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
!(
|
||||
draftFields.length === fields.length &&
|
||||
draftFields.every((f, i) => f.key === fields[i]?.key)
|
||||
),
|
||||
[draftFields, fields],
|
||||
);
|
||||
|
||||
const isAtLimit = draftFields.length >= maxFields;
|
||||
|
||||
return (
|
||||
<div className="fields-settings">
|
||||
<div className="fs-header">
|
||||
<div className="fs-title">
|
||||
<TableColumnsSplit size={16} />
|
||||
{title}
|
||||
</div>
|
||||
<X className="fs-close-icon" size={16} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
<section className="fs-search">
|
||||
<Input
|
||||
className="fs-search-input"
|
||||
type="text"
|
||||
value={inputValue}
|
||||
placeholder="Search for a field..."
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AddedFields
|
||||
inputValue={inputValue}
|
||||
fields={draftFields}
|
||||
onFieldsChange={setDraftFields}
|
||||
/>
|
||||
|
||||
<OtherFields
|
||||
dataSource={dataSource}
|
||||
debouncedInputValue={debouncedInputValue}
|
||||
addedFields={draftFields}
|
||||
onAdd={handleAdd}
|
||||
isAtLimit={isAtLimit}
|
||||
/>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
<div className="fs-footer">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
prefix={<X width={14} height={14} />}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
prefix={<Check width={14} height={14} />}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldsSettings;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
interface OtherFieldsProps {
|
||||
dataSource: DataSource;
|
||||
debouncedInputValue: string;
|
||||
addedFields: BaseAutocompleteData[];
|
||||
onAdd: (field: BaseAutocompleteData) => void;
|
||||
isAtLimit: boolean;
|
||||
}
|
||||
|
||||
function OtherFields({
|
||||
dataSource,
|
||||
debouncedInputValue,
|
||||
addedFields,
|
||||
onAdd,
|
||||
isAtLimit,
|
||||
}: OtherFieldsProps): JSX.Element {
|
||||
// API call to get available attribute keys
|
||||
const { data, isFetching } = useGetAggregateKeys(
|
||||
{
|
||||
searchText: debouncedInputValue,
|
||||
dataSource,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: '',
|
||||
tagType: '',
|
||||
},
|
||||
{
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_OTHER_FILTERS,
|
||||
'preview-fields',
|
||||
debouncedInputValue,
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Filter out already-added fields, match on .key from API response objects
|
||||
const otherFields = useMemo(() => {
|
||||
const attributes = data?.payload?.attributeKeys || [];
|
||||
const addedKeys = new Set(addedFields.map((f) => f.key));
|
||||
return attributes.filter((attr) => !addedKeys.has(attr.key));
|
||||
}, [data, addedFields]);
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="fs-section fs-other">
|
||||
<div className="fs-section-header">OTHER FIELDS</div>
|
||||
<div className="fs-other-list">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Skeleton.Input active size="small" key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fs-section fs-other">
|
||||
<div className="fs-section-header">OTHER FIELDS</div>
|
||||
<div className="fs-other-list">
|
||||
<OverlayScrollbar>
|
||||
<>
|
||||
{otherFields.length === 0 ? (
|
||||
<div className="fs-no-values">No values found</div>
|
||||
) : (
|
||||
otherFields.map((attr) => (
|
||||
<div key={attr.key} className="fs-field-item other-field-item">
|
||||
<span className="fs-field-key">{attr.key}</span>
|
||||
{!isAtLimit && (
|
||||
<Button
|
||||
className="add-field-btn periscope-btn"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => onAdd(attr)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isAtLimit && <div className="fs-limit-hint">Maximum 10 fields</div>}
|
||||
</>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OtherFields;
|
||||
55
frontend/src/pages/TraceDetailsV3/constants.ts
Normal file
55
frontend/src/pages/TraceDetailsV3/constants.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export interface ColorByOption {
|
||||
field: TelemetryFieldKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const COLOR_BY_OPTIONS: ColorByOption[] = [
|
||||
{
|
||||
field: {
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
label: 'Service',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'service.namespace',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
label: 'Namespace',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'host.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
label: 'Host',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'k8s.node.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
label: 'Node',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'k8s.container.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
label: 'Container',
|
||||
},
|
||||
];
|
||||
|
||||
export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
|
||||
(o) => o.field,
|
||||
);
|
||||
|
||||
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];
|
||||
220
frontend/src/pages/TraceDetailsV3/contexts/TraceContext.tsx
Normal file
220
frontend/src/pages/TraceDetailsV3/contexts/TraceContext.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import {
|
||||
SpanV3,
|
||||
WaterfallAggregationResponse,
|
||||
WaterfallAggregationType,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
import {
|
||||
ColorByOption,
|
||||
COLOR_BY_FIELDS,
|
||||
COLOR_BY_OPTIONS,
|
||||
DEFAULT_COLOR_BY_FIELD,
|
||||
} from '../constants';
|
||||
import { getSpanAttribute } from '../utils';
|
||||
import {
|
||||
AGGREGATIONS,
|
||||
getAggregationMap as findAggregationMap,
|
||||
} from '../utils/aggregations';
|
||||
|
||||
interface TraceContextValue {
|
||||
colorByField: TelemetryFieldKey;
|
||||
setColorByField: (field: TelemetryFieldKey) => void;
|
||||
aggregations: WaterfallAggregationResponse[] | undefined;
|
||||
getAggregationMap: (
|
||||
type: WaterfallAggregationType,
|
||||
) => Record<string, number> | undefined;
|
||||
getSpanGroupValue: (span: SpanV3) => string;
|
||||
resolveSpanColor: (span: SpanV3) => string;
|
||||
/**
|
||||
* Subset of COLOR_BY_OPTIONS whose data is populated on the current trace.
|
||||
* `service.name` is always included; host/container only when their
|
||||
* aggregation `value` map has entries.
|
||||
*/
|
||||
availableColorByOptions: ColorByOption[];
|
||||
/**
|
||||
* Per-user preview fields (selected via the floating "Preview fields"
|
||||
* panel). Stored in `span_details_preview_attributes` user pref. Will be
|
||||
* consumed by the flamegraph `selectFields` request and the waterfall +
|
||||
* flamegraph hover popovers in follow-up phases.
|
||||
*/
|
||||
previewFields: BaseAutocompleteData[];
|
||||
setPreviewFields: (next: BaseAutocompleteData[]) => void;
|
||||
}
|
||||
|
||||
const TraceContext = createContext<TraceContextValue | null>(null);
|
||||
|
||||
export function TraceProvider({
|
||||
aggregations,
|
||||
children,
|
||||
}: {
|
||||
aggregations: WaterfallAggregationResponse[] | undefined;
|
||||
children: ReactNode;
|
||||
}): JSX.Element | null {
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { mutate: updateUserPreferenceMutation } = useMutation(
|
||||
updateUserPreferenceAPI,
|
||||
);
|
||||
|
||||
// Source of truth: user-preferences API (loaded once on app init via
|
||||
// AppProvider). Render nothing until it resolves so we never paint the
|
||||
// default colour first and then swap to the user's persisted choice.
|
||||
// AppProvider fires the prefs query as soon as the user is logged in, so
|
||||
// this is usually already settled by the time TraceDetailsV3 mounts.
|
||||
const persistedColorByField = useMemo<TelemetryFieldKey>(() => {
|
||||
const pref = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
|
||||
);
|
||||
const name = (pref?.value as string) || '';
|
||||
return COLOR_BY_FIELDS.find((f) => f.name === name) ?? DEFAULT_COLOR_BY_FIELD;
|
||||
}, [userPreferences]);
|
||||
|
||||
const setColorByField = useCallback(
|
||||
(field: TelemetryFieldKey): void => {
|
||||
// Optimistically reflect the choice in the in-memory cache so the UI
|
||||
// reacts immediately (the GET /user/preferences response on app init
|
||||
// always includes the registered key, so `existing` will be defined).
|
||||
const existing = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
|
||||
);
|
||||
if (existing) {
|
||||
updateUserPreferenceInContext({ ...existing, value: field.name });
|
||||
}
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
|
||||
value: field.name,
|
||||
});
|
||||
},
|
||||
[
|
||||
userPreferences,
|
||||
updateUserPreferenceInContext,
|
||||
updateUserPreferenceMutation,
|
||||
],
|
||||
);
|
||||
|
||||
const previewFields = useMemo<BaseAutocompleteData[]>(() => {
|
||||
const pref = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
|
||||
);
|
||||
const raw = (pref?.value as BaseAutocompleteData[] | undefined) ?? [];
|
||||
// Defensive: keep only entries that have a string `key`.
|
||||
return raw.filter(
|
||||
(f): f is BaseAutocompleteData =>
|
||||
typeof f === 'object' &&
|
||||
f !== null &&
|
||||
typeof (f as { key?: unknown }).key === 'string',
|
||||
);
|
||||
}, [userPreferences]);
|
||||
|
||||
const setPreviewFields = useCallback(
|
||||
(next: BaseAutocompleteData[]): void => {
|
||||
const existing = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
|
||||
);
|
||||
if (existing) {
|
||||
updateUserPreferenceInContext({ ...existing, value: next });
|
||||
}
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
|
||||
value: next,
|
||||
});
|
||||
},
|
||||
[
|
||||
userPreferences,
|
||||
updateUserPreferenceInContext,
|
||||
updateUserPreferenceMutation,
|
||||
],
|
||||
);
|
||||
|
||||
const value = useMemo<TraceContextValue>(() => {
|
||||
const isFieldAvailable = (fieldName: string): boolean => {
|
||||
if (fieldName === DEFAULT_COLOR_BY_FIELD.name) {
|
||||
return true;
|
||||
}
|
||||
// Pick any aggregation type — if execution_time_percentage is empty,
|
||||
// span_count for the same field will be too (both are derived from
|
||||
// the same set of spans).
|
||||
const map = findAggregationMap(
|
||||
aggregations,
|
||||
AGGREGATIONS.EXEC_TIME_PCT,
|
||||
fieldName,
|
||||
);
|
||||
return !!map && Object.keys(map).length > 0;
|
||||
};
|
||||
|
||||
const availableColorByOptions = COLOR_BY_OPTIONS.filter((opt) =>
|
||||
isFieldAvailable(opt.field.name),
|
||||
);
|
||||
|
||||
const colorByField =
|
||||
aggregations === undefined || isFieldAvailable(persistedColorByField.name)
|
||||
? persistedColorByField
|
||||
: DEFAULT_COLOR_BY_FIELD;
|
||||
|
||||
const getAggregationMap = (
|
||||
type: WaterfallAggregationType,
|
||||
): Record<string, number> | undefined =>
|
||||
findAggregationMap(aggregations, type, colorByField.name);
|
||||
|
||||
const getSpanGroupValue = (span: SpanV3): string =>
|
||||
getSpanAttribute(span, colorByField.name) || 'unknown';
|
||||
|
||||
const resolveSpanColor = (span: SpanV3): string => {
|
||||
if (span.has_error) {
|
||||
return 'var(--bg-cherry-500)';
|
||||
}
|
||||
return generateColor(
|
||||
getSpanGroupValue(span),
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
colorByField,
|
||||
setColorByField,
|
||||
aggregations,
|
||||
getAggregationMap,
|
||||
getSpanGroupValue,
|
||||
resolveSpanColor,
|
||||
availableColorByOptions,
|
||||
previewFields,
|
||||
setPreviewFields,
|
||||
};
|
||||
}, [
|
||||
persistedColorByField,
|
||||
aggregations,
|
||||
setColorByField,
|
||||
previewFields,
|
||||
setPreviewFields,
|
||||
]);
|
||||
|
||||
if (!userPreferences) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TraceContext.Provider value={value}>{children}</TraceContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTraceContext(): TraceContextValue {
|
||||
const ctx = useContext(TraceContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useTraceContext must be used inside TraceProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { isV3PinnedAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { serializeKeyPath } from 'periscope/components/PrettyView/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
/**
|
||||
* V2 stored pinned attributes as flat strings (`["http.method"]`).
|
||||
* V3 stores nested key paths (`['["attributes","http.method"]']`).
|
||||
*
|
||||
* On first load with both `userPreferences` and `selectedSpan` available,
|
||||
* detect a V2-format value in the backend pref and convert it to V3 paths
|
||||
* using the loaded span's shape. Idempotent: once written in V3 format the
|
||||
* format check on subsequent loads short-circuits.
|
||||
*
|
||||
* Unmappable keys (not present on the loaded span) are dropped — we can't
|
||||
* determine whether they belong under `attributes`, `resource`, or top-level.
|
||||
*/
|
||||
export function useMigratePinnedAttributes(
|
||||
selectedSpan: SpanV3 | undefined,
|
||||
): void {
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { mutate } = useMutation(updateUserPreferenceAPI);
|
||||
const ranRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ranRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!userPreferences || !selectedSpan) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pref = userPreferences.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
);
|
||||
const value = (pref?.value as string[] | undefined) ?? [];
|
||||
if (value.length === 0) {
|
||||
ranRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk every entry — the array may be mixed (e.g. some legacy V2 flat
|
||||
// keys saved alongside V3 paths). V3 entries pass through unchanged;
|
||||
// V2 entries get converted via the loaded span; unmappable V2 entries
|
||||
// are dropped. We only persist when at least one V2 entry was found
|
||||
// (otherwise the input is already V3-clean).
|
||||
const next: string[] = [];
|
||||
let hadV2Entry = false;
|
||||
|
||||
for (const entry of value) {
|
||||
if (isV3PinnedAttribute(entry)) {
|
||||
next.push(entry);
|
||||
continue;
|
||||
}
|
||||
hadV2Entry = true;
|
||||
const path = v2KeyToPath(entry, selectedSpan);
|
||||
if (path) {
|
||||
next.push(serializeKeyPath(path));
|
||||
}
|
||||
// else: unmappable on the loaded span — drop
|
||||
}
|
||||
|
||||
if (!hadV2Entry) {
|
||||
ranRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (pref) {
|
||||
updateUserPreferenceInContext({ ...pref, value: next });
|
||||
}
|
||||
mutate(
|
||||
{
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
value: next,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
ranRef.current = true;
|
||||
},
|
||||
onError: () => {
|
||||
// Roll back the optimistic context update
|
||||
if (pref) {
|
||||
updateUserPreferenceInContext({ ...pref, value });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [userPreferences, selectedSpan, updateUserPreferenceInContext, mutate]);
|
||||
}
|
||||
|
||||
function v2KeyToPath(key: string, span: SpanV3): (string | number)[] | null {
|
||||
if (span.attributes && key in span.attributes) {
|
||||
return ['attributes', key];
|
||||
}
|
||||
if (span.resource && key in span.resource) {
|
||||
return ['resource', key];
|
||||
}
|
||||
if (key in (span as unknown as Record<string, unknown>)) {
|
||||
return [key];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -12,16 +12,23 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import NoData from 'pages/TraceDetailV2/NoData/NoData';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import {
|
||||
SpanV3,
|
||||
TraceDetailV3URLProps,
|
||||
WaterfallAggregationRequest,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { COLOR_BY_FIELDS } from './constants';
|
||||
import { TraceProvider } from './contexts/TraceContext';
|
||||
import { AGGREGATIONS } from './utils/aggregations';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall, {
|
||||
IInterestedSpan,
|
||||
} from './TraceWaterfall/TraceWaterfall';
|
||||
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from './TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from './TraceWaterfall/utils';
|
||||
|
||||
import './TraceDetailsV3.styles.scss';
|
||||
@@ -79,6 +86,17 @@ function TraceDetailsV3(): JSX.Element {
|
||||
});
|
||||
}, [urlQuery]);
|
||||
|
||||
// Hardcoded for now — fetch aggregations for all 3 candidate color-by fields
|
||||
// upfront so a future color-by-field switch doesn't need to refetch.
|
||||
const waterfallAggregationsRequest = useMemo<WaterfallAggregationRequest[]>(
|
||||
() =>
|
||||
COLOR_BY_FIELDS.flatMap((field) => [
|
||||
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
|
||||
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
|
||||
]),
|
||||
[],
|
||||
);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
@@ -86,6 +104,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
aggregations: waterfallAggregationsRequest,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
@@ -94,6 +113,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
aggregations: waterfallAggregationsRequest,
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -105,6 +125,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
aggregations: queryParams.aggregations,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
@@ -119,6 +140,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
aggregations: waterfallAggregationsRequest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,6 +235,23 @@ function TraceDetailsV3(): JSX.Element {
|
||||
],
|
||||
);
|
||||
|
||||
const traceMetadataForHeader = useMemo(():
|
||||
| TraceMetadataForHeader
|
||||
| undefined => {
|
||||
const payload = traceData?.payload;
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
const rootSpan = payload.spans?.find((s) => s.level === 0);
|
||||
return {
|
||||
startTimestampMillis: payload.startTimestampMillis,
|
||||
endTimestampMillis: payload.endTimestampMillis,
|
||||
rootServiceName: payload.rootServiceName,
|
||||
rootServiceEntryPoint: payload.rootServiceEntryPoint,
|
||||
rootSpanStatusCode: rootSpan?.response_status_code || '',
|
||||
};
|
||||
}, [traceData?.payload]);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
@@ -246,104 +285,116 @@ function TraceDetailsV3(): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="trace-details-v3">
|
||||
<TraceDetailsHeader
|
||||
filterMetadata={filterMetadata}
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
noData={showNoData}
|
||||
/>
|
||||
<TraceProvider aggregations={traceData?.payload?.aggregations}>
|
||||
<div className="trace-details-v3">
|
||||
<TraceDetailsHeader
|
||||
filterMetadata={filterMetadata}
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
|
||||
traceMetadata={traceMetadataForHeader}
|
||||
/>
|
||||
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className="trace-details-v3__content">
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
className="trace-details-v3__flame-collapse"
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className="trace-details-v3__collapse-label">
|
||||
<span>Flame Graph</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className="trace-details-v3__collapse-count">
|
||||
{traceData.payload.totalSpansCount} spans
|
||||
{traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans."
|
||||
placement="bottomRight"
|
||||
autoAdjustOverflow={false}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className="trace-details-v3__content">
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
className="trace-details-v3__flame-collapse"
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className="trace-details-v3__collapse-label">
|
||||
<span>Flame Graph</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className="trace-details-v3__collapse-count">
|
||||
<span>Spans: {traceData.payload.totalSpansCount}</span>
|
||||
<span
|
||||
className={
|
||||
traceData.payload.totalErrorSpansCount > 0
|
||||
? 'trace-details-v3__collapse-count-errors'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
|
||||
</span>
|
||||
{traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomRight"
|
||||
autoAdjustOverflow={false}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
className={`trace-details-v3__waterfall-collapse${
|
||||
isWaterfallDocked ? ' trace-details-v3__waterfall-collapse--docked' : ''
|
||||
}`}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
className={`trace-details-v3__waterfall-collapse${
|
||||
isWaterfallDocked
|
||||
? ' trace-details-v3__waterfall-collapse--docked'
|
||||
: ''
|
||||
}`}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className="trace-details-v3__docked-span-details">
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap}
|
||||
/>
|
||||
</div>
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className="trace-details-v3__docked-span-details">
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && !isDocked && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && !isDocked && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TraceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,17 @@ import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
/**
|
||||
* Look up an attribute from both `resource` and `attributes` on a span.
|
||||
* Resources are checked first (service.name, k8s.* etc. live there).
|
||||
*
|
||||
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
|
||||
* structurally — the only fields touched are `resource` and `attributes`.
|
||||
*
|
||||
* TODO: Remove tagMap fallback when phasing out V2
|
||||
*/
|
||||
export function getSpanAttribute(
|
||||
span: SpanV3,
|
||||
span: {
|
||||
resource?: Record<string, string>;
|
||||
attributes?: Record<string, any>;
|
||||
},
|
||||
key: string,
|
||||
): string | undefined {
|
||||
return (
|
||||
@@ -31,3 +38,40 @@ export function hasInfraMetadata(span: SpanV3 | undefined): boolean {
|
||||
}
|
||||
return INFRA_METADATA_KEYS.some((key) => getSpanAttribute(span, key));
|
||||
}
|
||||
|
||||
// Top-level fields that exist on the API response only to support waterfall
|
||||
// rendering. They have no value in the Span Details DataViewer. Drop the whole
|
||||
// constant + helper once the backend stops emitting them.
|
||||
const HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW: ReadonlySet<string> = new Set([
|
||||
'sub_tree_node_count',
|
||||
'has_children',
|
||||
'level',
|
||||
'service.name',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Shallow-copies the span with waterfall-only fields stripped, for display in
|
||||
* the Span Details DataViewer.
|
||||
*/
|
||||
export function getSpanDisplayData(span: SpanV3): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(span)) {
|
||||
if (!HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW.has(key)) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* V3 pinned-attribute entries are JSON-stringified arrays (e.g.
|
||||
* `'["attributes","http.method"]'`). Legacy V2 entries are flat strings.
|
||||
* Used to distinguish V3 from V2 entries when reading the persisted value.
|
||||
*/
|
||||
export function isV3PinnedAttribute(entry: string): boolean {
|
||||
try {
|
||||
return Array.isArray(JSON.parse(entry));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
20
frontend/src/pages/TraceDetailsV3/utils/aggregations.ts
Normal file
20
frontend/src/pages/TraceDetailsV3/utils/aggregations.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
WaterfallAggregationResponse,
|
||||
WaterfallAggregationType,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
export const AGGREGATIONS = {
|
||||
EXEC_TIME_PCT: 'execution_time_percentage',
|
||||
SPAN_COUNT: 'span_count',
|
||||
DURATION: 'duration',
|
||||
} as const satisfies Record<string, WaterfallAggregationType>;
|
||||
|
||||
export function getAggregationMap(
|
||||
aggregations: WaterfallAggregationResponse[] | undefined,
|
||||
type: WaterfallAggregationType,
|
||||
fieldName: string,
|
||||
): Record<string, number> | undefined {
|
||||
return aggregations?.find(
|
||||
(a) => a.aggregation === type && a.field.name === fieldName,
|
||||
)?.value;
|
||||
}
|
||||
77
frontend/src/pages/TraceDetailsV3/utils/previewFields.ts
Normal file
77
frontend/src/pages/TraceDetailsV3/utils/previewFields.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
TelemetryFieldKey,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Map the picker's `BaseAutocompleteData.type` (`'tag' | 'resource' | '' | null`)
|
||||
* to the API's `FieldContext`. Unknown values fall back to `'attribute'`.
|
||||
*/
|
||||
function mapFieldContext(type: BaseAutocompleteData['type']): FieldContext {
|
||||
if (type === 'resource') {
|
||||
return 'resource';
|
||||
}
|
||||
return 'attribute';
|
||||
}
|
||||
|
||||
const DATA_TYPE_MAP: Record<DataTypes, FieldDataType> = {
|
||||
[DataTypes.String]: 'string',
|
||||
[DataTypes.bool]: 'bool',
|
||||
[DataTypes.Int64]: 'int64',
|
||||
[DataTypes.Float64]: 'float64',
|
||||
[DataTypes.ArrayString]: '[]string',
|
||||
[DataTypes.ArrayBool]: '[]bool',
|
||||
[DataTypes.ArrayInt64]: '[]int64',
|
||||
[DataTypes.ArrayFloat64]: '[]float64',
|
||||
[DataTypes.EMPTY]: 'string',
|
||||
};
|
||||
|
||||
function mapFieldDataType(
|
||||
dataType: BaseAutocompleteData['dataType'],
|
||||
): FieldDataType {
|
||||
if (!dataType) {
|
||||
return 'string';
|
||||
}
|
||||
return DATA_TYPE_MAP[dataType] ?? 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a picker-shaped field to the API's `TelemetryFieldKey` shape used
|
||||
* for `selectFields` on the flamegraph request.
|
||||
*/
|
||||
export function toTelemetryFieldKey(
|
||||
field: BaseAutocompleteData,
|
||||
): TelemetryFieldKey {
|
||||
return {
|
||||
name: field.key,
|
||||
fieldContext: mapFieldContext(field.type),
|
||||
fieldDataType: mapFieldDataType(field.dataType),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two `TelemetryFieldKey` lists, de-duping by `name`. Earlier entries win
|
||||
* (so callers can pass user-controlled fields after a baseline list and have
|
||||
* the baseline's metadata be preserved).
|
||||
*/
|
||||
export function mergeTelemetryFieldKeys(
|
||||
...lists: TelemetryFieldKey[][]
|
||||
): TelemetryFieldKey[] {
|
||||
const seen = new Set<string>();
|
||||
const out: TelemetryFieldKey[] = [];
|
||||
for (const list of lists) {
|
||||
for (const f of list) {
|
||||
if (seen.has(f.name)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(f.name);
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -23,7 +23,14 @@ const editorOptions: EditorProps['options'] = {
|
||||
lineHeight: 18,
|
||||
colorDecorators: true,
|
||||
scrollBeyondLastLine: false,
|
||||
scrollbar: { vertical: 'hidden', horizontal: 'hidden' },
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontal: 'hidden',
|
||||
// Once the editor can't scroll any further, release the wheel event so
|
||||
// the parent container picks it up. Without this Monaco swallows the
|
||||
// event at the boundary and outer scroll feels stuck.
|
||||
alwaysConsumeMouseWheel: false,
|
||||
},
|
||||
folding: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -56,6 +56,13 @@ export interface PrettyViewProps {
|
||||
searchable?: boolean;
|
||||
showPinned?: boolean;
|
||||
drawerKey?: string;
|
||||
/**
|
||||
* Controlled list of pinned key paths (each entry is `JSON.stringify(path)`).
|
||||
* When provided, PrettyView delegates persistence to the caller via
|
||||
* `onPinnedFieldsChange` and skips its own localStorage I/O.
|
||||
*/
|
||||
pinnedFieldsValue?: string[];
|
||||
onPinnedFieldsChange?: (next: string[]) => void;
|
||||
}
|
||||
|
||||
function PrettyView({
|
||||
@@ -65,6 +72,8 @@ function PrettyView({
|
||||
searchable = true,
|
||||
showPinned = false,
|
||||
drawerKey = 'default',
|
||||
pinnedFieldsValue,
|
||||
onPinnedFieldsChange,
|
||||
}: PrettyViewProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
@@ -75,7 +84,10 @@ function PrettyView({
|
||||
pinnedEntries,
|
||||
pinnedData,
|
||||
displayKeyToForwardPath,
|
||||
} = usePinnedFields(data, drawerKey);
|
||||
} = usePinnedFields(data, drawerKey, {
|
||||
value: pinnedFieldsValue,
|
||||
onChange: onPinnedFieldsChange,
|
||||
});
|
||||
|
||||
const filteredPinnedData = useMemo(() => {
|
||||
const trimmed = searchQuery.trim();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
|
||||
@@ -42,17 +42,58 @@ export interface UsePinnedFieldsReturn {
|
||||
displayKeyToForwardPath: Record<string, (string | number)[]>;
|
||||
}
|
||||
|
||||
export interface UsePinnedFieldsOptions {
|
||||
/**
|
||||
* Initial / controlled list of serialized key paths.
|
||||
* When provided, overrides the default localStorage read.
|
||||
*/
|
||||
value?: string[];
|
||||
/**
|
||||
* Called whenever the pin set changes. When provided, the caller is
|
||||
* responsible for persistence (e.g. backend user preference).
|
||||
*/
|
||||
onChange?: (next: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence behavior:
|
||||
* - Controlled (`options.value`/`options.onChange` provided) → caller drives
|
||||
* state and persistence. localStorage is not touched, regardless of
|
||||
* `drawerKey`.
|
||||
* - Uncontrolled with `drawerKey` → reads/writes `pinnedFields:${drawerKey}`
|
||||
* in localStorage.
|
||||
* - Uncontrolled without `drawerKey` → in-memory only (no persistence).
|
||||
*/
|
||||
function usePinnedFields(
|
||||
data: AnyRecord,
|
||||
drawerKey: string,
|
||||
drawerKey?: string,
|
||||
options?: UsePinnedFieldsOptions,
|
||||
): UsePinnedFieldsReturn {
|
||||
const storageKey = `${STORAGE_PREFIX}:${drawerKey}`;
|
||||
const controlledValue = options?.value;
|
||||
const onChange = options?.onChange;
|
||||
const isControlled = controlledValue !== undefined || onChange !== undefined;
|
||||
const storageKey =
|
||||
!isControlled && drawerKey ? `${STORAGE_PREFIX}:${drawerKey}` : null;
|
||||
|
||||
// Stored as serialized keyPath arrays (JSON strings)
|
||||
const [pinnedSerializedKeys, setPinnedSerializedKeys] = useState<Set<string>>(
|
||||
() => new Set(loadFromStorage(storageKey)),
|
||||
() => {
|
||||
if (controlledValue) {
|
||||
return new Set(controlledValue);
|
||||
}
|
||||
if (storageKey) {
|
||||
return new Set(loadFromStorage(storageKey));
|
||||
}
|
||||
return new Set();
|
||||
},
|
||||
);
|
||||
|
||||
// Sync state with the controlled value when it changes externally.
|
||||
useEffect(() => {
|
||||
if (controlledValue) {
|
||||
setPinnedSerializedKeys(new Set(controlledValue));
|
||||
}
|
||||
}, [controlledValue]);
|
||||
|
||||
const togglePin = useCallback(
|
||||
(forwardPath: (string | number)[]): void => {
|
||||
const serialized = serializeKeyPath(forwardPath);
|
||||
@@ -63,11 +104,17 @@ function usePinnedFields(
|
||||
} else {
|
||||
next.add(serialized);
|
||||
}
|
||||
saveToStorage(storageKey, Array.from(next));
|
||||
const arr = Array.from(next);
|
||||
if (storageKey) {
|
||||
saveToStorage(storageKey, arr);
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(arr);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[storageKey],
|
||||
[storageKey, onChange],
|
||||
);
|
||||
|
||||
const isPinned = useCallback(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export interface TraceDetailFlamegraphURLProps {
|
||||
id: string;
|
||||
}
|
||||
@@ -6,6 +8,7 @@ export interface GetTraceFlamegraphPayloadProps {
|
||||
traceId: string;
|
||||
selectedSpanId?: string;
|
||||
limit?: number;
|
||||
selectFields?: TelemetryFieldKey[];
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
@@ -26,6 +29,8 @@ export interface FlamegraphSpan {
|
||||
name: string;
|
||||
level: number;
|
||||
event: Event[];
|
||||
resource?: Record<string, string>;
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GetTraceFlamegraphSuccessResponse {
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export type WaterfallAggregationType =
|
||||
| 'span_count'
|
||||
| 'execution_time_percentage'
|
||||
| 'duration';
|
||||
|
||||
export interface WaterfallAggregationRequest {
|
||||
field: TelemetryFieldKey;
|
||||
aggregation: WaterfallAggregationType;
|
||||
}
|
||||
|
||||
export interface WaterfallAggregationResponse extends WaterfallAggregationRequest {
|
||||
value: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface GetTraceV3PayloadProps {
|
||||
traceId: string;
|
||||
selectedSpanId: string;
|
||||
uncollapsedSpans: string[];
|
||||
isSelectedSpanIDUnCollapsed: boolean;
|
||||
limit?: number; // Optional limit for number of spans to fetch, default can be set in API
|
||||
aggregations?: WaterfallAggregationRequest[];
|
||||
}
|
||||
|
||||
export interface TraceDetailV3URLProps {
|
||||
@@ -81,5 +98,5 @@ export interface GetTraceV3SuccessResponse {
|
||||
totalErrorSpansCount: number;
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
serviceNameToTotalDurationMap: Record<string, number>;
|
||||
aggregations?: WaterfallAggregationResponse[];
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('extractQueryPairs', () => {
|
||||
valueEnd: undefined,
|
||||
valueStart: undefined,
|
||||
},
|
||||
isComplete: false,
|
||||
isComplete: true,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
@@ -204,7 +204,7 @@ describe('extractQueryPairs', () => {
|
||||
key: 'active',
|
||||
operator: 'EXISTS',
|
||||
value: undefined,
|
||||
isComplete: false,
|
||||
isComplete: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -388,7 +388,7 @@ describe('extractQueryPairs', () => {
|
||||
expect(result[0].operator).toBe('exists');
|
||||
expect(result[0].value).toBeUndefined();
|
||||
expect(result[0].valuesPosition).toStrictEqual([]);
|
||||
expect(result[0].isComplete).toBe(false);
|
||||
expect(result[0].isComplete).toBe(true);
|
||||
expect(result[1].key).toBe('service.name');
|
||||
expect(result[1].operator).toBe('contains');
|
||||
expect(result[1].value).toBe('"test"');
|
||||
|
||||
@@ -1208,7 +1208,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
|
||||
isComplete: !!(
|
||||
currentPair.key &&
|
||||
currentPair.operator &&
|
||||
currentPair.value
|
||||
(currentPair.value || isNonValueOperator(currentPair.operator))
|
||||
),
|
||||
} as IQueryPair);
|
||||
}
|
||||
@@ -1369,7 +1369,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
|
||||
isComplete: !!(
|
||||
currentPair.key &&
|
||||
currentPair.operator &&
|
||||
currentPair.value
|
||||
(currentPair.value || isNonValueOperator(currentPair.operator))
|
||||
),
|
||||
} as IQueryPair);
|
||||
|
||||
@@ -1414,7 +1414,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
|
||||
isComplete: !!(
|
||||
currentPair.key &&
|
||||
currentPair.operator &&
|
||||
currentPair.value
|
||||
(currentPair.value || isNonValueOperator(currentPair.operator))
|
||||
),
|
||||
} as IQueryPair);
|
||||
}
|
||||
|
||||
@@ -70,12 +70,39 @@ func (b *traceOperatorCTEBuilder) build(ctx context.Context, requestType qbtypes
|
||||
|
||||
selectFromCTE := rootCTEName
|
||||
if b.operator.ReturnSpansFrom != "" {
|
||||
selectFromCTE = b.queryToCTEName[b.operator.ReturnSpansFrom]
|
||||
if selectFromCTE == "" {
|
||||
sourceQueryCTE := b.queryToCTEName[b.operator.ReturnSpansFrom]
|
||||
if sourceQueryCTE == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"returnSpansFrom references query '%s' which has no corresponding CTE",
|
||||
b.operator.ReturnSpansFrom)
|
||||
}
|
||||
filteredCTEName := fmt.Sprintf("__return_from_%s", b.operator.ReturnSpansFrom)
|
||||
|
||||
// DISTINCT is essential here. The operator CTE (rootCTEName) holds one row
|
||||
// per matching *span*, not one row per matching *trace*. A single trace can
|
||||
// satisfy the operator through multiple spans — e.g. for "A -> B", every
|
||||
// A-span that is an indirect ancestor of any B-span appears as a separate
|
||||
// row. If we joined sourceQueryCTE directly against rootCTEName on trace_id,
|
||||
// each source span would be duplicated once for every operator-matching span
|
||||
// on the same trace. DISTINCT collapses rootCTEName to one row per trace_id,
|
||||
// making the join a clean membership test with no fan-out.
|
||||
matchingTracedSB := sqlbuilder.NewSelectBuilder()
|
||||
matchingTracedSB.Select("DISTINCT trace_id")
|
||||
matchingTracedSB.From(rootCTEName)
|
||||
matchedTracesSQL, matchedTracesArgs := matchingTracedSB.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
filteredSB := sqlbuilder.NewSelectBuilder()
|
||||
filteredSB.Select("src.*")
|
||||
filteredSB.From(fmt.Sprintf("%s AS src", sourceQueryCTE))
|
||||
filteredSB.JoinWithOption(
|
||||
sqlbuilder.InnerJoin,
|
||||
fmt.Sprintf("(%s) AS matched_traces", matchedTracesSQL),
|
||||
"src.trace_id = matched_traces.trace_id",
|
||||
)
|
||||
filteredSQL, filteredArgs := filteredSB.BuildWithFlavor(sqlbuilder.ClickHouse, matchedTracesArgs...)
|
||||
|
||||
b.addCTE(filteredCTEName, filteredSQL, filteredArgs, []string{sourceQueryCTE, rootCTEName})
|
||||
selectFromCTE = filteredCTEName
|
||||
}
|
||||
|
||||
finalStmt, err := b.buildFinalQuery(ctx, selectFromCTE, requestType)
|
||||
|
||||
@@ -385,6 +385,82 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "returnSpansFrom B: A -> B return B spans filtered by operator",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
operator: qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "A -> B",
|
||||
ReturnSpansFrom: "B",
|
||||
Limit: 10,
|
||||
},
|
||||
compositeQuery: &qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'gateway'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'database'"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __return_from_B AS (SELECT src.* FROM B AS src INNER JOIN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B) AS matched_traces ON src.trace_id = matched_traces.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM __return_from_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "returnSpansFrom C: (A -> B) && C return C spans filtered by operator",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
operator: qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "(A -> B) && C",
|
||||
ReturnSpansFrom: "C",
|
||||
Limit: 10,
|
||||
},
|
||||
compositeQuery: &qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'gateway'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'database'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "C",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'auth'"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B_AND_C AS (SELECT l.* FROM A_INDIR_DESC_B AS l INNER JOIN C AS r ON l.trace_id = r.trace_id), __return_from_C AS (SELECT src.* FROM C AS src INNER JOIN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B_AND_C) AS matched_traces ON src.trace_id = matched_traces.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM __return_from_C ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "auth", "%service.name%", "%service.name\":\"auth%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
|
||||
3
tests/fixtures/querier.py
vendored
3
tests/fixtures/querier.py
vendored
@@ -72,6 +72,7 @@ class TraceOperatorQuery:
|
||||
return_spans_from: str
|
||||
limit: int | None = None
|
||||
order: list[OrderBy] | None = None
|
||||
select_fields: list[TelemetryFieldKey] | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
spec: dict[str, Any] = {
|
||||
@@ -83,6 +84,8 @@ class TraceOperatorQuery:
|
||||
spec["limit"] = self.limit
|
||||
if self.order:
|
||||
spec["order"] = [o.to_dict() if hasattr(o, "to_dict") else o for o in self.order]
|
||||
if self.select_fields:
|
||||
spec["selectFields"] = [f.to_dict() for f in self.select_fields]
|
||||
return {"type": "builder_trace_operator", "spec": spec}
|
||||
|
||||
|
||||
|
||||
537
tests/integration/tests/querier/15_trace_operator.py
Normal file
537
tests/integration/tests/querier/15_trace_operator.py
Normal file
@@ -0,0 +1,537 @@
|
||||
"""
|
||||
Integration tests for TraceOperatorQuery (builder_trace_operator) through the
|
||||
/api/v5/query_range endpoint.
|
||||
|
||||
Covers:
|
||||
1. Basic trace operator (A => B) — returns matched spans from the correct trace.
|
||||
2. Order by a field absent from selectFields — must not return a server error.
|
||||
Guards against the ClickHouse NOT_FOUND_COLUMN_IN_BLOCK regression where
|
||||
ordering by a column absent from an outer SELECT caused a query failure.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
OrderBy,
|
||||
TelemetryFieldKey,
|
||||
TraceOperatorQuery,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
def _builder_query(name: str, filter_expr: str, limit: int = 100) -> dict:
|
||||
return {
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": name,
|
||||
"signal": "traces",
|
||||
"filter": {"expression": filter_expr},
|
||||
"limit": limit,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_trace_operator_query_basic(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Insert one parent span and one child span in the same trace.
|
||||
|
||||
Tests:
|
||||
A => B (parent has a direct child) returns the parent span (returnSpansFrom=A)
|
||||
from the correct trace.
|
||||
"""
|
||||
parent_trace_id = TraceIdGenerator.trace_id()
|
||||
parent_span_id = TraceIdGenerator.span_id()
|
||||
child_span_id = TraceIdGenerator.span_id()
|
||||
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=10),
|
||||
duration=timedelta(seconds=5),
|
||||
trace_id=parent_trace_id,
|
||||
span_id=parent_span_id,
|
||||
parent_span_id="",
|
||||
name="parent-op",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-a"},
|
||||
attributes={"operation.type": "parent"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=9),
|
||||
duration=timedelta(seconds=2),
|
||||
trace_id=parent_trace_id,
|
||||
span_id=child_span_id,
|
||||
parent_span_id=parent_span_id,
|
||||
name="child-op",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-a"},
|
||||
attributes={"operation.type": "child"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type="raw",
|
||||
queries=[
|
||||
_builder_query("A", "operation.type = 'parent'"),
|
||||
_builder_query("B", "operation.type = 'child'"),
|
||||
TraceOperatorQuery(
|
||||
name="C",
|
||||
expression="A => B",
|
||||
return_spans_from="A",
|
||||
limit=100,
|
||||
).to_dict(),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
rows = results[0].get("rows") or []
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["data"]["trace_id"] == parent_trace_id
|
||||
assert rows[0]["data"]["name"] == "parent-op"
|
||||
|
||||
|
||||
def test_trace_operator_query_order_by_field_not_in_select_fields(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Two traces, each with a grandparent → middle → grandchild chain:
|
||||
Trace 1: grandparent (svc-a, http.method=POST) → middle → grandchild
|
||||
Trace 2: grandparent (svc-b, http.method=GET) → middle → grandchild
|
||||
|
||||
Tests:
|
||||
A -> B (indirect descendant) with selectFields=[service.name] and
|
||||
order=[http.method DESC], where http.method is NOT in selectFields.
|
||||
|
||||
1. Query succeeds (no NOT_FOUND_COLUMN_IN_BLOCK error from ClickHouse).
|
||||
2. Results are actually ordered: POST sorts before GET descending, so
|
||||
svc-a must come before svc-b.
|
||||
"""
|
||||
trace_id_1 = TraceIdGenerator.trace_id()
|
||||
trace_id_2 = TraceIdGenerator.trace_id()
|
||||
|
||||
gp_span_id_1 = TraceIdGenerator.span_id()
|
||||
mid_span_id_1 = TraceIdGenerator.span_id()
|
||||
gc_span_id_1 = TraceIdGenerator.span_id()
|
||||
|
||||
gp_span_id_2 = TraceIdGenerator.span_id()
|
||||
mid_span_id_2 = TraceIdGenerator.span_id()
|
||||
gc_span_id_2 = TraceIdGenerator.span_id()
|
||||
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
# Trace 1 — grandparent has http.method=POST (sorts first in DESC)
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=10),
|
||||
duration=timedelta(seconds=5),
|
||||
trace_id=trace_id_1,
|
||||
span_id=gp_span_id_1,
|
||||
parent_span_id="",
|
||||
name="gp-op",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-a"},
|
||||
attributes={"operation.type": "grandparent", "http.method": "POST"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=9),
|
||||
duration=timedelta(seconds=3),
|
||||
trace_id=trace_id_1,
|
||||
span_id=mid_span_id_1,
|
||||
parent_span_id=gp_span_id_1,
|
||||
name="mid-op",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-a"},
|
||||
attributes={"operation.type": "middle"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=8),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id_1,
|
||||
span_id=gc_span_id_1,
|
||||
parent_span_id=mid_span_id_1,
|
||||
name="gc-op",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-a"},
|
||||
attributes={"operation.type": "grandchild"},
|
||||
),
|
||||
# Trace 2 — grandparent has http.method=GET (sorts second in DESC)
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=7),
|
||||
duration=timedelta(seconds=5),
|
||||
trace_id=trace_id_2,
|
||||
span_id=gp_span_id_2,
|
||||
parent_span_id="",
|
||||
name="gp-op",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-b"},
|
||||
attributes={"operation.type": "grandparent", "http.method": "GET"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=6),
|
||||
duration=timedelta(seconds=3),
|
||||
trace_id=trace_id_2,
|
||||
span_id=mid_span_id_2,
|
||||
parent_span_id=gp_span_id_2,
|
||||
name="mid-op",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-b"},
|
||||
attributes={"operation.type": "middle"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=5),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id_2,
|
||||
span_id=gc_span_id_2,
|
||||
parent_span_id=mid_span_id_2,
|
||||
name="gc-op",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-b"},
|
||||
attributes={"operation.type": "grandchild"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type="raw",
|
||||
queries=[
|
||||
_builder_query("A", "operation.type = 'grandparent'"),
|
||||
_builder_query("B", "operation.type = 'grandchild'"),
|
||||
TraceOperatorQuery(
|
||||
name="C",
|
||||
expression="A -> B", # indirect descendant
|
||||
return_spans_from="A",
|
||||
limit=100,
|
||||
select_fields=[
|
||||
TelemetryFieldKey(name="service.name", field_data_type="string", field_context="resource"),
|
||||
],
|
||||
order=[
|
||||
# http.method is intentionally absent from select_fields
|
||||
OrderBy(
|
||||
key=TelemetryFieldKey(name="http.method", field_data_type="string", field_context="attribute"),
|
||||
direction="desc",
|
||||
),
|
||||
],
|
||||
).to_dict(),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
rows = results[0].get("rows") or []
|
||||
|
||||
# Both grandparent spans must be returned
|
||||
assert len(rows) == 2
|
||||
|
||||
# Ordering: POST > GET in DESC — svc-a (POST) must come before svc-b (GET)
|
||||
assert rows[0]["data"]["service.name"] == "svc-a", f"Expected svc-a (POST) first in http.method DESC order, got {rows[0]['data']['service.name']}"
|
||||
assert rows[1]["data"]["service.name"] == "svc-b", f"Expected svc-b (GET) second in http.method DESC order, got {rows[1]['data']['service.name']}"
|
||||
|
||||
|
||||
def test_trace_operator_query_order_by_select_field(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Two traces each with a parent → child pair; the parents have distinct durations
|
||||
(5 s and 1 s).
|
||||
|
||||
Tests:
|
||||
A => B with order=[duration_nano DESC] and no explicit selectFields.
|
||||
1. Query succeeds (no NOT_FOUND_COLUMN_IN_BLOCK error).
|
||||
2. Longer-duration parent (5 s) appears before the shorter one (1 s).
|
||||
"""
|
||||
trace_id_1 = TraceIdGenerator.trace_id()
|
||||
trace_id_2 = TraceIdGenerator.trace_id()
|
||||
parent_span_id_1 = TraceIdGenerator.span_id()
|
||||
child_span_id_1 = TraceIdGenerator.span_id()
|
||||
parent_span_id_2 = TraceIdGenerator.span_id()
|
||||
child_span_id_2 = TraceIdGenerator.span_id()
|
||||
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=10),
|
||||
duration=timedelta(seconds=5),
|
||||
trace_id=trace_id_1,
|
||||
span_id=parent_span_id_1,
|
||||
parent_span_id="",
|
||||
name="parent-long",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-long"},
|
||||
attributes={"operation.type": "parent"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=9),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id_1,
|
||||
span_id=child_span_id_1,
|
||||
parent_span_id=parent_span_id_1,
|
||||
name="child-long",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-long"},
|
||||
attributes={"operation.type": "child"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=8),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id_2,
|
||||
span_id=parent_span_id_2,
|
||||
parent_span_id="",
|
||||
name="parent-short",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-short"},
|
||||
attributes={"operation.type": "parent"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=7),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id_2,
|
||||
span_id=child_span_id_2,
|
||||
parent_span_id=parent_span_id_2,
|
||||
name="child-short",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-short"},
|
||||
attributes={"operation.type": "child"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type="raw",
|
||||
queries=[
|
||||
_builder_query("A", "operation.type = 'parent'"),
|
||||
_builder_query("B", "operation.type = 'child'"),
|
||||
TraceOperatorQuery(
|
||||
name="C",
|
||||
expression="A => B",
|
||||
return_spans_from="A",
|
||||
limit=100,
|
||||
order=[
|
||||
OrderBy(
|
||||
key=TelemetryFieldKey(name="duration_nano", field_context="span"),
|
||||
direction="desc",
|
||||
),
|
||||
],
|
||||
).to_dict(),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
rows = results[0].get("rows") or []
|
||||
|
||||
assert len(rows) == 2
|
||||
# DESC: 5 s parent first, 1 s parent second
|
||||
assert rows[0]["data"]["name"] == "parent-long", f"Expected parent-long (5s) first in duration_nano DESC, got {rows[0]['data']['name']}"
|
||||
assert rows[1]["data"]["name"] == "parent-short", f"Expected parent-short (1s) second in duration_nano DESC, got {rows[1]['data']['name']}"
|
||||
|
||||
|
||||
def test_trace_operator_query_order_by_non_core_field_in_select(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Two traces each with a parent → child pair; parents have distinct http.method
|
||||
values (POST and GET).
|
||||
|
||||
Tests:
|
||||
A => B with selectFields=[http.method] and order=[http.method DESC].
|
||||
1. Query succeeds (no NOT_FOUND_COLUMN_IN_BLOCK error).
|
||||
2. http.method is present in every result row (it is in selectFields).
|
||||
3. Results are ordered DESC — POST before GET.
|
||||
"""
|
||||
trace_id_1 = TraceIdGenerator.trace_id()
|
||||
trace_id_2 = TraceIdGenerator.trace_id()
|
||||
parent_span_id_1 = TraceIdGenerator.span_id()
|
||||
child_span_id_1 = TraceIdGenerator.span_id()
|
||||
parent_span_id_2 = TraceIdGenerator.span_id()
|
||||
child_span_id_2 = TraceIdGenerator.span_id()
|
||||
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=10),
|
||||
duration=timedelta(seconds=3),
|
||||
trace_id=trace_id_1,
|
||||
span_id=parent_span_id_1,
|
||||
parent_span_id="",
|
||||
name="parent-post",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-post"},
|
||||
attributes={"operation.type": "parent", "http.method": "POST"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=9),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id_1,
|
||||
span_id=child_span_id_1,
|
||||
parent_span_id=parent_span_id_1,
|
||||
name="child-post",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-post"},
|
||||
attributes={"operation.type": "child"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=8),
|
||||
duration=timedelta(seconds=3),
|
||||
trace_id=trace_id_2,
|
||||
span_id=parent_span_id_2,
|
||||
parent_span_id="",
|
||||
name="parent-get",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-get"},
|
||||
attributes={"operation.type": "parent", "http.method": "GET"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=7),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id_2,
|
||||
span_id=child_span_id_2,
|
||||
parent_span_id=parent_span_id_2,
|
||||
name="child-get",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={"service.name": "svc-get"},
|
||||
attributes={"operation.type": "child"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
|
||||
http_method_field = TelemetryFieldKey(
|
||||
name="http.method",
|
||||
field_data_type="string",
|
||||
field_context="attribute",
|
||||
)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type="raw",
|
||||
queries=[
|
||||
_builder_query("A", "operation.type = 'parent'"),
|
||||
_builder_query("B", "operation.type = 'child'"),
|
||||
TraceOperatorQuery(
|
||||
name="C",
|
||||
expression="A => B",
|
||||
return_spans_from="A",
|
||||
limit=100,
|
||||
select_fields=[http_method_field],
|
||||
order=[OrderBy(key=http_method_field, direction="desc")],
|
||||
).to_dict(),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
rows = results[0].get("rows") or []
|
||||
|
||||
assert len(rows) == 2
|
||||
# http.method must be present in every row (it is in selectFields)
|
||||
for row in rows:
|
||||
assert "http.method" in row["data"], f"http.method missing from row: {row['data']}"
|
||||
# DESC: POST before GET
|
||||
assert rows[0]["data"]["http.method"] == "POST", f"Expected POST first in http.method DESC, got {rows[0]['data']['http.method']}"
|
||||
assert rows[1]["data"]["http.method"] == "GET", f"Expected GET second in http.method DESC, got {rows[1]['data']['http.method']}"
|
||||
Reference in New Issue
Block a user