Back to Langfuse

Size-adaptive timeline — spike findings

web/src/features/traces/components/TraceTimelineV2/TimelineV2.mdx

4.13.010.9 KB
Original Source

import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as TimelineV2Stories from "./TimelineV2.stories"; import * as SizeStories from "./TimelineV2Sizes.stories"; import * as CompressionStories from "./TimelineV2Compression.stories"; import * as MobileStories from "./TimelineV2Mobile.stories";

<Meta of={TimelineV2Stories} name="Findings" />

Size-adaptive timeline — spike findings

A throwaway renderer over a pure layout(), built to answer one question before any production code moves: does fit-to-box + zoom + time compression read better than today's fixed 900px canvas, at every size and shape?

Verdict: yes, and by a wide margin. The model holds at every cell of the matrix. Two things it does not solve on its own are called out below: the name gutter below ~480px, and what a collapsed gap should look like.

What to look at, in order

  1. Sizes → Variant Matrix. The same nine-observation trace at 320 / 480 / 768 / 1280 / 1920. Every bar is inside every box. Nothing scrolls sideways at any width. The scale readout under each cell shows the px/ms the box bought.
  2. Timeline V2 → Reporter Shape. The exact trace from the community report, in the peek-width lane it was reported in. Compare against the screenshot on that issue.
  3. Compression → Long Tail Honest, then Long Tail Compressed. The whole trade-off in two cells.
  4. Mobile → Variant Matrix. Five takes on the gutter problem at 320px.
  5. Timeline V2 → Ten Thousand Spans. Ctrl-scroll (or pinch) inside the chart to zoom; double-click a bar to fit it; double-click again to reset.

Does the model win?

<Canvas of={SizeStories.VariantMatrix} />

At 320px the trace is whole, the axis is honest and the durations are readable. That is the claim, and it holds without a single conditional on viewport width: layout() gets the measured box and everything else follows. The 1920px cell gets 3.37 px/ms for the same trace, from the same code path.

Where it does not win on its own:

  • Names. At 320px the gutter is 127px, so RunnableSequence renders as Runna…. Fit-to-box fixed time, not identity. See the mobile section.
  • Zero-information cells. All-instantaneous and missing-endTime traces render a column of dashed minimum-width markers. That is honest — there is genuinely no duration to draw — but a trace where nothing has an end would read better as a list than as a chart. Worth a Phase 2 decision, not a renderer change.

Two bugs the spike caught in its own first render

Both are the exact failure class the architecture exists to remove, and both are worth remembering for Phase 2:

  1. The frame border. The component sized its lanes from the box prop while the actual content area was 2px narrower, so gutter + lane exceeded the viewport and produced 2px of horizontal scroll. Fixed by measuring the scroll viewport's own client box with a ResizeObserver rather than deriving it.
  2. The scrollbar. On a platform with classic scrollbars the same arithmetic is wrong by another ~15px. Measuring covers both. Phase 2 must measure the element that scrolls, not the element that contains it.

Design question 1 — mobile composition

<Canvas of={MobileStories.VariantMatrix} />
TakeLaneVerdict
split — gutter beside chart191pxCorrelation kept, names useless (Chat…)
icons — gutter is icon + caret272pxChart readable, identity gone entirely
overlay — names over the bars318pxWorst: name, duration and bar fight for the same pixels, depth lost
stacked — name above, bar below318pxBest. Full names, full-width chart, depth by indent, durations kept
modes — tree ⇄ timeline toggle318pxHonest, but correlating a name to a bar costs a mode flip

Recommendation: stacked for pointer: coarse / narrow boxes, split above ~480px. It is the only take that keeps all three of identity, duration and position, and it costs only row height — which touch density was already spending (44px). Note this moved density's second axis somewhere the plan did not anticipate: resolveDensity takes lines: 1 | 2, because a row that stacks a name over a bar needs two text lines' worth of height.

Density is not a function of box height. The first revision grew rows into the slack of a tall box (a 3-row trace got 34px rows in a 420px box); review rejected it on sight — density is the whole point of the parent ticket, so the box decides how many rows are visible, never how tall they are. Row height resolves from modality and lines-per-row only: 26px fine, 44px coarse, 38px stacked.

Does this need a designer? For the choice, no — stacked is clearly ahead. For the finish, yes: row separation, what a selected row looks like, and whether the type icon stays on the name line are visual calls, and the stacked row is a new pattern in this product rather than a tweak to an existing one.

Design question 2 — compression on by default?

<Canvas of={CompressionStories.LongTailHonest} />

