Skip to content

Commit bd195ba

Browse files
authored
Draft: redo tracing UI and add span close events (#342)
* Draft: redo tracing UI and add span close events * Fix stale JSDoc and add missing tracing_sleep example - Update buildSpanData JSDoc to reflect new return type (allSpans with segments instead of spansByWorker) - Add tracing_sleep example referenced in Cargo.toml (demonstrates multi-poll spans across async sleep boundaries) * rewrite tracing pane * finalize tracing rewrite * fix: reset selection state on trace load, fix crosshair scrollbar offset - Reset focusedSpanId, selectedSpanId, selectedSpanIds, and selectedTaskId when loading a new trace. Without this, a stale focusedSpanId causes selectSpanRenderSet to return an empty set, showing "No root spans in view" until the user clicks to clear. - Subtract scrollbarW in eventToTimelineNs so the crosshair drawn from span-panel hover aligns with the lane draw area.
1 parent 58baa38 commit bd195ba

12 files changed

Lines changed: 1377 additions & 354 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
- You MUST use Red/Green TDD.
44
- BEFORE COMMITING: `cargo nextest run --stress-duration 20s`. The package has no flaky tests. If you find a flaky test, you created it.
5+
- **JS/HTML-only changes** (no `.rs` files touched, no trace format changes): you do NOT need to run the full Rust test suite or the stress test. Run the relevant JS tests under `dial9-viewer/ui/test_*.js` with `node <test>` and a quick `cargo build -p dial9-viewer` to confirm `rust-embed` picks up any new files. Skip `cargo nextest` / stress run.
56

67
## API Design
78

PROGRESS.md

Lines changed: 0 additions & 26 deletions
This file was deleted.

dial9-tokio-telemetry/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,10 @@ harness = false
188188
name = "threadlocal_encode"
189189
harness = false
190190

191+
[[example]]
192+
name = "tracing_sleep"
193+
required-features = ["tracing-layer"]
194+
191195
[[bench]]
192196
name = "tracing_layer_bench"
193197
harness = false
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//! Demonstrates tracing spans across async sleep boundaries.
2+
//! Each span is polled multiple times (producing multiple segments in the viewer).
3+
use dial9_tokio_telemetry::telemetry::{RotatingWriter, TracedRuntime};
4+
use dial9_tokio_telemetry::tracing_layer::Dial9TokioLayer;
5+
use std::time::Duration;
6+
use tracing_subscriber::prelude::*;
7+
8+
#[tracing::instrument]
9+
async fn handle_request(id: u32) {
10+
inner_work(id).await;
11+
}
12+
13+
#[tracing::instrument]
14+
async fn inner_work(id: u32) {
15+
for _ in 0..3 {
16+
tokio::time::sleep(Duration::from_millis(5)).await;
17+
}
18+
let _ = id;
19+
}
20+
21+
fn main() {
22+
let mut builder = tokio::runtime::Builder::new_multi_thread();
23+
builder.worker_threads(2).enable_all();
24+
25+
let writer = RotatingWriter::single_file("tracing_sleep_trace.bin").unwrap();
26+
let (runtime, _guard) = TracedRuntime::builder()
27+
.with_task_tracking(true)
28+
.build_and_start(builder, writer)
29+
.unwrap();
30+
31+
let subscriber = tracing_subscriber::registry().with(Dial9TokioLayer::new());
32+
tracing::subscriber::set_global_default(subscriber).expect("failed to set subscriber");
33+
34+
runtime.block_on(async {
35+
let tasks: Vec<_> = (0..10).map(|i| tokio::spawn(handle_request(i))).collect();
36+
for t in tasks {
37+
let _ = t.await;
38+
}
39+
});
40+
41+
println!("Trace written to tracing_sleep_trace.bin");
42+
}

dial9-viewer/design/architecture.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,24 @@ The server exposes a REST API under `/api/` for browsing S3 prefixes, searching
1515

1616
Traces can also be loaded by drag-and-drop or file picker without a server (static file mode).
1717

18+
### Time-panel layout invariant
19+
20+
Every time-based panel in the viewer — timeline header, worker lanes, span panel, task detail, and queue chart — shares the same horizontal layout so its time axis lines up vertically with every other panel:
21+
22+
```
23+
┌──────────────┬──────────────────────────────┬────────┐
24+
│ label area │ draw area │ scroll │
25+
│ LABEL_W │ drawW = W - LABEL_W - sb │ sb │
26+
└──────────────┴──────────────────────────────┴────────┘
27+
x=0 x=LABEL_W x=W-sb x=W
28+
```
29+
30+
`LABEL_W = 100` is the canonical left-gutter width. The invariant is enforced by the shared helper in `ui/panel_layout.js` (`makeTimePanelLayout`), which produces the coordinate-conversion functions (`nsToPanelX`, `panelXToNs`) used by every panel. The browser-side wrapper `timePanelLayout(panel, scrollbarW)` in `viewer.html` adds DOM-reading and canvas-sizing on top.
31+
32+
Worker lanes are a slight exception: they use a DOM flex layout (`lane-label` div of width `LABEL_W`, then a `lane-content` div hosting the canvas) rather than a single canvas with an internal offset. The end result — time axis starts at x=LABEL_W — is identical; new panels should prefer the `timePanelLayout` pattern.
33+
34+
Regression history: the span panel was once built with `padding-left: 200px` instead of `100px`, shifting its time axis ~100px right of every other panel. `ui/test_panel_layout.js` now guards the invariant with unit tests.
35+
1836
## Agent skills (steering)
1937

2038
The viewer bundles markdown "skills" that teach AI agents how to use the toolkit. These are compiled into the binary at build time by `build.rs`:

dial9-viewer/skills/analyze.js

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -198,14 +198,12 @@ function accumulateTrace(acc, trace) {
198198

199199
// Span durations (native HDR histogram for bounded memory, exact percentiles)
200200
if (trace.customEvents && trace.customEvents.length > 0) {
201-
const { spansByWorker } = buildSpanData(trace.customEvents);
202-
for (const spans of Object.values(spansByWorker)) {
203-
for (const s of spans) {
204-
const dur = Math.max(1, Math.round(s.end - s.start));
205-
let h = acc.spanStats.get(s.spanName);
206-
if (!h) { h = createHistogram(); acc.spanStats.set(s.spanName, h); }
207-
h.record(dur);
208-
}
201+
const { allSpans } = buildSpanData(trace.customEvents);
202+
for (const s of allSpans) {
203+
const dur = Math.max(1, Math.round(s.end - s.start));
204+
let h = acc.spanStats.get(s.spanName);
205+
if (!h) { h = createHistogram(); acc.spanStats.set(s.spanName, h); }
206+
h.record(dur);
209207
}
210208
}
211209
}
@@ -767,8 +765,8 @@ function analyzeWorkerMain(cachePath) {
767765
partial.schedDelayWorst.sort((a, b) => b.delay - a.delay);
768766
partial.schedDelayWorst.length = Math.min(partial.schedDelayWorst.length, 100);
769767
if (trace.customEvents && trace.customEvents.length > 0) {
770-
const { spansByWorker } = buildSpanData(trace.customEvents);
771-
for (const ss of Object.values(spansByWorker)) for (const s of ss)
768+
const { allSpans } = buildSpanData(trace.customEvents);
769+
for (const s of allSpans)
772770
(partial.spanDurations[s.spanName] || (partial.spanDurations[s.spanName] = [])).push(Math.max(1, Math.round(s.end - s.start)));
773771
}
774772
process.stdout.write(JSON.stringify(partial) + '\n');

dial9-viewer/ui/demo-trace.bin

-1.68 MB
Binary file not shown.

dial9-viewer/ui/panel_layout.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// panel_layout.js — shared time-panel layout math.
2+
//
3+
// Every time-based panel in the viewer (timeline, worker lanes, span panel,
4+
// task detail, queue chart) must agree on the horizontal layout so their time
5+
// axes line up vertically:
6+
//
7+
// ┌──────────────┬──────────────────────────────┬────────┐
8+
// │ label area │ draw area │ scroll │
9+
// │ LABEL_W │ drawW = W - LABEL_W - sb │ sb │
10+
// └──────────────┴──────────────────────────────┴────────┘
11+
// x=0 x=LABEL_W x=W-sb x=W
12+
//
13+
// The bug this module prevents: a panel redefines the left-gutter width
14+
// (e.g. `padding-left: 200px`) or forgets to add LABEL_W when drawing. Either
15+
// mistake shifts the panel's time axis relative to every other panel — bars
16+
// no longer line up with their corresponding polls, confusing users who are
17+
// trying to correlate events across panels.
18+
//
19+
// Keep this file pure (no DOM access) so it's trivially unit-testable. The
20+
// browser-side `timePanelLayout(panel, scrollbarW)` in viewer.html wraps this
21+
// with DOM-reading and canvas-sizing on top.
22+
23+
(function (exports) {
24+
"use strict";
25+
26+
/**
27+
* Build a time-panel layout view. Pure function — no globals, no DOM.
28+
*
29+
* @param {number} pw Full panel/canvas width in CSS px.
30+
* @param {number} labelW Left gutter width reserved for labels
31+
* (by convention, LABEL_W = 100).
32+
* @param {number} scrollbarW Right gutter for the lane scrollbar. Pass 0
33+
* for panels that don't need to match the lane
34+
* right edge.
35+
* @param {number} viewStart Visible-range start timestamp (ns).
36+
* @param {number} viewEnd Visible-range end timestamp (ns).
37+
* @returns {{
38+
* pw: number, labelW: number, drawW: number,
39+
* nsToPanelX: (ns: number) => number,
40+
* nsToPanelXClamped: (ns: number) => number,
41+
* panelXToNs: (x: number) => number,
42+
* }}
43+
*/
44+
function makeTimePanelLayout(pw, labelW, scrollbarW, viewStart, viewEnd) {
45+
const sb = scrollbarW || 0;
46+
const drawW = pw - labelW - sb;
47+
const span = (viewEnd - viewStart) || 1;
48+
return {
49+
pw,
50+
labelW,
51+
drawW,
52+
/** Convert a timestamp to a canvas x-coordinate (labelW-shifted). */
53+
nsToPanelX(ns) {
54+
return labelW + ((ns - viewStart) / span) * drawW;
55+
},
56+
/** Like nsToPanelX, but clamped to [labelW, labelW+drawW]. */
57+
nsToPanelXClamped(ns) {
58+
const raw = ((ns - viewStart) / span) * drawW;
59+
return labelW + Math.max(0, Math.min(drawW, raw));
60+
},
61+
/** Convert a canvas x-coordinate back to a timestamp. */
62+
panelXToNs(x) {
63+
return viewStart + ((x - labelW) / drawW) * span;
64+
},
65+
};
66+
}
67+
68+
exports.makeTimePanelLayout = makeTimePanelLayout;
69+
})(
70+
typeof module !== "undefined" && module.exports
71+
? module.exports
72+
: (typeof window !== "undefined"
73+
? (window.PanelLayout = window.PanelLayout || {})
74+
: (this.PanelLayout = this.PanelLayout || {})),
75+
);
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env node
2+
// test_panel_layout.js — unit tests for the shared time-panel layout math.
3+
//
4+
// This test protects the invariant that every time-based panel in the viewer
5+
// (timeline, worker lanes, span panel, task detail, queue chart) uses the
6+
// same label/draw area layout so their time axes line up vertically. The
7+
// bug that prompted this test was a regression where the span panel used a
8+
// 200px left gutter while every other panel used 100px, shifting the span
9+
// time axis ~100px to the right of the task detail view.
10+
"use strict";
11+
12+
const assert = require("assert");
13+
const { makeTimePanelLayout } = require("./panel_layout.js");
14+
15+
// The canonical left-gutter width used by every time-based panel. If we ever
16+
// want to make it configurable, update viewer.html's LABEL_W constant and the
17+
// call sites at the same time — but the invariant is that *every* time-based
18+
// panel uses the same value.
19+
const LABEL_W = 100;
20+
21+
let passed = 0, failed = 0;
22+
function test(name, fn) {
23+
try {
24+
fn();
25+
console.log(`✓ ${name}`);
26+
passed++;
27+
} catch (e) {
28+
console.error(`✗ ${name}: ${e.message}`);
29+
failed++;
30+
}
31+
}
32+
33+
test("nsToPanelX at viewStart lands exactly at labelW", () => {
34+
const l = makeTimePanelLayout(1000, LABEL_W, 0, 500, 1500);
35+
assert.strictEqual(l.nsToPanelX(500), LABEL_W);
36+
});
37+
38+
test("nsToPanelX at viewEnd lands at labelW+drawW", () => {
39+
const l = makeTimePanelLayout(1000, LABEL_W, 0, 500, 1500);
40+
assert.strictEqual(l.nsToPanelX(1500), LABEL_W + l.drawW);
41+
});
42+
43+
test("nsToPanelX is linear in the middle of the view", () => {
44+
const l = makeTimePanelLayout(1000, LABEL_W, 0, 0, 1000);
45+
// Midpoint of the view should land at labelW + drawW/2.
46+
assert.strictEqual(l.nsToPanelX(500), LABEL_W + l.drawW / 2);
47+
});
48+
49+
test("nsToPanelXClamped clamps values outside the view", () => {
50+
const l = makeTimePanelLayout(1000, LABEL_W, 0, 500, 1500);
51+
assert.strictEqual(l.nsToPanelXClamped(100), LABEL_W); // before
52+
assert.strictEqual(l.nsToPanelXClamped(2000), LABEL_W + l.drawW); // after
53+
// Values inside the view match nsToPanelX.
54+
assert.strictEqual(l.nsToPanelXClamped(1000), l.nsToPanelX(1000));
55+
});
56+
57+
test("panelXToNs round-trips with nsToPanelX", () => {
58+
const l = makeTimePanelLayout(1234, LABEL_W, 17, 1_000_000, 2_000_000);
59+
for (const ns of [1_000_000, 1_250_000, 1_500_000, 1_999_999]) {
60+
const x = l.nsToPanelX(ns);
61+
const back = l.panelXToNs(x);
62+
assert.ok(Math.abs(back - ns) < 1e-6, `round-trip ${ns} -> ${x} -> ${back}`);
63+
}
64+
});
65+
66+
test("scrollbarW shrinks drawW but does not move labelW", () => {
67+
const noSb = makeTimePanelLayout(1000, LABEL_W, 0, 0, 1);
68+
const withSb = makeTimePanelLayout(1000, LABEL_W, 17, 0, 1);
69+
assert.strictEqual(noSb.labelW, withSb.labelW);
70+
assert.strictEqual(withSb.drawW, noSb.drawW - 17);
71+
// viewStart still maps to x=LABEL_W in both.
72+
assert.strictEqual(noSb.nsToPanelX(0), LABEL_W);
73+
assert.strictEqual(withSb.nsToPanelX(0), LABEL_W);
74+
});
75+
76+
test("INVARIANT: all panels with the same pw share the same time axis", () => {
77+
// Simulate the four time-based panels in the viewer. Each uses its own
78+
// scrollbarW (some have the lane scrollbar gutter, some don't), but they
79+
// all must map the same timestamp to the same x-coordinate within the
80+
// [LABEL_W, LABEL_W+min(drawW)] overlap — i.e. the start of the draw area
81+
// is identical (x=LABEL_W) for every panel.
82+
const pw = 1200;
83+
const vStart = 1_000_000, vEnd = 5_000_000;
84+
85+
const timeline = makeTimePanelLayout(pw, LABEL_W, 17, vStart, vEnd);
86+
const spanPanel = makeTimePanelLayout(pw, LABEL_W, 17, vStart, vEnd);
87+
const taskDetail = makeTimePanelLayout(pw, LABEL_W, 17, vStart, vEnd);
88+
const queueChart = makeTimePanelLayout(pw, LABEL_W, 17, vStart, vEnd);
89+
90+
for (const ts of [vStart, vStart + 500_000, vStart + 2_500_000, vEnd - 1]) {
91+
const x = timeline.nsToPanelX(ts);
92+
assert.strictEqual(spanPanel.nsToPanelX(ts), x, `span panel diverges at ${ts}`);
93+
assert.strictEqual(taskDetail.nsToPanelX(ts), x, `task detail diverges at ${ts}`);
94+
assert.strictEqual(queueChart.nsToPanelX(ts), x, `queue chart diverges at ${ts}`);
95+
}
96+
// The start of the draw area is LABEL_W everywhere, not a panel-specific
97+
// value. (This is the assertion that would have failed for the span
98+
// panel when its left gutter was 200px.)
99+
assert.strictEqual(timeline.labelW, LABEL_W);
100+
assert.strictEqual(spanPanel.labelW, LABEL_W);
101+
assert.strictEqual(taskDetail.labelW, LABEL_W);
102+
assert.strictEqual(queueChart.labelW, LABEL_W);
103+
});
104+
105+
test("REGRESSION: hypothetical 200px-gutter panel would NOT align", () => {
106+
// This is a documentation test: if someone re-breaks the invariant by
107+
// building a panel with a non-LABEL_W gutter, the test ensures we notice.
108+
const pw = 1200;
109+
const vStart = 0, vEnd = 1_000_000;
110+
const good = makeTimePanelLayout(pw, 100, 0, vStart, vEnd);
111+
const bad = makeTimePanelLayout(pw, 200, 0, vStart, vEnd);
112+
// They disagree on where viewStart lands by exactly the gutter delta.
113+
assert.strictEqual(bad.nsToPanelX(0) - good.nsToPanelX(0), 100);
114+
});
115+
116+
test("Zero-width view (viewStart == viewEnd) does not divide by zero", () => {
117+
const l = makeTimePanelLayout(1000, LABEL_W, 0, 500, 500);
118+
assert.ok(Number.isFinite(l.nsToPanelX(500)));
119+
assert.ok(Number.isFinite(l.nsToPanelXClamped(500)));
120+
});
121+
122+
test("Tiny panel width produces negative drawW but does not throw", () => {
123+
// Caller is expected to early-return on drawW <= 0; just verify no NaN.
124+
const l = makeTimePanelLayout(50, LABEL_W, 17, 0, 1000);
125+
assert.ok(l.drawW < 0);
126+
assert.ok(Number.isFinite(l.nsToPanelX(500)));
127+
});
128+
129+
test("INVARIANT: no time-based panel declares its own left gutter in CSS", () => {
130+
// Cheap but effective guard: grep viewer.html for any time-based panel
131+
// defining a `padding-left` value. The whole point of the invariant is
132+
// that the canvas fills the panel and `timePanelLayout` handles the
133+
// LABEL_W offset. If a panel redefines `padding-left`, its time axis
134+
// will silently drift relative to every other panel.
135+
const fs = require("fs");
136+
const path = require("path");
137+
const html = fs.readFileSync(
138+
path.join(__dirname, "viewer.html"),
139+
"utf8",
140+
);
141+
142+
const PANELS = [
143+
"#timeline-header",
144+
"#span-panel",
145+
"#task-detail",
146+
"#queue-chart",
147+
];
148+
for (const panel of PANELS) {
149+
// Match: `<selector> { ... padding-left: ... }` across lines.
150+
const ruleRe = new RegExp(
151+
`${panel.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`,
152+
"g",
153+
);
154+
let m;
155+
while ((m = ruleRe.exec(html)) !== null) {
156+
const body = m[1];
157+
assert.ok(
158+
!/padding-left\s*:/.test(body),
159+
`${panel} must not declare padding-left (would break the time-axis alignment invariant). Use timePanelLayout() instead.`,
160+
);
161+
}
162+
}
163+
});
164+
165+
console.log(`\n${passed} passed, ${failed} failed`);
166+
process.exit(failed > 0 ? 1 : 0);

0 commit comments

Comments
 (0)