From a1bf0e67dbd1f38450a3ebbbf316690da03e939d Mon Sep 17 00:00:00 2001 From: aks07 Date: Wed, 18 Mar 2026 18:54:19 +0530 Subject: [PATCH] feat: event dots in trace details --- .../EventTooltipContent.styles.scss | 60 ++++++++++++ .../SpanHoverCard/EventTooltipContent.tsx | 49 ++++++++++ .../TraceFlamegraph/FlamegraphCanvas.tsx | 32 ++++-- .../__tests__/drawUtils.test.ts | 48 +++++---- .../__tests__/useFlamegraphHover.test.ts | 1 + .../hooks/useFlamegraphDraw.ts | 29 ++++-- .../hooks/useFlamegraphHover.ts | 66 ++++++++++++- .../TraceDetailsV3/TraceFlamegraph/types.ts | 10 +- .../TraceDetailsV3/TraceFlamegraph/utils.ts | 97 ++++++++++++++++--- .../Success/Success.styles.scss | 4 +- .../TraceWaterfallStates/Success/Success.tsx | 96 +++++++++++------- 11 files changed, 404 insertions(+), 88 deletions(-) create mode 100644 frontend/src/pages/TraceDetailsV3/SpanHoverCard/EventTooltipContent.styles.scss create mode 100644 frontend/src/pages/TraceDetailsV3/SpanHoverCard/EventTooltipContent.tsx diff --git a/frontend/src/pages/TraceDetailsV3/SpanHoverCard/EventTooltipContent.styles.scss b/frontend/src/pages/TraceDetailsV3/SpanHoverCard/EventTooltipContent.styles.scss new file mode 100644 index 0000000000..dc2086fb2c --- /dev/null +++ b/frontend/src/pages/TraceDetailsV3/SpanHoverCard/EventTooltipContent.styles.scss @@ -0,0 +1,60 @@ +.event-tooltip-content { + font-family: Inter, sans-serif; + font-size: 12px; + color: #fff; + max-width: 300px; + + &__header { + display: inline-flex; + align-items: center; + gap: 4px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + padding: 2px 6px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 6px; + } + + &__name { + font-weight: 600; + margin-bottom: 2px; + color: rgb(14, 165, 233); + + &.error { + color: rgb(239, 68, 68); + } + } + + &__time { + font-size: 11px; + opacity: 0.8; + margin-bottom: 4px; + } + + &__divider { + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin: 6px 0; + } + + &__attributes { + font-size: 11px; + } + + &__kv { + margin-bottom: 2px; + line-height: 1.4; + word-break: break-all; + } + + &__key { + opacity: 0.6; + } + + &__value { + opacity: 0.9; + } +} diff --git a/frontend/src/pages/TraceDetailsV3/SpanHoverCard/EventTooltipContent.tsx b/frontend/src/pages/TraceDetailsV3/SpanHoverCard/EventTooltipContent.tsx new file mode 100644 index 0000000000..923ddff132 --- /dev/null +++ b/frontend/src/pages/TraceDetailsV3/SpanHoverCard/EventTooltipContent.tsx @@ -0,0 +1,49 @@ +import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils'; +import { Diamond } from 'lucide-react'; +import { toFixed } from 'utils/toFixed'; + +import './EventTooltipContent.styles.scss'; + +export interface EventTooltipContentProps { + eventName: string; + timeOffsetMs: number; + isError: boolean; + attributeMap: Record; +} + +export function EventTooltipContent({ + eventName, + timeOffsetMs, + isError, + attributeMap, +}: EventTooltipContentProps): JSX.Element { + const { time, timeUnitName } = convertTimeToRelevantUnit(timeOffsetMs); + + return ( +
+
+ + EVENT DETAILS +
+
+ {eventName} +
+
+ {toFixed(time, 2)} {timeUnitName} from start +
+ {Object.keys(attributeMap).length > 0 && ( + <> +
+
+ {Object.entries(attributeMap).map(([key, value]) => ( +
+ {key}:{' '} + {value} +
+ ))} +
+ + )} +
+ ); +} diff --git a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/FlamegraphCanvas.tsx b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/FlamegraphCanvas.tsx index 6cebf38404..d6a55e00da 100644 --- a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/FlamegraphCanvas.tsx +++ b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/FlamegraphCanvas.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'; import TimelineV3 from 'components/TimelineV3/TimelineV3'; import { useIsDarkMode } from 'hooks/useDarkMode'; +import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent'; import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard'; import { DEFAULT_ROW_HEIGHT } from './constants'; import { useCanvasSetup } from './hooks/useCanvasSetup'; @@ -12,7 +13,7 @@ import { useFlamegraphHover } from './hooks/useFlamegraphHover'; import { useFlamegraphZoom } from './hooks/useFlamegraphZoom'; import { useScrollToSpan } from './hooks/useScrollToSpan'; import { useVisualLayoutWorker } from './hooks/useVisualLayoutWorker'; -import { FlamegraphCanvasProps, SpanRect } from './types'; +import { EventRect, FlamegraphCanvasProps, SpanRect } from './types'; function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element { const { spans, traceMetadata, firstSpanAtFetchLevel, onSpanClick } = props; @@ -21,6 +22,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element { const canvasRef = useRef(null); const containerRef = useRef(null); const spanRectsRef = useRef([]); + const eventRectsRef = useRef([]); const [viewStartTs, setViewStartTs] = useState( traceMetadata.startTime, @@ -96,6 +98,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element { const { hoveredSpanId, + hoveredEventKey, handleHoverMouseMove, handleHoverMouseLeave, handleClick, @@ -103,6 +106,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element { } = useFlamegraphHover({ canvasRef, spanRectsRef, + eventRectsRef, traceMetadata, viewStartTs, viewEndTs, @@ -125,6 +129,8 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element { hoveredSpanId: hoveredSpanId ?? '', isDarkMode, spanRectsRef, + eventRectsRef, + hoveredEventKey, }); useScrollToSpan({ @@ -173,14 +179,22 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element { pointerEvents: 'none', }} > - {/* TODO: passing each content is too much, we should use the tooltipContent object directly */} - + {tooltipContent.event ? ( + + ) : ( + + )}
, document.body, ) diff --git a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/__tests__/drawUtils.test.ts b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/__tests__/drawUtils.test.ts index 3b7f474a76..acfc9bad82 100644 --- a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/__tests__/drawUtils.test.ts +++ b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/__tests__/drawUtils.test.ts @@ -1,7 +1,7 @@ import { DASHED_BORDER_LINE_DASH, MIN_WIDTH_FOR_NAME } from '../constants'; import type { FlamegraphRowMetrics } from '../utils'; import { getFlamegraphRowMetrics } from '../utils'; -import { drawEventDot, drawSpanBar } from '../utils'; +import { drawEventDot, drawSpanBar, getEventDotColor } from '../utils'; import { MOCK_SPAN } from './testUtils'; jest.mock('container/TraceDetail/utils', () => ({ @@ -85,6 +85,7 @@ describe('Canvas Draw Utils', () => { width: 100, levelIndex: 0, spanRectsArray, + eventRectsArray: [], color: '#1890ff', isDarkMode: false, metrics: METRICS, @@ -123,6 +124,7 @@ describe('Canvas Draw Utils', () => { width: 80, levelIndex: 1, spanRectsArray, + eventRectsArray: [], color: '#2F80ED', isDarkMode: false, metrics: METRICS, @@ -156,6 +158,7 @@ describe('Canvas Draw Utils', () => { width: 60, levelIndex: 0, spanRectsArray, + eventRectsArray: [], color: '#2F80ED', isDarkMode: false, metrics: METRICS, @@ -187,6 +190,7 @@ describe('Canvas Draw Utils', () => { width: 200, levelIndex: 2, spanRectsArray, + eventRectsArray: [], color: '#000', isDarkMode: false, metrics: METRICS, @@ -223,6 +227,7 @@ describe('Canvas Draw Utils', () => { width: MIN_WIDTH_FOR_NAME - 1, levelIndex: 0, spanRectsArray, + eventRectsArray: [], color: '#000', isDarkMode: false, metrics: METRICS, @@ -246,6 +251,7 @@ describe('Canvas Draw Utils', () => { width: 50, levelIndex: 0, spanRectsArray: [], + eventRectsArray: [], color: '#000', isDarkMode: false, metrics: METRICS, @@ -270,6 +276,7 @@ describe('Canvas Draw Utils', () => { width: 100, levelIndex: 0, spanRectsArray: [], + eventRectsArray: [], color: '#000', isDarkMode: false, metrics: METRICS, @@ -304,6 +311,7 @@ describe('Canvas Draw Utils', () => { width: 100, levelIndex: 0, spanRectsArray: [], + eventRectsArray: [], color: '#000', isDarkMode: false, metrics: METRICS, @@ -333,6 +341,7 @@ describe('Canvas Draw Utils', () => { width: 50, levelIndex: 0, spanRectsArray: [], + eventRectsArray: [], color: '#000', isDarkMode: false, metrics: METRICS, @@ -348,13 +357,13 @@ describe('Canvas Draw Utils', () => { describe('drawEventDot', () => { it('uses error styling when isError is true', () => { const ctx = createMockCtx(); + const color = getEventDotColor('#000', true, false); drawEventDot({ ctx, x: 50, y: 11, - isError: true, - isDarkMode: false, + color, eventDotSize: 6, }); @@ -368,31 +377,33 @@ describe('Canvas Draw Utils', () => { expect(ctx.restore).toHaveBeenCalled(); }); - it('uses normal styling when isError is false', () => { + it('derives color from span color when isError is false', () => { const ctx = createMockCtx(); + const color = getEventDotColor('rgb(100, 200, 150)', false, false); drawEventDot({ ctx, x: 0, y: 0, - isError: false, - isDarkMode: false, + color, eventDotSize: 6, }); - expect(ctx.fillStyle).toBe('rgb(6, 182, 212)'); - expect(ctx.strokeStyle).toBe('rgb(8, 145, 178)'); + // Darkened by 20% for fill + expect(ctx.fillStyle).toBe('rgb(80, 160, 120)'); + // Darkened by 40% for stroke + expect(ctx.strokeStyle).toBe('rgb(60, 120, 90)'); }); it('uses dark mode colors for error', () => { const ctx = createMockCtx(); + const color = getEventDotColor('#000', true, true); drawEventDot({ ctx, x: 0, y: 0, - isError: true, - isDarkMode: true, + color, eventDotSize: 6, }); @@ -400,31 +411,31 @@ describe('Canvas Draw Utils', () => { expect(ctx.strokeStyle).toBe('rgb(185, 28, 28)'); }); - it('uses dark mode colors for non-error', () => { + it('falls back to cyan/blue for unparseable span colors', () => { const ctx = createMockCtx(); + const color = getEventDotColor('hsl(200, 50%, 50%)', false, false); drawEventDot({ ctx, x: 0, y: 0, - isError: false, - isDarkMode: true, + color, eventDotSize: 6, }); - expect(ctx.fillStyle).toBe('rgb(14, 165, 233)'); - expect(ctx.strokeStyle).toBe('rgb(2, 132, 199)'); + expect(ctx.fillStyle).toBe('rgb(6, 182, 212)'); + expect(ctx.strokeStyle).toBe('rgb(8, 145, 178)'); }); it('calls save, translate, rotate, restore', () => { const ctx = createMockCtx(); + const color = getEventDotColor('#000', false, false); drawEventDot({ ctx, x: 10, y: 20, - isError: false, - isDarkMode: false, + color, eventDotSize: 4, }); @@ -449,6 +460,7 @@ describe('Canvas Draw Utils', () => { width: MIN_WIDTH_FOR_NAME - 1, levelIndex: 0, spanRectsArray: [], + eventRectsArray: [], color: '#000', isDarkMode: false, metrics: METRICS, @@ -472,6 +484,7 @@ describe('Canvas Draw Utils', () => { width: MIN_WIDTH_FOR_NAME - 1, levelIndex: 0, spanRectsArray: [], + eventRectsArray: [], color: '#000', isDarkMode: false, metrics: METRICS, @@ -512,6 +525,7 @@ describe('Canvas Draw Utils', () => { width: 100, levelIndex: 0, spanRectsArray: [], + eventRectsArray: [], color: '#000', isDarkMode: false, metrics: METRICS, diff --git a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/__tests__/useFlamegraphHover.test.ts b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/__tests__/useFlamegraphHover.test.ts index 80d0b9b408..3b6d89635b 100644 --- a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/__tests__/useFlamegraphHover.test.ts +++ b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/__tests__/useFlamegraphHover.test.ts @@ -38,6 +38,7 @@ const spanRect: SpanRect = { const defaultArgs = { canvasRef: { current: createMockCanvas() }, spanRectsRef: { current: [spanRect] }, + eventRectsRef: { current: [] as any[] }, traceMetadata: MOCK_TRACE_METADATA, viewStartTs: MOCK_TRACE_METADATA.startTime, viewEndTs: MOCK_TRACE_METADATA.endTime, diff --git a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/hooks/useFlamegraphDraw.ts b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/hooks/useFlamegraphDraw.ts index cad5866bff..664b835498 100644 --- a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/hooks/useFlamegraphDraw.ts +++ b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/hooks/useFlamegraphDraw.ts @@ -4,7 +4,7 @@ import { generateColor } from 'lib/uPlotLib/utils/generateColor'; import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph'; import { ConnectorLine } from '../computeVisualLayout'; -import { SpanRect } from '../types'; +import { EventRect, SpanRect } from '../types'; import { clamp, drawSpanBar, @@ -26,11 +26,14 @@ interface UseFlamegraphDrawArgs { hoveredSpanId: string; isDarkMode: boolean; spanRectsRef?: React.MutableRefObject; + eventRectsRef?: React.MutableRefObject; + hoveredEventKey?: string | null; } interface UseFlamegraphDrawResult { drawFlamegraph: () => void; spanRectsRef: RefObject; + eventRectsRef: RefObject; } const OVERSCAN_ROWS = 4; @@ -47,7 +50,9 @@ interface DrawLevelArgs { hoveredSpanId: string; isDarkMode: boolean; spanRectsArray: SpanRect[]; + eventRectsArray: EventRect[]; metrics: FlamegraphRowMetrics; + hoveredEventKey?: string | null; } function drawLevel(args: DrawLevelArgs): void { @@ -63,7 +68,9 @@ function drawLevel(args: DrawLevelArgs): void { hoveredSpanId, isDarkMode, spanRectsArray, + eventRectsArray, metrics, + hoveredEventKey, } = args; const viewEndTs = viewStartTs + timeSpan; @@ -109,11 +116,13 @@ function drawLevel(args: DrawLevelArgs): void { width, levelIndex, spanRectsArray, + eventRectsArray, color, isDarkMode, metrics, selectedSpanId, hoveredSpanId, + hoveredEventKey, }); } } @@ -196,10 +205,14 @@ export function useFlamegraphDraw( hoveredSpanId, isDarkMode, spanRectsRef: spanRectsRefProp, + eventRectsRef: eventRectsRefProp, + hoveredEventKey, } = args; const spanRectsRefInternal = useRef([]); const spanRectsRef = spanRectsRefProp ?? spanRectsRefInternal; + const eventRectsRefInternal = useRef([]); + const eventRectsRef = eventRectsRefProp ?? eventRectsRefInternal; const drawFlamegraph = useCallback(() => { const canvas = canvasRef.current; @@ -253,6 +266,8 @@ export function useFlamegraphDraw( }); const spanRectsArray: SpanRect[] = []; + const eventRectsArray: EventRect[] = []; + const currentHoveredEventKey = hoveredEventKey ?? null; // ---- Draw only visible levels ---- for (let levelIndex = firstLevel; levelIndex <= lastLevel; levelIndex++) { @@ -273,15 +288,19 @@ export function useFlamegraphDraw( hoveredSpanId, isDarkMode, spanRectsArray, + eventRectsArray, metrics, + hoveredEventKey: currentHoveredEventKey, }); } spanRectsRef.current = spanRectsArray; + eventRectsRef.current = eventRectsArray; }, [ canvasRef, containerRef, spanRectsRef, + eventRectsRef, spans, connectors, viewStartTs, @@ -290,13 +309,9 @@ export function useFlamegraphDraw( rowHeight, selectedSpanId, hoveredSpanId, + hoveredEventKey, isDarkMode, ]); - // TODO: spanRectsRef is a flat array — hover scans all visible rects O(N). - // Upgrade to per-level buckets: spanRects[levelIndex] = [...] so hover can - // compute level from mouseY / ROW_HEIGHT and scan only that row. - // Further: binary search within a level by x (spans are sorted by start time) - // to reduce hover cost from O(N) to O(log N). - return { drawFlamegraph, spanRectsRef }; + return { drawFlamegraph, spanRectsRef, eventRectsRef }; } diff --git a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/hooks/useFlamegraphHover.ts b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/hooks/useFlamegraphHover.ts index c6866cd274..96beb2643c 100644 --- a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/hooks/useFlamegraphHover.ts +++ b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/hooks/useFlamegraphHover.ts @@ -9,7 +9,7 @@ import { } from 'react'; import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph'; -import { SpanRect } from '../types'; +import { EventRect, SpanRect } from '../types'; import { ITraceMetadata } from '../types'; import { getSpanColor } from '../utils'; @@ -46,6 +46,28 @@ function findSpanAtPosition( return null; } +function findEventAtPosition( + cssX: number, + cssY: number, + eventRects: EventRect[], +): EventRect | null { + for (let i = eventRects.length - 1; i >= 0; i--) { + const r = eventRects[i]; + // Manhattan distance check for diamond shape with padding + if (Math.abs(r.cx - cssX) + Math.abs(r.cy - cssY) <= r.halfSize * 1.5) { + return r; + } + } + return null; +} + +export interface EventTooltipData { + name: string; + timeOffsetMs: number; + isError: boolean; + attributeMap: Record; +} + export interface TooltipContent { serviceName: string; spanName: string; @@ -55,11 +77,13 @@ export interface TooltipContent { clientX: number; clientY: number; spanColor: string; + event?: EventTooltipData; } interface UseFlamegraphHoverArgs { canvasRef: RefObject; spanRectsRef: MutableRefObject; + eventRectsRef: MutableRefObject; traceMetadata: ITraceMetadata; viewStartTs: number; viewEndTs: number; @@ -72,6 +96,7 @@ interface UseFlamegraphHoverArgs { interface UseFlamegraphHoverResult { hoveredSpanId: string | null; setHoveredSpanId: Dispatch>; + hoveredEventKey: string | null; handleHoverMouseMove: (e: ReactMouseEvent) => void; handleHoverMouseLeave: () => void; handleClick: (e: ReactMouseEvent) => void; @@ -84,6 +109,7 @@ export function useFlamegraphHover( const { canvasRef, spanRectsRef, + eventRectsRef, traceMetadata, viewStartTs, viewEndTs, @@ -94,6 +120,7 @@ export function useFlamegraphHover( } = args; const [hoveredSpanId, setHoveredSpanId] = useState(null); + const [hoveredEventKey, setHoveredEventKey] = useState(null); const [tooltipContent, setTooltipContent] = useState( null, ); @@ -131,6 +158,38 @@ export function useFlamegraphHover( return; } + // Check event dots first — they're drawn on top of spans + const eventRect = findEventAtPosition( + pointer.cssX, + pointer.cssY, + eventRectsRef.current, + ); + + if (eventRect) { + const { event, span } = eventRect; + const eventTimeMs = event.timeUnixNano / 1e6; + setHoveredEventKey(`${span.spanId}-${event.name}-${event.timeUnixNano}`); + setHoveredSpanId(span.spanId); + setTooltipContent({ + serviceName: span.serviceName || '', + 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 }), + event: { + name: event.name, + timeOffsetMs: eventTimeMs - span.timestamp, + isError: event.isError, + attributeMap: event.attributeMap || {}, + }, + }); + updateCursor(canvas, eventRect.span); + return; + } + const span = findSpanAtPosition( pointer.cssX, pointer.cssY, @@ -138,6 +197,7 @@ export function useFlamegraphHover( ); if (span) { + setHoveredEventKey(null); setHoveredSpanId(span.spanId); setTooltipContent({ serviceName: span.serviceName || '', @@ -151,6 +211,7 @@ export function useFlamegraphHover( }); updateCursor(canvas, span); } else { + setHoveredEventKey(null); setHoveredSpanId(null); setTooltipContent(null); updateCursor(canvas, null); @@ -159,6 +220,7 @@ export function useFlamegraphHover( [ canvasRef, spanRectsRef, + eventRectsRef, traceMetadata.startTime, isDraggingRef, updateCursor, @@ -167,6 +229,7 @@ export function useFlamegraphHover( ); const handleHoverMouseLeave = useCallback((): void => { + setHoveredEventKey(null); setHoveredSpanId(null); setTooltipContent(null); @@ -208,6 +271,7 @@ export function useFlamegraphHover( return { hoveredSpanId, setHoveredSpanId, + hoveredEventKey, handleHoverMouseMove, handleHoverMouseLeave, handleClick, diff --git a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/types.ts b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/types.ts index bfb8e54fca..82198760c1 100644 --- a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/types.ts +++ b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/types.ts @@ -1,5 +1,5 @@ import { Dispatch, SetStateAction } from 'react'; -import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph'; +import { Event, FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph'; export interface ITraceMetadata { startTime: number; @@ -22,3 +22,11 @@ export interface SpanRect { height: number; level: number; } + +export interface EventRect { + event: Event; + span: FlamegraphSpan; + cx: number; + cy: number; + halfSize: number; +} diff --git a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/utils.ts b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/utils.ts index f2f1a22111..8dc6ae5728 100644 --- a/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/utils.ts +++ b/frontend/src/pages/TraceDetailsV3/TraceFlamegraph/utils.ts @@ -16,7 +16,7 @@ import { MIN_WIDTH_FOR_NAME_AND_DURATION, SPAN_BAR_HEIGHT_RATIO, } from './constants'; -import { SpanRect } from './types'; +import { EventRect, SpanRect } from './types'; export function clamp(v: number, min: number, max: number): number { return Math.max(min, Math.min(max, v)); @@ -111,29 +111,80 @@ export function getSpanColor(args: GetSpanColorArgs): string { return color; } +export interface EventDotColor { + fill: string; + stroke: string; +} + +/** Derive event dot colors from parent span color. Error events always use red. */ +export function getEventDotColor( + spanColor: string, + isError: boolean, + isDarkMode: boolean, +): EventDotColor { + if (isError) { + return { + fill: isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)', + stroke: isDarkMode ? 'rgb(185, 28, 28)' : 'rgb(153, 27, 27)', + }; + } + + // Parse the span color (hex or rgb) to darken it for the event dot + let r: number | undefined; + let g: number | undefined; + let b: number | undefined; + + const rgbMatch = spanColor.match( + /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+)?\s*\)/, + ); + const hexMatch = spanColor.match( + /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i, + ); + + if (rgbMatch) { + r = parseInt(rgbMatch[1], 10); + g = parseInt(rgbMatch[2], 10); + b = parseInt(rgbMatch[3], 10); + } else if (hexMatch) { + r = parseInt(hexMatch[1], 16); + g = parseInt(hexMatch[2], 16); + b = parseInt(hexMatch[3], 16); + } + + if (r !== undefined && g !== undefined && b !== undefined) { + // Darken by 20% for fill, 40% for stroke + const darken = (v: number, factor: number): number => + Math.round(v * (1 - factor)); + return { + fill: `rgb(${darken(r, 0.2)}, ${darken(g, 0.2)}, ${darken(b, 0.2)})`, + stroke: `rgb(${darken(r, 0.4)}, ${darken(g, 0.4)}, ${darken(b, 0.4)})`, + }; + } + + // Fallback to original cyan/blue + return { + fill: isDarkMode ? 'rgb(14, 165, 233)' : 'rgb(6, 182, 212)', + stroke: isDarkMode ? 'rgb(2, 132, 199)' : 'rgb(8, 145, 178)', + }; +} + interface DrawEventDotArgs { ctx: CanvasRenderingContext2D; x: number; y: number; - isError: boolean; - isDarkMode: boolean; + color: EventDotColor; eventDotSize: number; } export function drawEventDot(args: DrawEventDotArgs): void { - const { ctx, x, y, isError, isDarkMode, eventDotSize } = args; + const { ctx, x, y, color, eventDotSize } = args; ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); - if (isError) { - ctx.fillStyle = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)'; - ctx.strokeStyle = isDarkMode ? 'rgb(185, 28, 28)' : 'rgb(153, 27, 27)'; - } else { - ctx.fillStyle = isDarkMode ? 'rgb(14, 165, 233)' : 'rgb(6, 182, 212)'; - ctx.strokeStyle = isDarkMode ? 'rgb(2, 132, 199)' : 'rgb(8, 145, 178)'; - } + ctx.fillStyle = color.fill; + ctx.strokeStyle = color.stroke; ctx.lineWidth = 1; const half = eventDotSize / 2; @@ -150,11 +201,13 @@ interface DrawSpanBarArgs { width: number; levelIndex: number; spanRectsArray: SpanRect[]; + eventRectsArray: EventRect[]; color: string; isDarkMode: boolean; metrics: FlamegraphRowMetrics; selectedSpanId?: string | null; hoveredSpanId?: string | null; + hoveredEventKey?: string | null; } export function drawSpanBar(args: DrawSpanBarArgs): void { @@ -166,11 +219,13 @@ export function drawSpanBar(args: DrawSpanBarArgs): void { width, levelIndex, spanRectsArray, + eventRectsArray, color, isDarkMode, metrics, selectedSpanId, hoveredSpanId, + hoveredEventKey, } = args; const spanY = y + metrics.SPAN_BAR_Y_OFFSET; @@ -224,13 +279,27 @@ export function drawSpanBar(args: DrawSpanBarArgs): void { const eventX = x + (clampedOffset / 100) * width; const eventY = spanY + metrics.SPAN_BAR_HEIGHT / 2; + const dotColor = getEventDotColor(color, event.isError, isDarkMode); + const eventKey = `${span.spanId}-${event.name}-${event.timeUnixNano}`; + const isEventHovered = hoveredEventKey === eventKey; + const dotSize = isEventHovered + ? Math.round(metrics.EVENT_DOT_SIZE * 1.5) + : metrics.EVENT_DOT_SIZE; + drawEventDot({ ctx, x: eventX, y: eventY, - isError: event.isError, - isDarkMode, - eventDotSize: metrics.EVENT_DOT_SIZE, + color: dotColor, + eventDotSize: dotSize, + }); + + eventRectsArray.push({ + event, + span, + cx: eventX, + cy: eventY, + halfSize: metrics.EVENT_DOT_SIZE / 2, }); }); diff --git a/frontend/src/pages/TraceDetailsV3/TraceWaterfall/TraceWaterfallStates/Success/Success.styles.scss b/frontend/src/pages/TraceDetailsV3/TraceWaterfall/TraceWaterfallStates/Success/Success.styles.scss index 0674899877..f0e8f54199 100644 --- a/frontend/src/pages/TraceDetailsV3/TraceWaterfall/TraceWaterfallStates/Success/Success.styles.scss +++ b/frontend/src/pages/TraceDetailsV3/TraceWaterfall/TraceWaterfallStates/Success/Success.styles.scss @@ -361,8 +361,8 @@ transform: translate(-50%, -50%) rotate(45deg); width: 5px; height: 5px; - background-color: var(--bg-robin-500); - border: 1px solid var(--bg-robin-600); + background-color: var(--event-dot-bg, var(--bg-robin-500)); + border: 1px solid var(--event-dot-border, var(--bg-robin-600)); cursor: pointer; z-index: 1; diff --git a/frontend/src/pages/TraceDetailsV3/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx b/frontend/src/pages/TraceDetailsV3/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx index a651d45fb1..042e968861 100644 --- a/frontend/src/pages/TraceDetailsV3/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx +++ b/frontend/src/pages/TraceDetailsV3/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx @@ -14,7 +14,7 @@ import { useReactTable, } from '@tanstack/react-table'; import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual'; -import { Button, Tooltip, Typography } from 'antd'; +import { Button, Popover, Tooltip, Typography } from 'antd'; import cx from 'classnames'; import TimelineV3 from 'components/TimelineV3/TimelineV3'; import { themeColors } from 'constants/theme'; @@ -34,6 +34,7 @@ import { import { Span } from 'types/api/trace/getTraceV2'; import { toFixed } from 'utils/toFixed'; +import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent'; import SpanHoverCard from '../../../SpanHoverCard/SpanHoverCard'; import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal'; import { IInterestedSpan } from '../../TraceWaterfall'; @@ -235,16 +236,16 @@ export function SpanDuration({ const isSelectedNonMatching = isSelected && isFilterActive && !isMatching; return ( - -
handleSpanClick(span)} - > +
handleSpanClick(span)} + > +
- {span.event?.map((event) => { - const eventTimeMs = event.timeUnixNano / 1e6; - const eventOffsetPercent = - ((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100; - const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99)); - const { isError } = event; - const { - time: evtTime, - timeUnitName: evtUnit, - } = convertTimeToRelevantUnit(eventTimeMs - span.timestamp); - return ( - -
- - ); - })}
-
-
+ + {span.event?.map((event) => { + const eventTimeMs = event.timeUnixNano / 1e6; + const spanDurationMs = span.durationNano / 1e6; + const eventOffsetPercent = + ((eventTimeMs - span.timestamp) / spanDurationMs) * 100; + const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99)); + const { isError } = event; + // Position relative to the span bar: leftOffset% + clampedOffset% of width% + const dotLeft = leftOffset + (clampedOffset / 100) * width; + const parts = rgbColor.split(', '); + const dotBg = `rgb(${parts + .map((c) => Math.round(Number(c) * 0.7)) + .join(', ')})`; + const dotBorder = `rgb(${parts + .map((c) => Math.round(Number(c) * 0.5)) + .join(', ')})`; + return ( + + } + trigger="hover" + rootClassName="span-hover-card-popover" + autoAdjustOverflow + arrow={false} + > +
+ + ); + })} +
); }