Fitted honestly, a 36.02s trace whose real work is three 40ms spans gives 0.005 px/ms: three 4px slivers pinned to the minimum bar width. This is exactly the "36.02s root drawn as a sliver" complaint, reproduced.

<Canvas of={CompressionStories.LongTailCompressed} />

Compressed: 0.372 px/ms, a 74× change, and the spans are real bars with readable labels. Two collapsed bands cost 28px each — 29% of a 191px lane spent saying "nothing happened here".

Recommendation: default OFF, one click away, and auto-suggested. Reasons:

  • Compression is only ever a large win on long-tail traces. On a dense trace it correctly does nothing (see Dense Trace Compressed), so defaulting it on would change nothing for most traces while making a minority of traces lie.
  • It genuinely misleads. A user reading proportion off a compressed axis reads it wrong, and that is a support cost we would be creating.
  • The cheap middle path: when layout() reports gaps that would collapse, offer it in place — "36.02s, 99.6% idle · compress?" — rather than making every user carry a preference.

Two things compression needs before it ships, both learned here:

  • Ticks at the gap boundaries. Without them every nice-step tick falls inside a collapsed gap and the axis degrades to a lone 0ms. With them the axis reads 0ms · 17.95s · 35.91s and the visible jump across a 28px band is what explains the band.
  • A better band than . The current treatment is a dashed grey column with a hover title. It is legible-but-mute, and 28px cannot hold 17.84s. This is a small design ask.

Performance — the React-vs-imperative call

The plan bets that vertical virtualization keeps the mounted row count low enough that recomputing every visible bar per frame in React is affordable, so we can adopt Sentry's 146-line space model and skip its 2447-line imperative view manager. The bet is correct, with three orders of magnitude to spare.

Measured on the 10k-span story, 768×420 box, 486px lane:

MeasurementResult
Mounted bars for 10,000 rows20
layout(), 40-row window, per call0.0070 ms (0.0046 ms compressed)
Frame time, 235 frames of continuous ctrl-wheel zoomp50 8.3 ms, p95 9.3 ms, max 9.4 ms
Frames over 16.7 ms0
Same measurement with no zoom driven (baseline)p50 8.3 ms, max 9.4 ms
Zoom range actually swept during the run0.02 → 486 px/ms
prepareTimeline(10k) — memoized on tree + collapse1.92 ms
timeCompressionFor(10k) — memoized on box width2.05 ms

Frame time under continuous zoom is indistinguishable from idle, and the pure math is ~0.007ms against a 16.7ms budget. Measured in Storybook's dev build, so React is unminified and in development mode — production is faster, making these numbers a pessimistic bound.

Conclusion: adopt the space model, skip the view manager. If a future shape ever does drop frames, layout() is the seam an imperative write path plugs into without redesigning anything above it — the two calls that are not cheap (prepareTimeline, timeCompressionFor) are already memoized out of the frame loop.

Deviations from the Sentry originals

  • Time compression prices its label buffer at the wrong scale. Sentry converts DURATION_LABEL_BUFFER_PX = 48 to milliseconds using the uncompressed px/ms. In a 320px lane that reserves ~5.4 seconds around each span of a 36s trace, and compression becomes a no-op precisely where it is needed most. One fixed-point step — compress with no buffer, then re-compress with the buffer priced at the resulting scale — takes the long-tail case from ~15px of legible bar to ~41px.
  • Clamping holds the zoom, not the pan. At the right edge Sentry holds trace_view.x and shrinks the window; we hold the window and pull x back. Panning past the end should stop, not silently zoom out.
  • A plain scalar transform replaces mat3. Both spaces are one-dimensional in time, so the matrix composition buys nothing.
  • No gl-matrix, no TraceScheduler, no DOM-ref write path.

What Phase 2 inherits

Pure and tested, no React: layout(), the four-space view model, compression, the text measurer, density resolution. TreeNode already satisfies LayoutNode structurally, so there is no adapter to write.

Suggested sub-issue split:

  1. Land the renderer behind the existing timeline entry point — measured box → layout() → absolute placement, SCALE_WIDTH deleted, gutter/chart split, virtualization, playhead and selection kept working. Closes the community issue; its acceptance test is Reporter Shape at peek width.
  2. Zoom/pan as real state — toolbar, double-tap-to-fit, URL persistence if we want shareable zoom.
  3. Touch + density — pinch, drag, the stacked composition below 480px, pointer: coarse resolution, and a tap equivalent for every hover-only affordance in TimelineBar.
  4. Compression, with the gap-boundary ticks and a designed band, behind the suggest-in-place affordance rather than a preference.
  5. The density follow-ups — gutter header dead space, observation names weighted like the trace tree.