Compare commits

..

6 Commits

Author SHA1 Message Date
aks07
a06046612a feat: connector line ux 2026-03-17 18:45:11 +05:30
aks07
31c9d4309b feat: move to service worker 2026-03-17 17:06:05 +05:30
aks07
7bef8b86c4 feat: subtree segregated tree 2026-03-17 14:39:57 +05:30
aks07
d26acd36a3 feat: subtree segregated tree 2026-03-17 13:55:58 +05:30
aks07
1cee595135 feat: subtree segregated tree 2026-03-17 13:29:07 +05:30
aks07
dd1868fcbc feat: row based flamegraph 2026-03-16 23:27:31 +05:30
11 changed files with 1074 additions and 6 deletions

View File

@@ -16,7 +16,7 @@ const useGetTraceFlamegraph = (
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
props.traceId,
props.selectedSpanId,
// props.selectedSpanId,
],
enabled: !!props.traceId,
keepPreviousData: true,

View File

@@ -11,6 +11,7 @@ import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
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';
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
@@ -58,7 +59,9 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
viewEndRef.current = traceMetadata.endTime;
}, [traceMetadata.startTime, traceMetadata.endTime]);
const totalHeight = spans.length * rowHeight;
const { layout, isComputing: _isComputing } = useVisualLayoutWorker(spans);
const totalHeight = layout.totalVisualRows * rowHeight;
const { isOverFlamegraphRef } = useFlamegraphZoom({
canvasRef,
@@ -112,7 +115,8 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
const { drawFlamegraph } = useFlamegraphDraw({
canvasRef,
containerRef,
spans,
spans: layout.visualRows,
connectors: layout.connectors,
viewStartTs,
viewEndTs,
scrollTop,
@@ -125,7 +129,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
useScrollToSpan({
firstSpanAtFetchLevel,
spans,
spans: layout.visualRows,
traceMetadata,
containerRef,
viewStartRef,

View File

@@ -43,7 +43,7 @@ function TraceFlamegraph(): JSX.Element {
const { data, isFetching, error } = useGetTraceFlamegraph({
traceId,
selectedSpanId: firstSpanAtFetchLevel,
// selectedSpanId: firstSpanAtFetchLevel,
});
const flamegraphState = useMemo(() => {

View File

@@ -0,0 +1,475 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { computeVisualLayout } from '../computeVisualLayout';
function makeSpan(
overrides: Partial<FlamegraphSpan> & {
spanId: string;
timestamp: number;
durationNano: number;
},
): FlamegraphSpan {
return {
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'svc',
name: 'op',
level: 0,
event: [],
...overrides,
};
}
describe('computeVisualLayout', () => {
it('should handle empty input', () => {
const layout = computeVisualLayout([]);
expect(layout.totalVisualRows).toBe(0);
expect(layout.visualRows).toEqual([]);
});
it('should handle single root, no children — 1 visual row', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 100e6,
});
const layout = computeVisualLayout([[root]]);
expect(layout.totalVisualRows).toBe(1);
expect(layout.visualRows[0]).toEqual([root]);
expect(layout.spanToVisualRow['root']).toBe(0);
});
it('should keep non-overlapping siblings on the same row (compact)', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 500e6,
});
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 100e6,
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 200,
durationNano: 100e6,
});
const c = makeSpan({
spanId: 'c',
parentSpanId: 'root',
timestamp: 400,
durationNano: 100e6,
});
const layout = computeVisualLayout([[root], [a, b, c]]);
// root on row 0, all children on row 1
expect(layout.totalVisualRows).toBe(2);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['a']).toBe(1);
expect(layout.spanToVisualRow['b']).toBe(1);
expect(layout.spanToVisualRow['c']).toBe(1);
});
it('should pack non-overlapping siblings into shared lanes (greedy packing)', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 300e6,
});
// A and B overlap; C does not overlap with either
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 100e6, // ends at 100ms
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 50,
durationNano: 100e6, // starts at 50ms < 100ms end of A → overlap → lane 1
});
const c = makeSpan({
spanId: 'c',
parentSpanId: 'root',
timestamp: 200,
durationNano: 100e6, // 200 >= 100, fits lane 0 with A
});
const layout = computeVisualLayout([[root], [a, b, c]]);
// root on row 0, C placed first (latest) → row 1, B doesn't overlap C → row 1, A overlaps B → row 2
expect(layout.totalVisualRows).toBe(3);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['c']).toBe(1);
expect(layout.spanToVisualRow['b']).toBe(1);
expect(layout.spanToVisualRow['a']).toBe(2);
});
it('should handle full overlap — all siblings get own row', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const layout = computeVisualLayout([[root], [a, b]]);
expect(layout.totalVisualRows).toBe(3);
expect(layout.spanToVisualRow['a']).toBe(1);
expect(layout.spanToVisualRow['b']).toBe(2);
});
it('should stack children correctly below overlapping parents', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 300e6,
});
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 50,
durationNano: 200e6,
});
// Child of A
const childA = makeSpan({
spanId: 'childA',
parentSpanId: 'a',
timestamp: 10,
durationNano: 50e6,
});
// Child of B
const childB = makeSpan({
spanId: 'childB',
parentSpanId: 'b',
timestamp: 60,
durationNano: 50e6,
});
const layout = computeVisualLayout([[root], [a, b], [childA, childB]]);
// DFS processes b's subtree first (latest):
// root → row 0
// b → row 1 (parentRow 0 + 1)
// childB → row 2 (parentRow 1 + 1)
// a → try row 1 (parentRow 0 + 1), overlaps b → try row 2, overlaps childB → row 3
// childA → row 4 (parentRow 3 + 1)
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['b']).toBe(1);
expect(layout.spanToVisualRow['childB']).toBe(2);
expect(layout.spanToVisualRow['a']).toBe(3);
expect(layout.spanToVisualRow['childA']).toBe(4);
expect(layout.totalVisualRows).toBe(5);
});
it('should handle multiple roots as a sibling group', () => {
// Two overlapping roots
const r1 = makeSpan({
spanId: 'r1',
timestamp: 0,
durationNano: 100e6,
});
const r2 = makeSpan({
spanId: 'r2',
timestamp: 50,
durationNano: 100e6,
});
const layout = computeVisualLayout([[r1, r2]]);
expect(layout.spanToVisualRow['r1']).toBe(0);
expect(layout.spanToVisualRow['r2']).toBe(1);
expect(layout.totalVisualRows).toBe(2);
});
it('should produce compact layout for deep nesting without overlap', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 1000e6,
});
const child = makeSpan({
spanId: 'child',
parentSpanId: 'root',
timestamp: 10,
durationNano: 500e6,
});
const grandchild = makeSpan({
spanId: 'grandchild',
parentSpanId: 'child',
timestamp: 20,
durationNano: 200e6,
});
const layout = computeVisualLayout([[root], [child], [grandchild]]);
// No overlap at any level → visual rows == tree depth
expect(layout.totalVisualRows).toBe(3);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['child']).toBe(1);
expect(layout.spanToVisualRow['grandchild']).toBe(2);
});
it('should pack many sequential siblings into 1 row (no diagonal staircase)', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 500e6,
});
// 6 sequential children — like checkoutservice/PlaceOrder scenario
const spans = [
makeSpan({
spanId: 's1',
parentSpanId: 'root',
timestamp: 3,
durationNano: 30e6,
}),
makeSpan({
spanId: 's2',
parentSpanId: 'root',
timestamp: 35,
durationNano: 4e6,
}),
makeSpan({
spanId: 's3',
parentSpanId: 'root',
timestamp: 39,
durationNano: 1e6,
}),
makeSpan({
spanId: 's4',
parentSpanId: 'root',
timestamp: 40,
durationNano: 4e6,
}),
makeSpan({
spanId: 's5',
parentSpanId: 'root',
timestamp: 44,
durationNano: 5e6,
}),
makeSpan({
spanId: 's6',
parentSpanId: 'root',
timestamp: 49,
durationNano: 1e6,
}),
];
const layout = computeVisualLayout([[root], spans]);
// All 6 sequential siblings should share 1 row
expect(layout.totalVisualRows).toBe(2);
expect(layout.spanToVisualRow['root']).toBe(0);
for (const span of spans) {
expect(layout.spanToVisualRow[span.spanId]).toBe(1);
}
});
it('should keep children below parents even with misparented spans', () => {
// Simulates the dd_sig2 bug: /route spans have parentSpanId pointing
// to the wrong ancestor, but they are at level 2 in the spans[][] input.
// Level-based packing must place them below level 1 regardless.
const httpGet = makeSpan({
spanId: 'http-get',
timestamp: 0,
durationNano: 500e6,
});
const route = makeSpan({
spanId: 'route',
parentSpanId: 'some-wrong-ancestor', // misparented!
timestamp: 10,
durationNano: 200e6,
});
const layout = computeVisualLayout([[httpGet], [route]]);
// httpGet at level 0 → row 0, route at level 1 → row 1
expect(layout.spanToVisualRow['http-get']).toBe(0);
expect(layout.spanToVisualRow['route']).toBe(1);
expect(layout.totalVisualRows).toBe(2);
});
it('should keep parent-child pairs adjacent when sibling subtrees overlap', () => {
// Multiple overlapping parents each with a child — the subtree-unit
// guarantee means every parent→child gap should be exactly 1.
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 500e6,
});
// Three overlapping HTTP GET children of root, each with its own /route child
const get1 = makeSpan({
spanId: 'get1',
parentSpanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const route1 = makeSpan({
spanId: 'route1',
parentSpanId: 'get1',
timestamp: 10,
durationNano: 180e6,
});
const get2 = makeSpan({
spanId: 'get2',
parentSpanId: 'root',
timestamp: 50,
durationNano: 200e6,
});
const route2 = makeSpan({
spanId: 'route2',
parentSpanId: 'get2',
timestamp: 60,
durationNano: 180e6,
});
const get3 = makeSpan({
spanId: 'get3',
parentSpanId: 'root',
timestamp: 100,
durationNano: 200e6,
});
const route3 = makeSpan({
spanId: 'route3',
parentSpanId: 'get3',
timestamp: 110,
durationNano: 180e6,
});
const layout = computeVisualLayout([
[root],
[get1, get2, get3],
[route1, route2, route3],
]);
// Each parent-child pair should have a gap of exactly 1
const get1Row = layout.spanToVisualRow['get1'];
const route1Row = layout.spanToVisualRow['route1'];
const get2Row = layout.spanToVisualRow['get2'];
const route2Row = layout.spanToVisualRow['route2'];
const get3Row = layout.spanToVisualRow['get3'];
const route3Row = layout.spanToVisualRow['route3'];
expect(route1Row - get1Row).toBe(1);
expect(route2Row - get2Row).toBe(1);
expect(route3Row - get3Row).toBe(1);
});
it('should handle mixed levels — overlap at level 2 but not level 1', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 1000e6,
});
// Non-overlapping children
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 400e6,
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 500,
durationNano: 400e6,
});
// Overlapping grandchildren under A
const ga1 = makeSpan({
spanId: 'ga1',
parentSpanId: 'a',
timestamp: 0,
durationNano: 200e6,
});
const ga2 = makeSpan({
spanId: 'ga2',
parentSpanId: 'a',
timestamp: 100,
durationNano: 200e6,
});
const layout = computeVisualLayout([[root], [a, b], [ga1, ga2]]);
// root → row 0
// a, b → row 1 (no overlap, share row)
// ga1 → row 2, ga2 → row 3 (overlap, expanded)
// b has no children, so nothing after ga2
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['a']).toBe(1);
expect(layout.spanToVisualRow['b']).toBe(1);
expect(layout.spanToVisualRow['ga2']).toBe(2);
expect(layout.spanToVisualRow['ga1']).toBe(3);
expect(layout.totalVisualRows).toBe(4);
});
it('should not place a span where it covers an existing connector point (Check 2)', () => {
// Scenario: root has 3 leaf children. Sorted latest-first: C(200), B(100), A(80).
//
// C placed at row 1 [200, 400].
// B overlaps C → placed at row 2 [100, 300]. Connector from row 0→2 at x=100
// passes through row 1, recording connector point at (row 1, x=100).
// A [80, 110] does NOT overlap C's span [200, 400] at row 1 (110 < 200),
// so without connector reservation A would fit at row 1.
// But A's span [80, 110) contains the connector point x=100 at row 1.
// Check 2 prevents this placement, pushing A further down.
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 500e6,
});
const c = makeSpan({
spanId: 'c',
parentSpanId: 'root',
timestamp: 200,
durationNano: 200e6, // [200, 400]
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 100,
durationNano: 200e6, // [100, 300]
});
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 80,
durationNano: 30e6, // [80, 110]
});
const layout = computeVisualLayout([[root], [a, b, c]]);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['c']).toBe(1); // latest, placed first
expect(layout.spanToVisualRow['b']).toBe(2); // overlaps C → row 2
// A would fit at row 1 by span overlap alone, but connector point at
// (row 1, x=100) falls within A's span [80, 110). Check 2 pushes A down.
const aRow = layout.spanToVisualRow['a']!;
expect(aRow).toBeGreaterThan(1); // must NOT be at row 1
expect(aRow).toBe(3); // next free row after B at row 2 (A overlaps B)
});
});

