Files
qdrant/tools/uio-trace-visualizer.html
xzfc 19d4da9988 UIO tracing and visualizer (#10742)
* Add uio_trace module and visualizer

* Handle object store, not just gRPC

* Improve visualizer

* Improve visualizer [2]
2026-09-23 21:56:24 +00:00

1113 lines
38 KiB
HTML

<!doctype html>
<!--
Visualizer for traces written by the `uio_trace` module.
See `lib/edge/tools/shard_query/SKILL.md` for an example on how to obtain this trace.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>uio trace</title>
<style>
:root {
color-scheme: dark;
--surface: #1a1a19;
--plane: #0d0d0d;
--ink: #ffffff;
--ink2: #c3c2b7;
--muted: #898781;
--grid: #2c2c2a;
--axis: #383835;
--bad: #d03b3b;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: var(--plane); color: var(--ink);
font: 13px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
display: flex; flex-direction: column;
}
header {
display: flex; gap: 8px; align-items: baseline;
padding: 5px 10px; border-bottom: 1px solid var(--axis);
}
header #note {
flex: 1; min-width: 0; color: var(--bad);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
header label { color: var(--ink2); }
button {
background: var(--surface); color: var(--ink2);
border: 1px solid var(--axis); border-radius: 4px; padding: 2px 8px; cursor: pointer;
}
button:disabled { color: var(--muted); cursor: default; }
#file { display: none; }
#wrap { position: relative; flex: 1; min-height: 0; background: var(--surface); }
canvas { display: block; width: 100%; height: 100%; cursor: crosshair; }
#tip {
position: absolute; display: none; pointer-events: none; max-width: 460px;
background: #000000e8; border: 1px solid var(--axis); border-radius: 5px;
padding: 6px 8px; font-size: 12px; line-height: 1.45; white-space: pre-wrap;
word-break: break-all; z-index: 2;
}
#tip b { color: var(--ink); }
#tip .k { color: var(--muted); }
#tip .err { color: #e66767; }
#tip .map { position: relative; height: 8px; margin: 3px 0; }
#tip .map i { position: absolute; top: 0; bottom: 0; }
#toast {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 2;
background: #000000e8; border: 1px solid var(--axis); border-radius: 5px; padding: 8px 14px;
pointer-events: none; opacity: 0; transition: opacity 0.2s;
}
#toast.on { opacity: 1; }
#drop {
position: absolute; inset: 0; z-index: 3; background: var(--surface);
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; color: var(--ink2); text-align: center;
}
#drop.gone { display: none; }
#drop b { color: var(--ink); font-size: 15px; }
#drop code { color: var(--muted); font-size: 12px; }
#hint { margin-top: 44px; }
#help {
max-width: 80ch; color: var(--muted); text-align: left;
white-space: pre-wrap; line-height: 1.6;
}
body.over #drop { outline: 2px dashed var(--ink2); outline-offset: -14px; }
</style>
</head>
<body>
<header>
<span id="note"></span>
<label title="Show text marks left by `uio_trace::mark!()`."><input type="checkbox" id="marks" checked> <u>m</u>arks</label>
<button id="reflow" title="Hide lanes without visible requests.&#10;(when zooming in)">re<u>f</u>low</button>
<button id="url"><u>o</u>pen URL</button>
<button id="upload"><u>u</u>pload file</button>
<input type="file" id="file" accept=".jsonl,.json,.ndjson,application/json">
</header>
<div id="wrap">
<canvas id="c"></canvas>
<div id="tip"></div>
<div id="toast">ctrl+wheel or pinch to zoom</div>
<div id="drop">
<b>What is it?</b>
<div id="help"></div>
<b id="hint">drop a trace here</b>
</div>
</div>
<script>
"use strict";
// Canvas cannot read CSS variables; resolve the palette once.
const CSS = getComputedStyle(document.documentElement);
const [AXIS, BAD, GRID, INK, INK2, MUTED, SURFACE] =
["axis", "bad", "grid", "ink", "ink2", "muted", "surface"]
.map(name => CSS.getPropertyValue("--" + name).trim());
// Lane order, left to right; also the categorical slot order; `null` draws muted.
const KIND_COLOR = {
"vectors": "#3987e5",
"graph.neighbors": "#d95926",
"graph.offsets": "#199e70",
"graph.reindex": "#c98500",
"graph.header": "#d55181",
"graph": null,
"payload_index": "#008300",
"payload_storage": "#9085e9",
"id_tracker": null,
"vector_meta": null,
"index_meta": null,
"meta": null,
};
const SHADES = 8; // tints per kind, so a dense lane reads as texture, not a flat wall
const CONC_BIN = 250; // µs
const BW_BIN = 1000; // µs
const CPU_BIN = 2000; // µs, the sampling interval of the tracer
const LANE_FONT = "11px system-ui, sans-serif";
const REQ_W = 4.5; // px per request bar
const REQ_GAP = 2; // px between neighbouring bars, unless the lane is crowded
const HIT_R = 10; // px, how far from a request bar its tooltip still shows
const MAP_W = 300; // px, the file map in the request tooltip
const BAD_DARK = darken(BAD);
const cv = document.getElementById("c");
const ctx = cv.getContext("2d");
const tip = document.getElementById("tip");
const wrap = document.getElementById("wrap");
const note = document.getElementById("note");
const marksBox = document.getElementById("marks");
const toastBox = document.getElementById("toast");
const reflowButton = document.getElementById("reflow");
const HEAD = 26;
const W_RULER = 68, W_MARK = 26, W_SIDE = 96, W_HIDDEN = 32, GAP = 10;
const view = {t0: 0, tpp: 1};
let D = null;
let L = null;
let mouse = null;
let hover = null;
let select = null; // time the drag started at
let dirty = false;
let refit = true;
let toastTimer = 0;
let hidden = new Set(); // lanes folded into one column by "reflow"
let unseen = []; // lanes without requests in view, as of the last draw
// ---------- trace ----------
// Coarse read type, from the file name alone; a `sections` record refines it.
function classify(path) {
if (/(matrix\.dat|quantized\.data)$/.test(path)) return "vectors";
if (path.includes("/vector_storage/")) return "vector_meta";
if (/(links_compressed|links_comp_vec|links|graph)\.bin$/.test(path)) return "graph";
if (path.includes("/vector_index/")) return "index_meta";
if (path.includes("/payload_index/")) return "payload_index";
if (path.includes("/payload_storage/")) return "payload_storage";
if (path.includes("id_tracker")) return "id_tracker";
return "meta";
}
function build(text, name) {
const layouts = new Map();
const phases = [];
const marks = [];
const reqs = [];
const cpu = [];
let dropped = 0, bad = 0;
for (const line of text.split("\n")) {
if (!line.trim()) continue;
let ev;
try { ev = JSON.parse(line); } catch (err) { bad++; continue; }
if (ev.kind === "sections") {
layouts.set(ev.path, ev.sections);
} else if (ev.kind === "cpu") {
cpu.push({at: ev.at_ns / 1000, cpu: ev.cpu_ns / 1000});
} else if (ev.kind === "dropped") {
dropped += ev.count;
} else if (ev.kind === "phase") {
phases.push({id: ev.id, name: ev.name, s: ev.start_ns / 1000, e: ev.end_ns / 1000, depth: 0});
} else if (ev.kind === "mark") {
marks.push({at: ev.at_ns / 1000, text: ev.text});
} else if (ev.kind === "request") {
reqs.push({
// Requests are not numbered in the trace; the file order names them.
id: reqs.length, parent: ev.parent, s: ev.start_ns / 1000, e: ev.end_ns / 1000,
op: ev.op, path: ev.path, off: Number(ev.offset), len: Number(ev.length), outcome: ev.outcome,
});
}
}
if (!reqs.length && !phases.length) throw new Error("no spans in " + name);
phases.sort((a, b) => a.s - b.s || b.e - a.e);
marks.sort((a, b) => a.at - b.at);
reqs.sort((a, b) => a.s - b.s);
nest(phases);
const byId = new Map(phases.map(phase => [phase.id, phase]));
for (const req of reqs) {
req.spans = sections(layouts, req);
req.kind = req.spans?.length ? classify(req.path) + "." + req.spans[0][0] : classify(req.path);
req.phase = byId.get(req.parent);
}
const counts = new Map(), bytes = new Map();
for (const req of reqs) {
counts.set(req.kind, (counts.get(req.kind) || 0) + 1);
bytes.set(req.kind, (bytes.get(req.kind) || 0) + req.len);
}
const kinds = Object.keys(KIND_COLOR).filter(k => counts.has(k));
kinds.push(...[...counts.keys()].filter(k => !kinds.includes(k)).sort());
const index = new Map(kinds.map((k, i) => [k, i]));
const colors = kinds.map(k => KIND_COLOR[k] || MUTED);
let tMax = 1, maxdur = 1;
for (const req of reqs) {
req.k = index.get(req.kind);
// Disjoint bits of one avalanching hash, so the shade does not correlate
// with the jitter that spreads the bars across the lane.
const h = hash32(req.id);
req.jit = (h >>> 8) / (1 << 24);
req.shade = h % SHADES;
tMax = Math.max(tMax, req.e);
maxdur = Math.max(maxdur, req.e - req.s);
}
for (const phase of phases) tMax = Math.max(tMax, phase.e);
for (const mark of marks) tMax = Math.max(tMax, mark.at);
ctx.font = LANE_FONT;
const phaseW = phases.reduce(
(w, phase) => Math.max(w, 18 + phase.depth * 11 + ctx.measureText(phase.name).width),
ctx.measureText("phases").width + 8);
const conc = concurrency(reqs, tMax);
const bw = throughput(reqs, tMax);
const busy = cores(cpu, tMax);
// The samples count process CPU time from its start, not from the trace start.
const cpu0 = cpu.length ? cpu[0].cpu : 0;
return {
name, kinds, colors, reqs, phases, marks, dropped, bad, maxdur, phaseW,
t_max: tMax,
files: files(layouts, reqs),
in_flight: reqs.reduce((sum, r) => sum + r.e - r.s, 0),
total_bytes: reqs.reduce((sum, r) => sum + r.len, 0),
shades: colors.map(shades),
darks: colors.map(darken),
counts: kinds.map(k => counts.get(k)),
gap: tMax * 0.005,
bytes: kinds.map(k => bytes.get(k)),
S: Float64Array.from(reqs, r => r.s),
conc: {bin: CONC_BIN, v: conc, max: peak(conc)},
bw: {bin: BW_BIN, v: bw, max: peak(bw), sum: prefixSum(bw)},
cpu: {
bin: CPU_BIN, v: busy, max: peak(busy),
total: cpu.length ? cpu[cpu.length - 1].cpu - cpu0 : 0,
at: Float64Array.from(cpu, c => c.at),
used: Float64Array.from(cpu, c => c.cpu - cpu0),
},
};
}
// Phases carry no parent, so nesting is by containment.
function nest(phases) {
const stack = [];
for (const phase of phases) {
while (stack.length && stack[stack.length - 1] < phase.e) stack.pop();
phase.depth = stack.length;
stack.push(phase.e);
}
}
// Bytes of `req` landing in each section of the file's reported byte layout, largest
// first. The disk cache rounds reads out to whole blocks, so a read of one section
// routinely starts inside the previous one; the largest overlap names the read.
function sections(layouts, req) {
const layout = layouts.get(req.path);
if (!layout) return null;
const end = req.off + Math.max(req.len, 1);
const out = [];
for (let i = 0; i < layout.length; i++) {
const next = i + 1 < layout.length ? layout[i + 1][1] : Infinity;
const bytes = Math.min(end, next) - Math.max(req.off, layout[i][1]);
if (bytes > 0) out.push([layout[i][0], bytes]);
}
return out.sort((a, b) => b[1] - a[1]);
}
/**
* Per path, the strips of the file map: `[color, start, end]`.
* The file size is not traced, so the map ends where the furthest read does.
*/
function files(layouts, reqs) {
const sizes = new Map();
for (const req of reqs) sizes.set(req.path, Math.max(sizes.get(req.path) || 0, req.off + req.len));
const out = new Map();
for (const [path, size] of sizes) {
const layout = layouts.get(path) || [[null, 0]];
const strips = layout.map(([name, start], i) => {
const kind = name === null ? classify(path) : classify(path) + "." + name;
const end = i + 1 < layout.length ? layout[i + 1][1] : size;
return [KIND_COLOR[kind] || MUTED, Math.min(start, size), Math.min(end, size)];
});
out.set(path, {size, strips});
}
return out;
}
/** Peak in-flight request count per `CONC_BIN` bucket. */
function concurrency(reqs, tMax) {
const out = new Array(Math.floor(tMax / CONC_BIN) + 1).fill(0);
const events = [];
for (const req of reqs) events.push([req.s, 1], [req.e, -1]);
events.sort((a, b) => a[0] - b[0]);
let held = 0, previous = 0;
for (const [time, delta] of events) {
if (held > 0) {
for (let b = Math.floor(previous / CONC_BIN); b <= Math.floor(time / CONC_BIN); b++) {
out[b] = Math.max(out[b], held);
}
}
held += delta;
previous = time;
}
return out;
}
/** Bytes per `BW_BIN` bucket, each request spread over its lifetime. */
function throughput(reqs, tMax) {
const out = new Array(Math.floor(tMax / BW_BIN) + 1).fill(0);
for (const req of reqs) {
if (!req.len) continue;
const rate = req.len / Math.max(req.e - req.s, 1);
const last = Math.min(Math.floor(req.e / BW_BIN), out.length - 1);
for (let b = Math.floor(req.s / BW_BIN); b <= last; b++) {
const overlap = Math.min(req.e, (b + 1) * BW_BIN) - Math.max(req.s, b * BW_BIN);
out[b] += rate * Math.max(overlap, 0);
}
}
return out.map(Math.round);
}
/** Cores busy per `CPU_BIN` bucket, from the cumulative CPU samples. */
function cores(samples, tMax) {
const out = new Array(Math.floor(tMax / CPU_BIN) + 1).fill(0);
for (let i = 1; i < samples.length; i++) {
const [previous, sample] = [samples[i - 1], samples[i]];
const busy = (sample.cpu - previous.cpu) / Math.max(sample.at - previous.at, 1);
const last = Math.min(Math.floor(sample.at / CPU_BIN), out.length - 1);
for (let b = Math.floor(previous.at / CPU_BIN); b <= last; b++) {
out[b] = Math.max(out[b], busy);
}
}
return out;
}
const peak = values => values.reduce((a, b) => Math.max(a, b), 0);
function prefixSum(values) {
let sum = 0;
return values.map(v => sum += v);
}
function hash32(x) {
x = Math.imul(x ^ (x >>> 16), 2246822507);
x = Math.imul(x ^ (x >>> 13), 3266489909);
return (x ^ (x >>> 16)) >>> 0;
}
/**
* Tints of one colour, mixed towards white.
*
* Only ever lighter than the base, so contrast against the dark surface can
* only improve and every tint keeps the base's validated 3:1.
*/
function shades(color) {
return Array.from({length: SHADES}, (_, i) => mix(color, "#ffffff", 0.22 * i / (SHADES - 1)));
}
/** One colour, darkened: the bar under the cursor. */
function darken(color) {
return mix(color, "#000000", 0.55);
}
function mix(from, to, weight) {
const target = rgb(to);
return "#" + rgb(from)
.map((c, i) => Math.round(c + (target[i] - c) * weight).toString(16).padStart(2, "0"))
.join("");
}
function rgb(hex) {
return [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
}
function rgba(hex, alpha) {
return "rgba(" + rgb(hex).join(",") + "," + alpha + ")";
}
// ---------- layout ----------
function layout() {
const w = cv.clientWidth, h = cv.clientHeight;
const o = {w: w, h: h, top: HEAD, ph: Math.max(1, h - HEAD)};
o.phaseX = W_RULER;
o.markX = o.phaseX + D.phaseW;
o.reqX = o.markX + W_MARK;
o.reqW = Math.max(80, w - o.reqX - 4 * GAP - 3 * W_SIDE);
o.concX = o.reqX + o.reqW + GAP;
o.bwX = o.concX + W_SIDE + GAP;
o.cpuX = o.bwX + W_SIDE + GAP;
// Every lane first takes the width of its own label, then shares out what is
// left by log of its count, so the huge `graph.neighbors` lane cannot swamp
// the rest and a one-request lane still names itself.
const avail = o.reqW - 8 - (hidden.size ? W_HIDDEN : 0);
ctx.font = LANE_FONT;
const floor = D.kinds.map((k, i) => hidden.has(i) ? 0 : ctx.measureText(laneName(k)).width + 6);
const spare = Math.max(0, avail - floor.reduce((a, b) => a + b, 0));
const weight = D.counts.map((c, i) => hidden.has(i) ? 0 : Math.log2(c + 1));
const sum = weight.reduce((a, b) => a + b, 0) || 1;
let lw = floor.map((f, i) => f + spare * weight[i] / sum);
const total = lw.reduce((a, b) => a + b, 0) || 1;
if (total > avail) lw = lw.map(v => v * avail / total); // too many kinds to label
o.laneX = [];
let lx = o.reqX + 4;
for (const width of lw) { o.laneX.push(lx); lx += width; }
// The hidden lanes share one column after the rest.
o.hiddenX = lx;
for (const k of hidden) { o.laneX[k] = lx; lw[k] = W_HIDDEN; }
o.laneW = lw;
const room = lw.map(width => Math.max(1, Math.round(width - REQ_W - 2)));
const placedFor = room + "|" + [...hidden];
if (D.placedFor !== placedFor) {
D.placedFor = placedFor;
place(room);
}
return o;
}
// A request prefers its random spot in the lane, but moves to the nearest spot clear
// of the requests alongside it (or just before it); only a crowded lane lets bars overlap.
function place(room) {
const active = new Map();
for (const req of D.reqs) {
const column = hidden.has(req.k) ? -1 : req.k;
const lane = (active.get(column) || []).filter(other => other.e + D.gap > req.s);
active.set(column, lane);
// Up to twice as thin when the bars do not fit side by side.
const crowd = (lane.length + 1) * (REQ_W + REQ_GAP) / room[req.k];
req.w = REQ_W / Math.min(2, Math.max(1, crowd));
req.x = req.jit * room[req.k];
lane.push(req);
if (lane.length * (req.w + REQ_GAP) > room[req.k]) continue; // crowded, and quadratic below
const blocked = lane.slice(0, -1)
.map(other => [other.x - req.w - REQ_GAP, other.x + other.w + REQ_GAP]);
blocked.sort((a, b) => a[0] - b[0]).push([room[req.k], Infinity]);
const want = req.x;
let from = 0, dist = Infinity;
for (const [a, b] of blocked) {
const to = Math.min(a, room[req.k]);
const x = Math.min(Math.max(want, from), to);
if (from <= to && Math.abs(x - want) < dist) { dist = Math.abs(x - want); req.x = x; }
from = Math.max(from, b);
}
}
}
const yOf = t => L.top + (t - view.t0) / view.tpp;
const tOf = y => view.t0 + (y - L.top) * view.tpp;
const viewEnd = () => view.t0 + L.ph * view.tpp;
const reqX = req => L.laneX[req.k] + req.x;
const fitTpp = () => D.t_max / L.ph;
const clampY = y => Math.min(Math.max(y, L.top), L.h);
function fit() {
refit = true;
redraw();
}
// The trace fills the pane at most once: no zooming past the whole span, and no
// panning past either end of it.
function clampView() {
view.tpp = Math.min(Math.max(view.tpp, 0.02), fitTpp());
view.t0 = Math.min(Math.max(view.t0, 0), Math.max(0, D.t_max - L.ph * view.tpp));
}
// ---------- drawing ----------
function redraw() {
if (dirty || !D) return;
dirty = true;
requestAnimationFrame(() => { dirty = false; draw(); });
}
function draw() {
const dpr = window.devicePixelRatio || 1;
const w = wrap.clientWidth, h = wrap.clientHeight;
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) {
cv.width = Math.round(w * dpr);
cv.height = Math.round(h * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
L = layout();
if (refit) { // stays on until the user zooms in, so resizes re-fit
view.tpp = fitTpp();
view.t0 = 0;
}
clampView();
ctx.clearRect(0, 0, w, h);
ctx.textBaseline = "middle";
drawRuler();
drawPhases();
drawRequests();
drawMarks();
drawSeries(D.conc, L.concX);
drawSeries(D.bw, L.bwX);
drawSeries(D.cpu, L.cpuX);
drawSelection();
drawHeader();
drawCrosshair();
}
function drawHeader() {
ctx.fillStyle = SURFACE;
ctx.fillRect(0, 0, L.w, HEAD);
ctx.strokeStyle = AXIS;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, HEAD - 0.5); ctx.lineTo(L.w, HEAD - 0.5); ctx.stroke();
ctx.fillStyle = MUTED;
ctx.font = LANE_FONT;
ctx.textAlign = "right";
ctx.fillText("ms", W_RULER - 6, HEAD / 2);
ctx.textAlign = "left";
ctx.fillText("phases", L.phaseX + 4, HEAD / 2);
for (let i = 0; i < D.kinds.length; i++) {
if (hidden.has(i)) continue;
ctx.save();
ctx.beginPath();
ctx.rect(L.laneX[i], 0, L.laneW[i] - 4, HEAD);
ctx.clip();
ctx.fillStyle = D.colors[i];
ctx.fillText(laneName(D.kinds[i]), L.laneX[i] + 2, HEAD / 2);
ctx.restore();
}
ctx.fillStyle = MUTED;
if (hidden.size) ctx.fillText("+" + hidden.size, L.hiddenX + 2, HEAD / 2);
ctx.fillText("conc ≤" + D.conc.max, L.concX, HEAD / 2);
ctx.fillText("MB/s ≤" + (D.bw.max / D.bw.bin).toFixed(0), L.bwX, HEAD / 2);
ctx.fillText("CPU ≤" + D.cpu.max.toFixed(1), L.cpuX, HEAD / 2);
}
const laneName = kind => kind.replace("graph.", "g.");
function niceStep(target) {
const magnitude = 10 ** Math.floor(Math.log10(target));
return magnitude * [1, 2, 5, 10].find(m => m * magnitude >= target);
}
function drawRuler() {
const step = niceStep(56 * view.tpp);
const begin = Math.floor(view.t0 / step) * step;
ctx.font = LANE_FONT;
ctx.lineWidth = 1;
for (let t = begin; t <= viewEnd(); t += step) {
const y = Math.round(yOf(t)) + 0.5;
if (y < L.top) continue;
ctx.strokeStyle = GRID;
ctx.beginPath(); ctx.moveTo(W_RULER, y); ctx.lineTo(L.w, y); ctx.stroke();
ctx.strokeStyle = AXIS;
ctx.beginPath(); ctx.moveTo(W_RULER - 6, y); ctx.lineTo(W_RULER, y); ctx.stroke();
ctx.fillStyle = MUTED;
ctx.textAlign = "right";
ctx.fillText(fmtMs(t), W_RULER - 8, y);
}
ctx.textAlign = "left";
}
function drawPhases() {
ctx.save();
ctx.beginPath();
ctx.rect(L.phaseX, L.top, D.phaseW, L.ph);
ctx.clip();
ctx.font = LANE_FONT;
const taken = [];
for (const phase of D.phases) {
const y0 = yOf(phase.s), y1 = yOf(phase.e);
if (y1 < L.top || y0 > L.h) continue;
const x = L.phaseX + 5 + phase.depth * 11;
const alpha = Math.max(0.35, 0.95 - 0.13 * phase.depth);
ctx.fillStyle = rgba(INK2, alpha);
ctx.fillRect(x, Math.max(y0, L.top), 2, Math.max(1, y1 - Math.max(y0, L.top)));
if (y0 >= L.top) ctx.fillRect(x - 3, y0, 8, 2);
ctx.fillRect(x - 3, y1 - 2, 8, 2);
// Nested phases start at nearly the same time; push labels apart.
let ly = Math.min(Math.max(y0 + 9, L.top + 9), Math.max(y1 - 3, L.top + 9));
while (taken.some(v => Math.abs(v - ly) < 12)) ly += 12;
if (ly < L.h) {
taken.push(ly);
ctx.fillText(phase.name, x + 7, ly);
}
}
ctx.restore();
}
function drawRequests() {
const t0 = view.t0, tpp = view.tpp, vend = viewEnd();
ctx.save();
ctx.beginPath();
ctx.rect(L.reqX, L.top, L.reqW, L.ph);
ctx.clip();
// One pixel short, so that back-to-back requests stay apart; errors one pixel wider.
const bar = req => {
const pad = req.outcome === "err" ? 1 : 0;
ctx.fillRect(reqX(req) - pad, L.top + (req.s - t0) / tpp, req.w + 2 * pad,
Math.max(1.5 + pad, (req.e - req.s) / tpp - 1));
};
const bad = [];
const seen = D.kinds.map(() => false);
let last = "";
for (let i = lowerBound(D.S, t0 - D.maxdur); i < D.reqs.length; i++) {
const req = D.reqs[i];
if (req.s > vend) break;
if (req.e < t0) continue;
seen[req.k] = true;
if (req === hover) continue;
if (req.outcome === "err") { bad.push(req); continue; }
const color = D.shades[req.k][req.shade];
if (color !== last) { ctx.fillStyle = color; last = color; }
bar(req);
}
ctx.fillStyle = BAD;
bad.forEach(bar);
if (hover) {
ctx.fillStyle = hover.outcome === "err" ? BAD_DARK : D.darks[hover.k];
bar(hover);
}
ctx.restore();
if (hidden.size) {
ctx.strokeStyle = GRID;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(L.hiddenX - 0.5, L.top); ctx.lineTo(L.hiddenX - 0.5, L.h); ctx.stroke();
}
// An empty view has nothing to reflow to.
unseen = seen.includes(true) ? D.kinds.map((_, k) => k).filter(k => !seen[k]) : [];
reflowButton.disabled = !hidden.size && !unseen.length;
}
function drawMarks() {
const vend = viewEnd();
ctx.font = LANE_FONT;
ctx.lineWidth = 1;
const visible = [];
for (const mark of D.marks) {
if (mark.at < view.t0 || mark.at > vend) continue;
const y = Math.round(yOf(mark.at)) + 0.5;
if (y < L.top) continue;
if (marksBox.checked) {
ctx.strokeStyle = rgba(MUTED, 0.28);
ctx.beginPath(); ctx.moveTo(L.markX, y); ctx.lineTo(L.w, y); ctx.stroke();
}
ctx.strokeStyle = INK2;
ctx.beginPath(); ctx.moveTo(L.markX + 2, y); ctx.lineTo(L.markX + W_MARK - 4, y); ctx.stroke();
visible.push({mark: mark, y: y});
}
if (!marksBox.checked) return;
// Marks crowd together zoomed out; the earlier one keeps the slot.
const taken = [];
for (const v of visible) {
if (taken.some(y => Math.abs(y - v.y) < 12)) continue;
taken.push(v.y);
const label = v.mark.text.length > 64 ? v.mark.text.slice(0, 63) + "…" : v.mark.text;
ctx.fillStyle = INK2;
ctx.fillText(label, L.reqX + 4, v.y);
}
}
function drawSeries(s, x0) {
ctx.save();
ctx.beginPath();
ctx.rect(x0, L.top, W_SIDE, L.ph);
ctx.clip();
ctx.strokeStyle = GRID;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x0 + 0.5, L.top); ctx.lineTo(x0 + 0.5, L.h); ctx.stroke();
const max = s.max || 1;
const i0 = Math.max(0, Math.floor(view.t0 / s.bin));
const i1 = Math.min(s.v.length - 1, Math.ceil(viewEnd() / s.bin));
if (i1 >= i0) {
ctx.beginPath();
ctx.moveTo(x0, yOf(i0 * s.bin));
for (let i = i0; i <= i1; i++) {
const x = x0 + (s.v[i] / max) * (W_SIDE - 2);
ctx.lineTo(x, yOf(i * s.bin));
ctx.lineTo(x, yOf((i + 1) * s.bin));
}
ctx.lineTo(x0, yOf((i1 + 1) * s.bin));
ctx.closePath();
ctx.fillStyle = rgba(INK2, 0.22);
ctx.fill();
ctx.strokeStyle = INK2;
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.restore();
}
function drawSelection() {
if (!selecting()) return;
const [a, b] = selection();
const y0 = Math.max(yOf(a), L.top), y1 = yOf(b);
ctx.fillStyle = rgba(INK, 0.08);
ctx.fillRect(W_RULER, y0, L.w - W_RULER, y1 - y0);
ctx.strokeStyle = rgba(INK, 0.5);
ctx.lineWidth = 1;
ctx.beginPath();
for (const y of [y0, y1]) { ctx.moveTo(W_RULER, Math.round(y) + 0.5); ctx.lineTo(L.w, Math.round(y) + 0.5); }
ctx.stroke();
}
function drawCrosshair() {
if (!mouse || mouse.y < L.top) return;
const y = Math.round(mouse.y) + 0.5;
ctx.strokeStyle = rgba(INK, 0.22);
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.moveTo(W_RULER, y); ctx.lineTo(L.w, y); ctx.stroke();
ctx.setLineDash([]);
ctx.font = LANE_FONT;
ctx.fillStyle = SURFACE;
ctx.fillRect(0, y - 7, W_RULER - 6, 14);
ctx.fillStyle = INK;
ctx.textAlign = "right";
ctx.fillText(fmtMs(tOf(mouse.y)), W_RULER - 8, y);
ctx.textAlign = "left";
}
// ---------- hit testing ----------
function lowerBound(arr, t) {
let lo = 0, hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] < t) lo = mid + 1; else hi = mid;
}
return lo;
}
function hitReq(mx, my) {
if (my < L.top || mx < L.reqX || mx >= L.reqX + L.reqW) return null;
const t = tOf(my), tol = HIT_R * view.tpp;
let best = null, bestDist = HIT_R;
for (let i = lowerBound(D.S, t - D.maxdur - tol); i < D.reqs.length; i++) {
const req = D.reqs[i];
if (req.s > t + tol) break;
if (req.e + tol < t) continue;
const x = reqX(req), y0 = yOf(req.s), y1 = Math.max(yOf(req.e), y0 + 1.5);
const dist = Math.hypot(Math.max(x - mx, 0, mx - x - req.w), Math.max(y0 - my, 0, my - y1));
if (dist < bestDist) { bestDist = dist; best = req; }
}
return best;
}
function hit(mx, my) {
if (my < L.top) return headTip(mx);
const t = tOf(my);
if (mx >= L.markX && mx < L.markX + W_MARK) {
let best = null, bestDy = 5;
for (const mark of D.marks) {
const dy = Math.abs(yOf(mark.at) - my);
if (dy < bestDy) { bestDy = dy; best = mark; }
}
if (best) {
return "<b>" + esc(best.text) + "</b>\n<span class=k>t</span> " +
fmtMs(best.at) + " ms";
}
}
if (mx >= L.phaseX && mx < L.markX) {
let best = null;
for (const phase of D.phases) {
if (t < phase.s || t > phase.e) continue;
if (!best || phase.depth > best.depth) best = phase;
}
if (best) {
return "<b>phase " + esc(best.name) + "</b>\n" + times(best);
}
}
const at = "<span class=k>t</span> " + fmtMs(t) + " ms\n<span class=k>so far</span> ";
if (mx >= L.concX && mx < L.concX + W_SIDE) {
return "<b>concurrency " + binAt(D.conc, t) + "</b>\n" + at +
fmtInt(lowerBound(D.S, t)) + " requests";
}
if (mx >= L.bwX && mx < L.bwX + W_SIDE) {
const v = binAt(D.bw, t);
return "<b>" + (v / D.bw.bin).toFixed(1) + " MB/s</b>\n" + at +
fmtBytes(D.bw.sum[Math.min(Math.floor(t / D.bw.bin), D.bw.sum.length - 1)]) +
"\n<span class=k>bin</span> " + (v / 1e3).toFixed(1) + " kB / " + (D.bw.bin / 1e3) + " ms";
}
if (mx >= L.cpuX && mx < L.cpuX + W_SIDE) {
return "<b>" + binAt(D.cpu, t).toFixed(2) + " cores busy</b>\n" + at +
fmtTotalMs(D.cpu.used[lowerBound(D.cpu.at, t) - 1] ?? 0);
}
return null;
}
function headTip(mx) {
if (hidden.size && mx >= L.hiddenX && mx < L.hiddenX + W_HIDDEN) {
return 'press "reflow" again to expand';
}
const lane = L.laneX.findIndex((x, i) => !hidden.has(i) && mx >= x && mx < x + L.laneW[i]);
if (lane >= 0) {
return "<b>" + esc(D.kinds[lane]) + "</b>\n" + fmtInt(D.counts[lane]) + " requests, " +
fmtBytes(D.bytes[lane]);
}
const summary = (title, average, total) =>
title + "\n<span class=k>average</span> " + average + "\n<span class=k>total</span> " + total;
if (mx >= L.concX && mx < L.concX + W_SIDE) {
return summary("<b>concurrency</b>: number of simultaneous requests",
(D.in_flight / D.t_max).toFixed(2),
fmtInt(D.reqs.length) + " requests, " + fmtTotalMs(D.in_flight) + " in flight");
}
if (mx >= L.bwX && mx < L.bwX + W_SIDE) {
return summary("<b>bandwidth</b>", (D.total_bytes / D.t_max).toFixed(1) + " MB/s",
fmtBytes(D.total_bytes));
}
if (mx >= L.cpuX && mx < L.cpuX + W_SIDE) {
return summary("<b>cpu utilization</b>", (D.cpu.total / D.t_max).toFixed(2) + " cores",
fmtTotalMs(D.cpu.total));
}
return null;
}
// Reflowing again to the same lanes would do nothing, so it toggles back.
const reflowShowsAll = () =>
hidden.size > 0 && hidden.size === unseen.length && unseen.every(k => hidden.has(k));
const binAt = (s, t) => s.v[Math.floor(t / s.bin)] ?? 0;
const selecting = () => select !== null && mouse && Math.abs(yOf(select) - clampY(mouse.y)) >= 3;
function selection() {
const t = tOf(clampY(mouse.y));
return select < t ? [select, t] : [t, select];
}
function selectionTip() {
const [a, b] = selection();
return "<b>" + fmtMs(b - a) + " ms</b>\n<span class=k>from</span> " + fmtMs(a) +
" ms <span class=k>to</span> " + fmtMs(b) + " ms";
}
function reqTip(req) {
return "<b>#" + req.id + " " + esc(req.kind) + "</b>\n" + times(req) +
"<span class=k>op</span> " + esc(req.op) +
" <span class=k>range</span> " + fmtInt(req.off) + "<span class=k>…</span>" +
fmtInt(req.off + req.len) + " <span class=k>size</span> " + fmtBytes(req.len) + "\n" + fileMap(req) + spanTip(req) +
(req.outcome === "ok" ? "" : "<span class=k>outcome</span> <b class=err>" + esc(req.outcome) + "</b>\n") +
// The segment prefix is long and alike across files; the rest tells them apart.
"<span class=k>" + esc(req.path.replace(/^(.*?\bsegments\/[0-9a-f-]{36}\/)(?=.)/, "$1\n")) + "</span>";
}
/** The file as a bar: sections dimmed, the requested range in full colour. */
function fileMap(req) {
if (!req.len) return "";
const file = D.files.get(req.path);
// Bytes `from..to` of the file stretched over `width` px.
const gradient = (from, to, width, tint) => "linear-gradient(to right," + file.strips
.map(strip => {
const px = offset => width * (Math.min(Math.max(offset, from), to) - from) / (to - from);
return tint(strip[0]) + " " + px(strip[1]) + "px " + px(strip[2]) + "px";
})
.join(",") + ")";
const width = Math.max(3, MAP_W * req.len / file.size);
const left = Math.min(MAP_W * req.off / file.size, MAP_W - width);
return "<div class=map style='width:" + MAP_W + "px;background:" +
gradient(0, file.size, MAP_W, color => mix(color, "#000000", 0.5)) + "'><i style='left:" +
left + "px;width:" + width + "px;background:" + gradient(req.off, req.off + req.len, width, color => color) +
"'></i></div>";
}
/** One `<section> <share>` per section the request touched, widest first. */
function spanTip(req) {
if (!req.spans?.length) return "";
const total = Math.max(req.len, 1);
const parts = req.spans.map(([name, bytes]) => esc(name) + " " + fmtPct(bytes / total));
return "<span class=k>sections</span> " + parts.join(" ") + "\n";
}
function times(span) {
return "<span class=k>start</span> " + fmtMs(span.s) + " ms " +
"<span class=k>end</span> " + fmtMs(span.e) + " ms " +
"<span class=k>dur</span> " + fmtMs(span.e - span.s) + " ms\n";
}
// ---------- formatting ----------
function fmtMs(us) {
const step = view.tpp / 1000;
const digits = step < 0.005 ? 4 : step < 0.05 ? 3 : step < 0.5 ? 2 : 1;
return (us / 1000).toFixed(digits);
}
function fmtPct(share) {
const pct = 100 * share;
return (pct >= 10 ? pct.toFixed(1) : pct.toFixed(2)) + "%";
}
function fmtTotalMs(us) {
return (us / 1000).toFixed(1) + " ms";
}
// Not `toLocaleString`: the output should not depend on the locale.
function fmtInt(n) {
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function fmtBytes(n) {
if (n >= 1e6) return (n / 1e6).toFixed(2) + " MB";
if (n >= 1e3) return (n / 1e3).toFixed(1) + " kB";
return n + " B";
}
function esc(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ---------- events ----------
cv.addEventListener("wheel", e => {
if (!D || !L) return;
e.preventDefault();
const px = e.deltaY * [1, 16, L.ph][e.deltaMode];
if (e.ctrlKey || e.metaKey) {
// A touchpad pinch arrives as ctrl+wheel with small deltas; clamp a mouse
// notch's large one so that both zoom at a usable pace.
const anchor = tOf(e.offsetY);
view.tpp *= Math.exp(0.01 * Math.min(Math.max(px, -40), 40));
clampView();
view.t0 = anchor - (e.offsetY - L.top) * view.tpp;
clampView();
refit = view.tpp >= fitTpp();
} else if (refit) {
// Nothing to scroll.
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
toastBox.classList.add("on");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastBox.classList.remove("on"), 1500);
}
return;
} else {
view.t0 += px * view.tpp;
clampView();
}
pointAt(e.offsetX, e.offsetY);
}, {passive: false});
cv.addEventListener("pointerdown", e => {
if (!D || !L || e.button !== 0 || e.offsetY < L.top) return;
select = tOf(e.offsetY);
cv.setPointerCapture(e.pointerId);
pointAt(e.offsetX, e.offsetY);
});
for (const type of ["pointerup", "pointercancel"]) {
cv.addEventListener(type, e => {
if (select === null) return;
select = null;
pointAt(e.offsetX, e.offsetY);
});
}
cv.addEventListener("pointermove", e => pointAt(e.offsetX, e.offsetY));
cv.addEventListener("pointerleave", () => {
mouse = null;
hover = null;
tip.style.display = "none";
redraw();
});
function pointAt(x, y) {
if (!D || !L) return;
mouse = {x, y};
hover = select === null ? hitReq(x, y) : null;
const html = select !== null ? (selecting() ? selectionTip() : null) : hover ? reqTip(hover) : hit(x, y);
if (html === null) {
tip.style.display = "none";
} else {
tip.innerHTML = html;
tip.style.display = "block";
const r = wrap.getBoundingClientRect();
tip.style.left = Math.max(4, Math.min(x + 16, r.width - tip.offsetWidth - 6)) + "px";
tip.style.top = Math.max(4, Math.min(y + 16, r.height - tip.offsetHeight - 6)) + "px";
}
redraw();
}
// The help text is the comment at the top of the file.
document.getElementById("help").innerHTML = esc([...document.childNodes]
.find(node => node.nodeType === Node.COMMENT_NODE).data.trim())
.replace(/`([^`]+)`/g, "<code>$1</code>");
marksBox.addEventListener("change", redraw);
reflowButton.addEventListener("click", () => {
hidden = new Set(reflowShowsAll() ? [] : unseen);
redraw();
});
document.getElementById("upload").addEventListener("click", () => document.getElementById("file").click());
// By key position, so that other keyboard layouts work too.
document.addEventListener("keydown", e => {
if (e.ctrlKey || e.altKey || e.metaKey || e.repeat) return;
const id = {KeyF: "reflow", KeyM: "marks", KeyO: "url", KeyU: "upload"}[e.code];
if (!id) return;
e.preventDefault();
document.getElementById(id).click();
});
window.addEventListener("resize", redraw);
document.getElementById("file").addEventListener("change", e => {
const file = e.target.files[0];
if (file) loadTrace(file.name, () => file.text(), "");
});
document.addEventListener("dragover", e => {
e.preventDefault();
document.body.classList.add("over");
});
document.addEventListener("dragleave", e => {
if (e.relatedTarget === null) document.body.classList.remove("over");
});
document.addEventListener("drop", e => {
e.preventDefault();
document.body.classList.remove("over");
const file = e.dataTransfer.files[0];
if (file) loadTrace(file.name, () => file.text(), "");
});
document.getElementById("url").addEventListener("click", () => {
const url = prompt("trace URL");
if (url) loadUrl(url);
});
window.addEventListener("hashchange", loadHash);
// ---------- init ----------
// Not decoded, so the URL may keep its own `&` and `%XX`.
function loadHash() {
const url = location.hash.match(/^#load=(.+)/)?.[1];
if (url) loadUrl(url);
}
function loadUrl(url) {
loadTrace(url.split("/").pop(), () => fetchText(url), "#load=" + url);
}
const setHash = hash => history.replaceState(null, "", location.pathname + location.search + hash);
async function fetchText(url) {
const resp = await fetch(url);
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.text();
}
let loads = 0;
async function loadTrace(name, text, hash) {
const load = ++loads;
let trace;
try {
trace = build(await text(), name.replace(/\.jsonl$/, ""));
} catch (err) {
if (load !== loads) return;
note.textContent = name + ": " + err.message;
if (D) setHash(D.hash);
return;
}
if (load !== loads) return;
D = trace;
hidden = new Set();
D.hash = hash;
setHash(hash);
document.title = D.name + " — uio trace";
document.getElementById("drop").classList.add("gone");
note.textContent = [
D.dropped && D.dropped + " events dropped",
D.bad && D.bad + " unparsed lines",
].filter(Boolean).join(" · ");
fit();
}
loadHash();
</script>
</body>
</html>