feat: event dots in trace details

This commit is contained in:
aks07
2026-03-18 18:54:19 +05:30
parent a06046612a
commit a1bf0e67db
11 changed files with 404 additions and 88 deletions

View File

@@ -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;
}
}

View File

@@ -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<string, string>;
}
export function EventTooltipContent({
eventName,
timeOffsetMs,
isError,
attributeMap,
}: EventTooltipContentProps): JSX.Element {
const { time, timeUnitName } = convertTimeToRelevantUnit(timeOffsetMs);
return (
<div className="event-tooltip-content">
<div className="event-tooltip-content__header">
<Diamond size={10} />
<span>EVENT DETAILS</span>
</div>
<div className={`event-tooltip-content__name ${isError ? 'error' : ''}`}>
{eventName}
</div>
<div className="event-tooltip-content__time">
{toFixed(time, 2)} {timeUnitName} from start
</div>
{Object.keys(attributeMap).length > 0 && (
<>
<div className="event-tooltip-content__divider" />
<div className="event-tooltip-content__attributes">
{Object.entries(attributeMap).map(([key, value]) => (
<div key={key} className="event-tooltip-content__kv">
<span className="event-tooltip-content__key">{key}:</span>{' '}
<span className="event-tooltip-content__value">{value}</span>
</div>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -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<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const spanRectsRef = useRef<SpanRect[]>([]);
const eventRectsRef = useRef<EventRect[]>([]);
const [viewStartTs, setViewStartTs] = useState<number>(
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 */}
<SpanTooltipContent
spanName={tooltipContent.spanName}
color={tooltipContent.spanColor}
hasError={tooltipContent.status === 'error'}
relativeStartMs={tooltipContent.startMs}
durationMs={tooltipContent.durationMs}
/>
{tooltipContent.event ? (
<EventTooltipContent
eventName={tooltipContent.event.name}
timeOffsetMs={tooltipContent.event.timeOffsetMs}
isError={tooltipContent.event.isError}
attributeMap={tooltipContent.event.attributeMap}
/>
) : (
<SpanTooltipContent
spanName={tooltipContent.spanName}
color={tooltipContent.spanColor}
hasError={tooltipContent.status === 'error'}
relativeStartMs={tooltipContent.startMs}
durationMs={tooltipContent.durationMs}
/>
)}
</div>,
document.body,
)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<SpanRect[]>;
eventRectsRef?: React.MutableRefObject<EventRect[]>;
hoveredEventKey?: string | null;
}
interface UseFlamegraphDrawResult {
drawFlamegraph: () => void;
spanRectsRef: RefObject<SpanRect[]>;
eventRectsRef: RefObject<EventRect[]>;
}
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<SpanRect[]>([]);
const spanRectsRef = spanRectsRefProp ?? spanRectsRefInternal;
const eventRectsRefInternal = useRef<EventRect[]>([]);
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 };
}

View File

@@ -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<string, string>;
}
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<HTMLCanvasElement>;
spanRectsRef: MutableRefObject<SpanRect[]>;
eventRectsRef: MutableRefObject<EventRect[]>;
traceMetadata: ITraceMetadata;
viewStartTs: number;
viewEndTs: number;
@@ -72,6 +96,7 @@ interface UseFlamegraphHoverArgs {
interface UseFlamegraphHoverResult {
hoveredSpanId: string | null;
setHoveredSpanId: Dispatch<SetStateAction<string | null>>;
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<string | null>(null);
const [hoveredEventKey, setHoveredEventKey] = useState<string | null>(null);
const [tooltipContent, setTooltipContent] = useState<TooltipContent | null>(
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,

View File

@@ -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;
}

View File

@@ -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,
});
});

View File

@@ -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;

View File

@@ -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 (
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className={cx('span-duration', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onClick={(): void => handleSpanClick(span)}
>
<div
className={cx('span-duration', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onClick={(): void => handleSpanClick(span)}
>
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className="span-bar"
style={
@@ -263,33 +264,54 @@ export function SpanDuration({
2,
)} ${timeUnitName}`}</span>
</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 (
<Tooltip
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
title={`${event.name} @ ${toFixed(evtTime, 2)} ${evtUnit}`}
>
<div
className={`event-dot ${isError ? 'error' : ''}`}
style={{
left: `${clampedOffset}%`,
}}
/>
</Tooltip>
);
})}
</div>
</div>
</SpanHoverCard>
</SpanHoverCard>
{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 (
<Popover
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
content={
<EventTooltipContent
eventName={event.name}
timeOffsetMs={eventTimeMs - span.timestamp}
isError={isError}
attributeMap={event.attributeMap || {}}
/>
}
trigger="hover"
rootClassName="span-hover-card-popover"
autoAdjustOverflow
arrow={false}
>
<div
className={`event-dot ${isError ? 'error' : ''}`}
style={
{
left: `${dotLeft}%`,
'--event-dot-bg': isError ? undefined : dotBg,
'--event-dot-border': isError ? undefined : dotBorder,
} as React.CSSProperties
}
/>
</Popover>
);
})}
</div>
);
}