View File

@@ -0,0 +1,370 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
export interface ConnectorLine {
parentRow: number;
childRow: number;
timestampMs: number;
serviceName: string;
}
export interface VisualLayout {
visualRows: FlamegraphSpan[][];
spanToVisualRow: Record<string, number>;
connectors: ConnectorLine[];
totalVisualRows: number;
}
/**
* Computes an overlap-safe visual layout for flamegraph spans using DFS ordering.
*
* Builds a parent→children tree from parentSpanId, then traverses in DFS pre-order.
* Each span is placed at parentRow+1 if free, otherwise scans upward row-by-row
* until finding a non-overlapping row. This keeps children visually close to their
* parents and avoids the BFS problem where distant siblings push children far down.
*/
export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
const spanToVisualRow = new Map<string, number>();
const visualRowsMap = new Map<number, FlamegraphSpan[]>();
let maxRow = -1;
// Per-row interval list for overlap detection
// Each entry: [startTime, endTime]
const rowIntervals = new Map<number, Array<[number, number]>>();
// function hasOverlap(row: number, startTime: number, endTime: number): boolean {
// const intervals = rowIntervals.get(row);
// if (!intervals) {
// return false;
// }
// for (const [s, e] of intervals) {
// if (startTime < e && endTime > s) {
// return true;
// }
// }
// return false;
// }
function addToRow(row: number, span: FlamegraphSpan): void {
spanToVisualRow.set(span.spanId, row);
let arr = visualRowsMap.get(row);
if (!arr) {
arr = [];
visualRowsMap.set(row, arr);
}
arr.push(span);
const startTime = span.timestamp;
const endTime = span.timestamp + span.durationNano / 1e6;
let intervals = rowIntervals.get(row);
if (!intervals) {
intervals = [];
rowIntervals.set(row, intervals);
}
intervals.push([startTime, endTime]);
if (row > maxRow) {
maxRow = row;
}
}
// Flatten all spans and build lookup + children map
const spanMap = new Map<string, FlamegraphSpan>();
const childrenMap = new Map<string, FlamegraphSpan[]>();
const allSpans: FlamegraphSpan[] = [];
for (const level of spans) {
for (const span of level) {
allSpans.push(span);
spanMap.set(span.spanId, span);
}
}
// Extract parentSpanId — the field may be missing at runtime when the API
// returns `references` instead. Fall back to the first CHILD_OF reference.
function getParentId(span: FlamegraphSpan): string {
if (span.parentSpanId) {
return span.parentSpanId;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const refs = (span as any).references as
| Array<{ spanId?: string; refType?: string }>
| undefined;
if (refs) {
for (const ref of refs) {
if (ref.refType === 'CHILD_OF' && ref.spanId) {
return ref.spanId;
}
}
}
return '';
}
// Build children map and identify roots
const roots: FlamegraphSpan[] = [];
for (const span of allSpans) {
const parentId = getParentId(span);
if (!parentId || !spanMap.has(parentId)) {
roots.push(span);
} else {
let children = childrenMap.get(parentId);
if (!children) {
children = [];
childrenMap.set(parentId, children);
}
children.push(span);
}
}
// Sort children by timestamp for deterministic ordering
for (const [, children] of childrenMap) {
children.sort((a, b) => b.timestamp - a.timestamp);
}
// --- Subtree-unit placement ---
// Compute each subtree's layout in isolation, then place as a unit
// to guarantee parent-child adjacency within subtrees.
interface ShapeEntry {
span: FlamegraphSpan;
relativeRow: number;
}
function hasOverlapIn(
intervals: Map<number, Array<[number, number]>>,
row: number,
startTime: number,
endTime: number,
): boolean {
const rowIntervals = intervals.get(row);
if (!rowIntervals) {
return false;
}
for (const [s, e] of rowIntervals) {
if (startTime < e && endTime > s) {
return true;
}
}
return false;
}
function addIntervalTo(
intervals: Map<number, Array<[number, number]>>,
row: number,
startTime: number,
endTime: number,
): void {
let arr = intervals.get(row);
if (!arr) {
arr = [];
intervals.set(row, arr);
}
arr.push([startTime, endTime]);
}
function hasConnectorConflict(
intervals: Map<number, Array<[number, number]>>,
row: number,
point: number,
): boolean {
const rowIntervals = intervals.get(row);
if (!rowIntervals) {
return false;
}
for (const [s, e] of rowIntervals) {
if (point >= s && point < e) {
return true;
}
}
return false;
}
function hasPointInSpan(
connectorPoints: Map<number, number[]>,
row: number,
startTime: number,
endTime: number,
): boolean {
const points = connectorPoints.get(row);
if (!points) {
return false;
}
for (const p of points) {
if (p >= startTime && p < endTime) {
return true;
}
}
return false;
}
function addConnectorPoint(
connectorPoints: Map<number, number[]>,
row: number,
point: number,
): void {
let arr = connectorPoints.get(row);
if (!arr) {
arr = [];
connectorPoints.set(row, arr);
}
arr.push(point);
}
function computeSubtreeShape(rootSpan: FlamegraphSpan): ShapeEntry[] {
const localIntervals = new Map<number, Array<[number, number]>>();
const localConnectorPoints = new Map<number, number[]>();
const shape: ShapeEntry[] = [];
// Place root span at relative row 0
const rootStart = rootSpan.timestamp;
const rootEnd = rootSpan.timestamp + rootSpan.durationNano / 1e6;
shape.push({ span: rootSpan, relativeRow: 0 });
addIntervalTo(localIntervals, 0, rootStart, rootEnd);
const children = childrenMap.get(rootSpan.spanId);
if (children) {
for (const child of children) {
const childShape = computeSubtreeShape(child);
const connectorX = child.timestamp;
const offset = findPlacement(
childShape,
1,
localIntervals,
localConnectorPoints,
connectorX,
);
// Record connector points for intermediate rows (1 to offset-1)
for (let r = 1; r < offset; r++) {
addConnectorPoint(localConnectorPoints, r, connectorX);
}
// Place child shape into local state at offset
for (const entry of childShape) {
const actualRow = entry.relativeRow + offset;
shape.push({ span: entry.span, relativeRow: actualRow });
const s = entry.span.timestamp;
const e = entry.span.timestamp + entry.span.durationNano / 1e6;
addIntervalTo(localIntervals, actualRow, s, e);
}
}
}
return shape;
}
function findPlacement(
shape: ShapeEntry[],
minOffset: number,
intervals: Map<number, Array<[number, number]>>,
connectorPoints?: Map<number, number[]>,
connectorX?: number,
): number {
// Track the first offset that passes Checks 1 & 2 as a fallback.
// Check 3 (connector vs span) is monotonically failing: once it fails
// at offset K, all offsets > K also fail (more intermediate rows).
// If we can't satisfy Check 3, fall back to the best offset without it.
let fallbackOffset = -1;
for (let offset = minOffset; ; offset++) {
let passesSpanChecks = true;
// Check 1: span vs span (existing)
for (const entry of shape) {
const targetRow = entry.relativeRow + offset;
const s = entry.span.timestamp;
const e = entry.span.timestamp + entry.span.durationNano / 1e6;
if (hasOverlapIn(intervals, targetRow, s, e)) {
passesSpanChecks = false;
break;
}
}
// Check 2: span vs existing connector points
if (passesSpanChecks && connectorPoints) {
for (const entry of shape) {
const targetRow = entry.relativeRow + offset;
const s = entry.span.timestamp;
const e = entry.span.timestamp + entry.span.durationNano / 1e6;
if (hasPointInSpan(connectorPoints, targetRow, s, e)) {
passesSpanChecks = false;
break;
}
}
}
if (!passesSpanChecks) {
continue;
}
// This offset passes Checks 1 & 2 — record as fallback
if (fallbackOffset === -1) {
fallbackOffset = offset;
}
// Check 3: new connector vs existing spans
if (connectorX !== undefined) {
let connectorClear = true;
for (let r = 1; r < offset; r++) {
if (hasConnectorConflict(intervals, r, connectorX)) {
connectorClear = false;
break;
}
}
if (!connectorClear) {
// Check 3 will fail for all larger offsets too.
// Fall back to the first offset that passed Checks 1 & 2.
return fallbackOffset;
}
}
return offset;
}
}
// Process roots sorted by timestamp
roots.sort((a, b) => a.timestamp - b.timestamp);
for (const root of roots) {
const shape = computeSubtreeShape(root);
const offset = findPlacement(shape, 0, rowIntervals);
for (const entry of shape) {
addToRow(entry.relativeRow + offset, entry.span);
}
}
// Build the visualRows array
const totalVisualRows = maxRow + 1;
const visualRows: FlamegraphSpan[][] = [];
for (let i = 0; i < totalVisualRows; i++) {
visualRows.push(visualRowsMap.get(i) || []);
}
// Build connector lines for parent-child pairs with row gap > 1
const connectors: ConnectorLine[] = [];
for (const [parentId, children] of childrenMap) {
const parentRow = spanToVisualRow.get(parentId);
if (parentRow === undefined) {
continue;
}
for (const child of children) {
const childRow = spanToVisualRow.get(child.spanId);
if (childRow === undefined || childRow - parentRow <= 1) {
continue;
}
connectors.push({
parentRow,
childRow,
timestampMs: child.timestamp,
serviceName: child.serviceName,
});
}
}
return {
visualRows,
spanToVisualRow: Object.fromEntries(spanToVisualRow),
connectors,
totalVisualRows,
};
}

