1import { A } from "@solidjs/router";
2import ChevronRight from "lucide-solid/icons/chevron-right";
3import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from "solid-js";
4import type { Span, Trace } from "../types/model.ts";
5import { Copy } from "../components/Copy.tsx";
6import { percent } from "../format.ts";
7
8/** Milliseconds under a second, else seconds; `seconds` may be a span's length or an offset into a trace. */
9export const ms = (seconds: number) => {
10 const value = seconds * 1000;
11 if (value >= 1000) return `${seconds.toFixed(seconds < 10 ? 2 : 1)} s`;
12 if (value >= 10 || value === 0) return `${Math.round(value)} ms`;
13 return `${value.toFixed(value < 1 ? 2 : 1)} ms`;
14};
15
16const ROW = 20;
17const AXIS = 20;
18/** Narrower bars go unlabelled. */
19const LABEL = 28;
20/** How much of a service's color each shade mixes into the page. */
21const SHADES = [0.36, 0.48, 0.6, 0.72];
22
23interface Bar {
24 span: Span;
25 /** Seconds from the trace's start. */
26 from: number;
27 to: number;
28 /** Time not covered by any child. */
29 self: number;
30 row: number;
31 service: number;
32 /** Spans sharing a name's first word ("index", "tokenize") share a shade of their service's color. */
33 shade: number;
34 parent: Bar | undefined;
35 /** By start. */
36 children: Bar[];
37}
38
39/**
40 * Bars in rows below their parents. Siblings that overlap in time, like concurrent calls, take separate lanes, and
41 * each keeps its whole subtree as one block under it so no bar ever sits beside a stranger's children.
42 */
43function layout(trace: Trace) {
44 const t0 = trace.spans[0]!.start;
45 const byId = new Map<string, Bar>();
46 const services: string[] = [];
47 const bars = trace.spans.map((span) => {
48 const parent = span.parent === null ? undefined : byId.get(span.parent);
49 if (!services.includes(span.service)) services.push(span.service);
50 const from = span.start - t0;
51 const word = span.name.split(" ")[0]!;
52 let shade = 0;
53 for (const char of word) shade = (shade * 31 + char.charCodeAt(0)) % SHADES.length;
54 const bar: Bar = {
55 span, from, to: from + span.duration, self: span.duration, row: 0, service: services.indexOf(span.service), shade, parent,
56 children: [],
57 };
58 byId.set(span.id, bar);
59 parent?.children.push(bar);
60 return bar;
61 });
62 const stack = (group: Bar[]) => {
63 group.sort((a, b) => a.from - b.from);
64 const lanes: [number, number][][] = [];
65 let height = 0;
66 let end = 0;
67 for (const bar of group) {
68 const below = stack(bar.children);
69 const rows = 1 + below.height;
70 const until = Math.max(bar.to, below.end);
71 let lane = 0;
72 while (lanes.slice(lane, lane + rows).some((row) => row.some(([a, b]) => a < until && bar.from < b))) lane++;
73 for (let i = lane; i < lane + rows; i++) (lanes[i] ??= []).push([bar.from, until]);
74 bar.row = lane;
75 height = Math.max(height, lane + rows);
76 end = Math.max(end, until);
77 }
78 return { height, end };
79 };
80 const { height, end } = stack(bars.filter((bar) => !bar.parent));
81 const rows: Bar[][] = Array.from({ length: height }, () => []);
82 for (const bar of bars) {
83 if (bar.parent) bar.row += bar.parent.row + 1;
84 rows[bar.row]!.push(bar);
85 let reach = bar.from;
86 for (const child of bar.children) {
87 const to = Math.min(child.to, bar.to);
88 bar.self -= Math.max(0, to - Math.max(child.from, reach));
89 reach = Math.max(reach, to);
90 }
91 }
92 for (const row of rows) row.sort((a, b) => a.from - b.from);
93 return { bars, rows, end, services };
94}
95
96/** A step of 1, 2 or 5 times a power of ten, at least `min`. */
97function niceStep(min: number) {
98 const power = 10 ** Math.floor(Math.log10(min));
99 return [1, 2, 5, 10].map((k) => k * power).find((step) => step >= min)!;
100}
101
102const token = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
103/** `a` over `b` at `amount`, both `#rrggbb`. */
104function mix(a: string, b: string, amount: number) {
105 const channel = (hex: string, i: number) => parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16);
106 return `rgb(${[0, 1, 2].map((i) => Math.round(channel(a, i) * amount + channel(b, i) * (1 - amount))).join(" ")})`;
107}
108
109function theme() {
110 const page = token("--page");
111 const fills = (color: string) =>
112 ({ shades: SHADES.map((amount) => mix(color, page, amount)), hot: mix(color, page, 0.9), faint: mix(color, page, 0.2) });
113 return {
114 font: token("--sans"),
115 text: token("--text"),
116 muted: token("--muted"),
117 line: token("--line"),
118 series: Array.from({ length: 8 }, (_, i) => fills(token(`--series-${i + 1}`))),
119 failed: fills(token("--critical")),
120 };
121}
122
123/**
124 * A flame chart of one trace: time runs left to right and each span sits under its parent. Clicking a span zooms to
125 * it and shows its attributes; Esc zooms back out. Drag or scroll pans, pinch or ⌘/ctrl scroll zooms at the pointer.
126 * The arrow keys walk the tree. Spans holding all of `needles` stay bright.
127 */
128export function Flame(props: { trace: Trace; needles: string[]; job: string }) {
129 const job = (service: string) => service === props.trace.spans[0]?.service ? props.job : service;
130 let wrap!: HTMLDivElement;
131 let canvas!: HTMLCanvasElement;
132 const graph = createMemo(() => layout(props.trace));
133 const [focus, setFocus] = createSignal<Bar>();
134 const [hover, setHover] = createSignal<{ bar: Bar; x: number; y: number }>();
135 let view = { from: 0, to: 1 };
136 let scroll = 0;
137 let width = 0;
138 let height = 0;
139 let colors = theme();
140 const widths = new Map<string, number>();
141 let frame = 0;
142 let animation = 0;
143 const reduced = matchMedia("(prefers-reduced-motion: reduce)");
144 const mac = /Mac|iPhone|iPad/.test(navigator.platform);
145
146 const total = () => graph().end || 1e-6;
147 const scale = () => width / (view.to - view.from);
148 const maxScroll = () => Math.max(0, graph().rows.length * ROW - (height - AXIS));
149 const clampView = (from: number, to: number) => {
150 const span = Math.min(total(), Math.max(to - from, total() * 1e-5));
151 const start = Math.min(Math.max(0, from), total() - span);
152 return { from: start, to: start + span };
153 };
154 /** Bars matching the search, or null when there is none. */
155 const matching = createMemo(() => {
156 const needles = props.needles.map((needle) => needle.toLowerCase());
157 return needles.length ? new Set(graph().bars.filter(({ span }) => {
158 const values = [span.name, ...Object.values(span.attributes)].map((value) => String(value).toLowerCase());
159 return needles.every((needle) => values.some((value) => value.includes(needle)));
160 })) : null;
161 });
162
163 const measure = (text: string, ctx: CanvasRenderingContext2D) => {
164 let known = widths.get(text);
165 if (known === undefined) widths.set(text, (known = ctx.measureText(text).width));
166 return known;
167 };
168 const fit = (text: string, space: number, ctx: CanvasRenderingContext2D) => {
169 const full = measure(text, ctx);
170 if (full <= space) return text;
171 const chars = Math.floor((text.length * space) / full) - 1;
172 return chars < 2 ? "" : text.slice(0, chars) + "…";
173 };
174
175 const draw = () => {
176 frame = 0;
177 const ctx = canvas.getContext("2d");
178 if (!ctx || !width) return;
179 const dpr = devicePixelRatio;
180 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
181 ctx.clearRect(0, 0, width, height);
182 ctx.font = `11px ${colors.font}`;
183 ctx.textBaseline = "middle";
184 const k = scale();
185 const x = (t: number) => (t - view.from) * k;
186 const step = niceStep(90 / k);
187 const seconds = step >= 1;
188 const digits = Math.max(0, -Math.floor(Math.log10(seconds ? step : step * 1000) + 1e-9));
189 for (let t = Math.ceil(view.from / step) * step; t <= view.to; t += step) {
190 const at = Math.round(x(t));
191 ctx.fillStyle = colors.line;
192 ctx.fillRect(at, AXIS - 4, 1, height);
193 const label = seconds ? `${t.toFixed(digits)} s` : `${(t * 1000).toFixed(digits)} ms`;
194 if (at + 4 + measure(label, ctx) > width) continue;
195 ctx.fillStyle = colors.muted;
196 ctx.fillText(label, at + 4, AXIS / 2 - 1);
197 }
198
199 ctx.save();
200 ctx.beginPath();
201 ctx.rect(0, AXIS, width, height - AXIS);
202 ctx.clip();
203 const { rows } = graph();
204 const hovered = hover()?.bar;
205 const lit = matching();
206 const focused = focus();
207 const last = Math.min(rows.length, Math.ceil((scroll + height - AXIS) / ROW));
208 for (let r = Math.floor(scroll / ROW); r < last; r++) {
209 const y = AXIS + r * ROW - scroll;
210 for (const bar of rows[r]!) {
211 if (bar.to < view.from || bar.from > view.to) continue;
212 const left = Math.max(-1, x(bar.from));
213 const w = Math.max(1, Math.min(width + 1, x(bar.to)) - left);
214 const fills = bar.span.error ? colors.failed : colors.series[bar.service % 8]!;
215 const bright = !lit || lit.has(bar);
216 ctx.fillStyle = !bright ? fills.faint : bar === hovered ? fills.hot : fills.shades[bar.shade]!;
217 ctx.fillRect(left, y, w > 2 ? w - 1 : w, ROW - 1);
218 if (bar === focused) {
219 ctx.strokeStyle = colors.text;
220 ctx.lineWidth = 1.5;
221 ctx.strokeRect(left + 0.75, y + 0.75, Math.max(0, w - 2.5), ROW - 2.5);
222 }
223 if (w < LABEL) continue;
224 const text = Math.max(0, left) + 5;
225 const name = fit(bar.span.name, w - 10, ctx);
226 ctx.fillStyle = bright ? colors.text : colors.muted;
227 ctx.fillText(name, text, y + ROW / 2);
228 if (name !== bar.span.name) continue;
229 const time = ms(bar.span.duration);
230 const after = measure(name, ctx) + 6;
231 if (after + measure(time, ctx) + 10 > w) continue;
232 ctx.globalAlpha = 0.6;
233 ctx.fillText(time, text + after, y + ROW / 2);
234 ctx.globalAlpha = 1;
235 }
236 }
237 ctx.restore();
238 };
239 const redraw = () => (frame ||= requestAnimationFrame(draw));
240
241 /** Eases to a view and a scroll, width geometrically so deep zooms feel even. */
242 const animate = (target: { from: number; to: number }, targetScroll = scroll) => {
243 cancelAnimationFrame(animation);
244 const start = { ...view, scroll };
245 const began = performance.now();
246 const duration = reduced.matches ? 0 : 220;
247 const tick = (now: number) => {
248 const p = duration ? Math.min(1, (now - began) / duration) : 1;
249 const e = 1 - (1 - p) ** 3;
250 const [a, b] = [start.to - start.from, target.to - target.from];
251 const span = a * (b / a) ** e;
252 const center = (start.from + start.to) / 2 + (((target.from + target.to) / 2) - (start.from + start.to) / 2) * e;
253 view = p === 1 ? target : { from: center - span / 2, to: center + span / 2 };
254 scroll = start.scroll + (targetScroll - start.scroll) * e;
255 draw();
256 if (p < 1) animation = requestAnimationFrame(tick);
257 };
258 animation = requestAnimationFrame(tick);
259 };
260
261 const zoomTo = (bar: Bar) => {
262 setFocus(bar);
263 const pad = (bar.to - bar.from) * 0.03;
264 const top = bar.row * ROW;
265 const room = height - AXIS;
266 const targetScroll = top < scroll ? top : top + ROW > scroll + room ? top + ROW - room : scroll;
267 animate(clampView(bar.from - pad, bar.to + pad), Math.min(maxScroll(), Math.max(0, targetScroll)));
268 };
269
270 const zoomAt = (px: number, factor: number) => {
271 const t = view.from + px / scale();
272 const span = (view.to - view.from) * factor;
273 cancelAnimationFrame(animation);
274 view = clampView(t - (px / width) * span, t - (px / width) * span + span);
275 redraw();
276 };
277
278 const hit = (px: number, py: number) => {
279 if (py < AXIS) return undefined;
280 const row = graph().rows[Math.floor((py - AXIS + scroll) / ROW)] ?? [];
281 const t = view.from + px / scale();
282 const slop = 3 / scale();
283 let found: Bar | undefined;
284 for (const bar of row) {
285 if (bar.from - slop > t) break;
286 if (t <= bar.to + slop && (!found || (bar.from <= t && t <= bar.to))) found = bar;
287 }
288 return found;
289 };
290
291 let drag: { x: number; y: number; view: typeof view; scroll: number; moved: boolean } | undefined;
292 const point = (event: MouseEvent) => {
293 const box = canvas.getBoundingClientRect();
294 return [event.clientX - box.left, event.clientY - box.top] as const;
295 };
296 const onPointerDown = (event: PointerEvent) => {
297 if (event.button !== 0) return;
298 canvas.setPointerCapture(event.pointerId);
299 drag = { x: event.clientX, y: event.clientY, view, scroll, moved: false };
300 };
301 const onPointerMove = (event: PointerEvent) => {
302 const [px, py] = point(event);
303 if (drag) {
304 const dx = event.clientX - drag.x;
305 const dy = event.clientY - drag.y;
306 if (!drag.moved && Math.hypot(dx, dy) < 4) return;
307 if (!drag.moved) cancelAnimationFrame(animation);
308 drag.moved = true;
309 canvas.style.cursor = "grabbing";
310 setHover(undefined);
311 const shift = dx / scale();
312 view = clampView(drag.view.from - shift, drag.view.to - shift);
313 scroll = Math.min(maxScroll(), Math.max(0, drag.scroll - dy));
314 redraw();
315 return;
316 }
317 const bar = hit(px, py);
318 canvas.style.cursor = bar ? "pointer" : "";
319 setHover(bar && { bar, x: px, y: py });
320 };
321 const onPointerUp = (event: PointerEvent) => {
322 if (drag && !drag.moved) {
323 const bar = hit(...point(event));
324 if (bar) zoomTo(bar);
325 }
326 drag = undefined;
327 canvas.style.cursor = "";
328 };
329 const onWheel = (event: WheelEvent) => {
330 event.preventDefault();
331 const [px] = point(event);
332 const lines = event.deltaMode === 1 ? 16 : 1;
333 if (event.ctrlKey || event.metaKey) return zoomAt(px, Math.exp(event.deltaY * lines * 0.01));
334 const dx = (event.shiftKey ? event.deltaY : event.deltaX) * lines;
335 const dy = event.shiftKey ? 0 : event.deltaY * lines;
336 cancelAnimationFrame(animation);
337 view = clampView(view.from + dx / scale(), view.to + dx / scale());
338 scroll = Math.min(maxScroll(), Math.max(0, scroll + dy));
339 setHover(undefined);
340 redraw();
341 };
342 const onKeyDown = (event: KeyboardEvent) => {
343 const bar = focus();
344 if (!bar) return;
345 const siblings = bar.parent?.children ?? graph().bars.filter((other) => !other.parent);
346 const index = siblings.indexOf(bar);
347 const next = ({
348 Escape: bar.parent ?? graph().bars[0],
349 ArrowUp: bar.parent,
350 ArrowDown: bar.children[0],
351 ArrowLeft: siblings[index - 1],
352 ArrowRight: siblings[index + 1],
353 Home: graph().bars[0],
354 } as Record<string, Bar | undefined>)[event.key];
355 if (event.key === "+" || event.key === "=" || event.key === "-") {
356 event.preventDefault();
357 return zoomAt(width / 2, event.key === "-" ? 1.5 : 1 / 1.5);
358 }
359 if (!next) return;
360 event.preventDefault();
361 zoomTo(next);
362 };
363
364 onMount(() => {
365 const resize = new ResizeObserver(() => {
366 width = canvas.clientWidth;
367 height = canvas.clientHeight;
368 canvas.width = Math.round(width * devicePixelRatio);
369 canvas.height = Math.round(height * devicePixelRatio);
370 scroll = Math.min(scroll, maxScroll());
371 draw();
372 });
373 resize.observe(canvas);
374 const scheme = matchMedia("(prefers-color-scheme: dark)");
375 const retheme = () => {
376 colors = theme();
377 widths.clear();
378 redraw();
379 };
380 scheme.addEventListener("change", retheme);
381 document.fonts.addEventListener("loadingdone", retheme);
382 canvas.addEventListener("wheel", onWheel, { passive: false });
383 onCleanup(() => {
384 resize.disconnect();
385 scheme.removeEventListener("change", retheme);
386 document.fonts.removeEventListener("loadingdone", retheme);
387 cancelAnimationFrame(frame);
388 cancelAnimationFrame(animation);
389 });
390 });
391 createEffect(on(graph, (next) => {
392 cancelAnimationFrame(animation);
393 setFocus(next.bars[0]);
394 setHover(undefined);
395 view = { from: 0, to: total() };
396 scroll = 0;
397 redraw();
398 }));
399 createEffect(on([focus, hover, matching], redraw, { defer: true }));
400
401 const path = () => {
402 const chain: Bar[] = [];
403 for (let bar = focus(); bar; bar = bar.parent) chain.unshift(bar);
404 return chain;
405 };
406 const root = () => graph().bars[0]!;
407 const facts = (bar: Bar) => [
408 ["duration", `${ms(bar.span.duration)} (${percent((bar.span.duration / (root().span.duration || 1)) * 100)})`],
409 ...(bar.children.length ? [["self", ms(bar.self)]] : []),
410 ["start", `+${ms(bar.from)}`],
411 ...(bar.span.service !== root().span.service ? [["service", bar.span.service]] : []),
412 ];
413
414 return (
415 <div class="flame">
416 <div class="flame-bar">
417 <nav class="flame-path" aria-label="Zoom">
418 <For each={path()}>
419 {(bar, i) => (
420 <>
421 <Show when={i()}><ChevronRight size={12} aria-hidden="true" /></Show>
422 <button aria-current={bar === focus() ? "location" : undefined} onClick={() => zoomTo(bar)}>
423 {bar.span.name}
424 </button>
425 </>
426 )}
427 </For>
428 </nav>
429 <Show when={graph().services.length > 1}>
430 <span class="legend">
431 <For each={graph().services}>
432 {(service, i) => (
433 <A href={`/services/${job(service)}/traces?trace=${props.trace.id}`} style={{ "--color": `var(--series-${(i() % 8) + 1})` }}
434 data-tip={`${service}'s traces`}>
435 {service}
436 </A>
437 )}
438 </For>
439 </span>
440 </Show>
441 <span class="hint"><kbd>{mac ? "⌘" : "ctrl"}</kbd> scroll to zoom</span>
442 </div>
443 <div ref={wrap} class="flame-canvas">
444 <canvas ref={canvas} tabIndex={0} role="img"
445 aria-label={`Flame chart of ${graph().bars.length} spans. Arrow keys move between spans, Escape zooms out.`}
446 onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp}
447 onPointerCancel={() => (drag = undefined)} onPointerLeave={() => !drag && setHover(undefined)} onKeyDown={onKeyDown} />
448 <Show when={hover()}>
449 {(at) => (
450 <div class="flame-hover" classList={{ left: at().x > wrap.clientWidth / 2, up: at().y > wrap.clientHeight / 2 }}
451 style={{ left: `${at().x}px`, top: `${at().y}px` }}>
452 <strong>{at().bar.span.name}</strong>
453 <dl>
454 <For each={facts(at().bar)}>{([key, value]) => <><dt>{key}</dt><dd>{value}</dd></>}</For>
455 </dl>
456 <Show when={at().bar.span.error}>{(error) => <p class="error">{error()}</p>}</Show>
457 </div>
458 )}
459 </Show>
460 </div>
461 <Show when={focus()}>
462 {(bar) => (
463 <dl class="span-facts">
464 <For each={facts(bar())}>{([key, value]) => <div><dt>{key}</dt><dd>{value}</dd></div>}</For>
465 <Show when={bar().span.error}>{(error) => <div><dt>error</dt><dd class="error">{error()}</dd></div>}</Show>
466 <For each={Object.entries(bar().span.attributes)}>
467 {([key, value]) => <div><dt>{key}</dt><dd class="mono"><Copy value={String(value)} /></dd></div>}
468 </For>
469 <div><dt>span</dt><dd class="mono"><Copy value={bar().span.id} /></dd></div>
470 <div>
471 <A href={`/services/${job(bar().span.service)}?at=${Math.floor(bar().span.start)}`}
472 data-tip={`${bar().span.service} logs from this moment`}>
473 logs
474 </A>
475 </div>
476 </dl>
477 )}
478 </Show>
479 </div>
480 );
481}