View File

@@ -1,6 +1,9 @@
import React, { RefObject, useCallback, useRef } from 'react';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { ConnectorLine } from '../computeVisualLayout';
import { SpanRect } from '../types';
import {
clamp,
@@ -14,6 +17,7 @@ interface UseFlamegraphDrawArgs {
canvasRef: RefObject<HTMLCanvasElement>;
containerRef: RefObject<HTMLDivElement>;
spans: FlamegraphSpan[][];
connectors: ConnectorLine[];
viewStartTs: number;
viewEndTs: number;
scrollTop: number;
@@ -114,6 +118,68 @@ function drawLevel(args: DrawLevelArgs): void {
}
}
interface DrawConnectorLinesArgs {
ctx: CanvasRenderingContext2D;
connectors: ConnectorLine[];
scrollTop: number;
viewStartTs: number;
timeSpan: number;
cssWidth: number;
viewportHeight: number;
metrics: FlamegraphRowMetrics;
}
function drawConnectorLines(args: DrawConnectorLinesArgs): void {
const {
ctx,
connectors,
scrollTop,
viewStartTs,
timeSpan,
cssWidth,
viewportHeight,
metrics,
} = args;
ctx.save();
ctx.lineWidth = 1;
ctx.globalAlpha = 0.6;
for (const conn of connectors) {
const xFrac = (conn.timestampMs - viewStartTs) / timeSpan;
if (xFrac < -0.01 || xFrac > 1.01) {
continue;
}
const parentY =
conn.parentRow * metrics.ROW_HEIGHT -
scrollTop +
metrics.SPAN_BAR_Y_OFFSET +
metrics.SPAN_BAR_HEIGHT;
const childY =
conn.childRow * metrics.ROW_HEIGHT - scrollTop + metrics.SPAN_BAR_Y_OFFSET;
// Skip if entirely outside viewport
if (parentY > viewportHeight || childY < 0) {
continue;
}
const color = generateColor(
conn.serviceName,
themeColors.traceDetailColorsV3,
);
ctx.strokeStyle = color;
const x = clamp(xFrac * cssWidth, 0, cssWidth);
ctx.beginPath();
ctx.moveTo(x, parentY);
ctx.lineTo(x, childY);
ctx.stroke();
}
ctx.restore();
}
export function useFlamegraphDraw(
args: UseFlamegraphDrawArgs,
): UseFlamegraphDrawResult {
@@ -121,6 +187,7 @@ export function useFlamegraphDraw(
canvasRef,
containerRef,
spans,
connectors,
viewStartTs,
viewEndTs,
scrollTop,
@@ -173,6 +240,18 @@ export function useFlamegraphDraw(
ctx.clearRect(0, 0, cssWidth, viewportHeight);
// ---- Draw connector lines (behind span bars) ----
drawConnectorLines({
ctx,
connectors,
scrollTop,
viewStartTs,
timeSpan,
cssWidth,
viewportHeight,
metrics,
});
const spanRectsArray: SpanRect[] = [];
// ---- Draw only visible levels ----
@@ -204,6 +283,7 @@ export function useFlamegraphDraw(
containerRef,
spanRectsRef,
spans,
connectors,
viewStartTs,
viewEndTs,
scrollTop,

View File

@@ -47,6 +47,7 @@ function findSpanAtPosition(
}
export interface TooltipContent {
serviceName: string;
spanName: string;
status: 'ok' | 'warning' | 'error';
startMs: number;
@@ -139,6 +140,7 @@ export function useFlamegraphHover(
if (span) {
setHoveredSpanId(span.spanId);
setTooltipContent({
serviceName: span.serviceName || '',
spanName: span.name || 'unknown',
status: span.hasError ? 'error' : 'ok',
startMs: span.timestamp - traceMetadata.startTime,

View File

@@ -0,0 +1,98 @@
import { useEffect, useRef, useState } from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { computeVisualLayout, VisualLayout } from '../computeVisualLayout';
import { LayoutWorkerResponse } from '../visualLayoutWorkerTypes';
const EMPTY_LAYOUT: VisualLayout = {
visualRows: [],
spanToVisualRow: {},
connectors: [],
totalVisualRows: 0,
};
function computeLayoutOrEmpty(spans: FlamegraphSpan[][]): VisualLayout {
return spans.length ? computeVisualLayout(spans) : EMPTY_LAYOUT;
}
function createLayoutWorker(): Worker {
return new Worker(new URL('../visualLayout.worker.ts', import.meta.url), {
type: 'module',
});
}
export function useVisualLayoutWorker(
spans: FlamegraphSpan[][],
): { layout: VisualLayout; isComputing: boolean } {
const [layout, setLayout] = useState<VisualLayout>(EMPTY_LAYOUT);
const [isComputing, setIsComputing] = useState(false);
const workerRef = useRef<Worker | null>(null);
const requestIdRef = useRef(0);
const fallbackRef = useRef(typeof Worker === 'undefined');
// Effect: post message to worker when spans change
useEffect(() => {
if (fallbackRef.current) {
setLayout(computeLayoutOrEmpty(spans));
return;
}
if (!workerRef.current) {
try {
workerRef.current = createLayoutWorker();
} catch {
fallbackRef.current = true;
setLayout(computeLayoutOrEmpty(spans));
return;
}
}
if (!spans.length) {
setLayout(EMPTY_LAYOUT);
return;
}
const currentId = ++requestIdRef.current;
setIsComputing(true);
const worker = workerRef.current;
const onMessage = (e: MessageEvent<LayoutWorkerResponse>): void => {
if (e.data.requestId !== requestIdRef.current) {
return;
}
if (e.data.type === 'result') {
setLayout(e.data.layout);
} else {
setLayout(computeVisualLayout(spans));
}
setIsComputing(false);
};
const onError = (): void => {
if (requestIdRef.current === currentId) {
setLayout(computeVisualLayout(spans));
setIsComputing(false);
}
};
worker.addEventListener('message', onMessage);
worker.addEventListener('error', onError);
worker.postMessage({ type: 'compute', requestId: currentId, spans });
return (): void => {
worker.removeEventListener('message', onMessage);
worker.removeEventListener('error', onError);
};
}, [spans]);
// Cleanup worker on unmount
useEffect(
() => (): void => {
workerRef.current?.terminate();
},
[],
);
return { layout, isComputing };
}

View File

@@ -0,0 +1,26 @@
/// <reference lib="webworker" />
import { computeVisualLayout } from './computeVisualLayout';
import {
LayoutWorkerRequest,
LayoutWorkerResponse,
} from './visualLayoutWorkerTypes';
self.onmessage = (event: MessageEvent<LayoutWorkerRequest>): void => {
const { requestId, spans } = event.data;
try {
const layout = computeVisualLayout(spans);
const response: LayoutWorkerResponse = {
type: 'result',
requestId,
layout,
};
self.postMessage(response);
} catch (err) {
const response: LayoutWorkerResponse = {
type: 'error',
requestId,
message: String(err),
};
self.postMessage(response);
}
};

View File

@@ -0,0 +1,13 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { VisualLayout } from './computeVisualLayout';
export interface LayoutWorkerRequest {
type: 'compute';
requestId: number;
spans: FlamegraphSpan[][];
}
export type LayoutWorkerResponse =
| { type: 'result'; requestId: number; layout: VisualLayout }
| { type: 'error'; requestId: number; message: string };

View File

@@ -4,7 +4,7 @@ export interface TraceDetailFlamegraphURLProps {
export interface GetTraceFlamegraphPayloadProps {
traceId: string;
selectedSpanId: string;
selectedSpanId?: string;
}
export interface Event {