v2.6.0

<bmx-trace-viewer>

A distributed-trace viewer in the page, for observability portals, support tools and incident write-ups: give it a trace exported from OpenTelemetry, Zipkin or Jaeger and it shows where the time went:

12 properties · 3 events · 15 methods · 6 parts

Example

Three checkout requests at a fictional shop, as an OpenTelemetry export, and a normal run as the baseline. The slow one waited on a retried card payment: turn on Critical path, or open Compare.

Show markup
<bmx-trace-viewer id="ex-tv" style="--bmx-trace-viewer-height: 26rem"></bmx-trace-viewer>
<p class="note" id="ex-tv-out" role="status" style="margin: 12px 0 0">Three checkout requests at a fictional shop, as an OpenTelemetry export, and a normal run as the baseline. The slow one waited on a retried card payment: turn on Critical path, or open Compare.</p>

<script type="module">
  await customElements.whenDefined('bmx-trace-viewer');
  const tv = document.getElementById('ex-tv');
  const out = document.getElementById('ex-tv-out');

  // Checkout traces for Harbour & Pine, a fictional shop, made here with a seeded random generator and
  // written as OTLP JSON - what the OpenTelemetry collector's file exporter writes.
  let seed = 11;
  const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
  const hex = n => Array.from({ length: n }, () => Math.floor(rnd() * 16).toString(16)).join('');
  const ms = 1000; // microseconds

  function checkout(startUs, { slow = false, fail = false } = {}) {
    const traceId = hex(32);
    const spans = [];
    const add = (parent, service, name, kind, start, dur, attrs = {}, extra = {}) => {
      const s = { traceId, spanId: hex(16), parentSpanId: parent?.spanId, service, name, kind, start, end: start + dur, attrs, ...extra };
      spans.push(s);
      return s;
    };
    const t0 = startUs;
    const total = slow ? 1640 : 420;
    const root = add(null, 'storefront', 'POST /checkout', 2, t0, total * ms, { 'http.method': 'POST', 'http.route': '/checkout', 'http.status_code': fail ? '502' : '200' });
    const gw = add(root, 'api-gateway', 'POST /api/orders', 2, t0 + 4 * ms, (total - 10) * ms, { 'http.route': '/api/orders', 'net.peer.name': 'storefront' });
    add(gw, 'api-gateway', 'authenticate', 1, t0 + 5 * ms, 9 * ms, { 'enduser.id': 'customer-1042' });
    const co = add(gw, 'checkout', 'PlaceOrder', 2, t0 + 16 * ms, (total - 26) * ms, { 'rpc.system': 'grpc', 'order.items': '3' });
    // Cart, prices and stock are looked up side by side.
    const cart = add(co, 'cart', 'GetCart', 2, t0 + 18 * ms, 34 * ms, { 'cart.items': '3' });
    add(cart, 'cart', 'redis HGETALL', 3, t0 + 20 * ms, 6 * ms, { 'db.system': 'redis', 'db.statement': 'HGETALL cart:1042' });
    const price = add(co, 'pricing', 'PriceItems', 2, t0 + 19 * ms, 48 * ms, { 'pricing.currency': 'GBP' });
    add(price, 'pricing', 'SELECT price_rules', 3, t0 + 23 * ms, 22 * ms, { 'db.system': 'postgresql', 'db.statement': 'SELECT * FROM price_rules WHERE sku = ANY($1)' });
    const stock = add(co, 'inventory', 'ReserveStock', 2, t0 + 19 * ms, 61 * ms, { 'inventory.warehouse': 'Leeds' });
    add(stock, 'inventory', 'UPDATE stock', 3, t0 + 28 * ms, 38 * ms, { 'db.system': 'postgresql', 'db.statement': 'UPDATE stock SET reserved = reserved + $1 WHERE sku = $2' });
    // Then the payment.
    let at = t0 + 84 * ms;
    const pay = add(co, 'payments', 'Charge', 2, at, (slow ? 1360 : 170) * ms, { 'payment.method': 'card', 'payment.amount': '84.50' });
    add(pay, 'fraud-check', 'Score', 2, at + 3 * ms, 41 * ms, { 'fraud.score': '0.07' });
    at += 48 * ms;
    if (slow) {
      // The bank timed out once; the retry went through.
      add(pay, 'bank-adapter', 'POST /v2/authorisations', 3, at, 1000 * ms, { 'http.status_code': '504', 'retry.attempt': '1' }, { error: 'upstream timed out after 1000 ms', events: [{ at: at + 1000 * ms, name: 'exception', attrs: { 'exception.type': 'TimeoutError', 'exception.message': 'upstream timed out after 1000 ms' } }] });
      at += 1004 * ms;
      add(pay, 'bank-adapter', 'POST /v2/authorisations', 3, at, 112 * ms, { 'http.status_code': fail ? '502' : '201', 'retry.attempt': '2' }, fail ? { error: 'card issuer unavailable' } : {});
      at += 116 * ms;
    } else {
      add(pay, 'bank-adapter', 'POST /v2/authorisations', 3, at, 104 * ms, { 'http.status_code': '201', 'retry.attempt': '1' });
      at += 108 * ms;
    }
    at = Math.max(at, pay.end) + 2 * ms;
    const order = add(co, 'orders', 'CreateOrder', 2, at, 54 * ms, { 'order.id': `HP-${700000 + Math.floor(rnd() * 99999)}` });
    add(order, 'orders', 'INSERT orders', 3, at + 4 * ms, 18 * ms, { 'db.system': 'postgresql', 'db.statement': 'INSERT INTO orders (...) VALUES (...)' });
    add(order, 'orders', 'INSERT order_lines', 3, at + 24 * ms, 21 * ms, { 'db.system': 'postgresql', 'db.statement': 'INSERT INTO order_lines (...) VALUES (...)' });
    const pub = add(order, 'orders', 'publish order.created', 4, at + 47 * ms, 4 * ms, { 'messaging.system': 'kafka', 'messaging.destination': 'order.created' });
    // Sent on after the response: e-mail and shipping pick the message up.
    const mail = add(pub, 'notifications', 'consume order.created', 5, at + 60 * ms, 85 * ms, { 'messaging.system': 'kafka' });
    add(mail, 'notifications', 'send confirmation e-mail', 3, at + 70 * ms, 70 * ms, { 'email.template': 'order-confirmation' });
    add(pub, 'shipping', 'consume order.created', 5, at + 58 * ms, 40 * ms, { 'messaging.system': 'kafka', 'shipping.carrier': 'Northline' });
    if (fail) root.error = 'payment failed';
    // Grouped by service, as OTLP has it.
    const byService = new Map();
    for (const s of spans) (byService.get(s.service) ?? byService.set(s.service, []).get(s.service)).push(s);
    const nanos = us => `${Math.round(us)}000`;
    const attrs = o => Object.entries(o).map(([key, v]) => ({ key, value: { stringValue: String(v) } }));
    return [...byService.entries()].map(([service, list]) => ({
      resource: { attributes: attrs({ 'service.name': service, 'service.version': '4.12.0', 'deployment.environment': 'production', 'host.name': `${service}-7d9f` }) },
      scopeSpans: [
        {
          scope: { name: 'opentelemetry-instrumentation' },
          spans: list.map(s => ({
            traceId: s.traceId,
            spanId: s.spanId,
            ...(s.parentSpanId ? { parentSpanId: s.parentSpanId } : {}),
            name: s.name,
            kind: s.kind,
            startTimeUnixNano: nanos(s.start),
            endTimeUnixNano: nanos(s.end),
            attributes: attrs(s.attrs),
            events: (s.events ?? []).map(e => ({ timeUnixNano: nanos(e.at), name: e.name, attributes: attrs(e.attrs) })),
            status: s.error ? { code: 2, message: s.error } : { code: 0 },
          })),
        },
      ],
    }));
  }

  const now = Date.UTC(2026, 9, 14, 9, 30) * 1000;
  tv.baseline = { resourceSpans: checkout(now - 3_600_000_000) };
  tv.data = {
    resourceSpans: [
      ...checkout(now, { slow: true }),
      ...checkout(now - 60_000_000),
      ...checkout(now - 120_000_000, { slow: true, fail: true }),
    ],
  };

  tv.addEventListener('bmxSelect', e => {
    const s = e.detail.span;
    out.textContent = `bmxSelect: ${s.service}: ${s.name}${s.status === 'error' ? ' (error)' : ''}.`;
  });
  tv.addEventListener('bmxTraceChange', e => {
    out.textContent = `bmxTraceChange: trace ${e.detail.traceId.slice(0, 8)}…`;
  });
</script>
  • the timeline (waterfall): every span as a bar on a shared time axis, in its place in the call tree, folding, with an overview to drag across and zoom into a stretch of time;
    • the critical path - the work that decided how long the request took - marked on the bars, with each span's self time and time on the path;
    • a flame graph of the same trace;
    • figures by service and by operation, and the service map;
    • comparing two traces of the same request: what got slower, faster, appeared or went away;
    • search across names, services and attributes, errors only;
    • exports: spans as CSV, the trace as OTLP JSON, the timeline and the service map as SVG pictures.
<bmx-trace-viewer id="tv"></bmx-trace-viewer>
<script>
  tv.data = await (await fetch('/api/traces/4bf92f3577b34da6')).json();   // OTLP, Zipkin or Jaeger
</script>

The trace never leaves the page. The same engine is in dist/node/bmx-trace-viewer.mjs for a Worker or a server.

The timeline is an ARIA tree (only the rows in view are drawn, so a trace of tens of thousands of spans scrolls smoothly): arrow keys move through the spans, Right and Left open and fold, and each span says its service, operation, duration, start, and whether it failed or is on the critical path.

Properties

PropertyAttributeTypeDefaultDescription
allowOpen allow-open boolean true Offer the "Open a trace" button.
baseline property only unknown '' A trace (or traces) to compare against - a known-good run of the same request - in any of the same formats.
compareWith compare-with string '' The trace the Compare view compares with (its id; default the baseline, or the next trace). Mutable.
criticalPath critical-path boolean true Mark the critical path. Mutable.
data property only unknown '' The traces: OTLP JSON, Zipkin v2 JSON or Jaeger JSON - text or parsed - or JSON lines. Mutable (opening a file sets it).
errorsOnly errors-only boolean false Show only spans that failed (with what called them). Mutable.
fileName file-name string 'trace' The name downloads are given (without the extension).
label label string — The element's accessible name. Default: "Trace viewer".
selected selected string '' The chosen span's id. Mutable.
strings strings Partial<BmxTraceViewerStrings> | string — Words to show instead of the English ones, as an object or JSON.
traceId trace-id string '' The trace shown (its id; default the first). Mutable.
view view BmxTraceView 'timeline' The view shown. Mutable.

Events

EventDetailDescription
bmxLoad BmxTraceLoadDetail Fired when traces have been read.
bmxSelect BmxTraceSelectDetail Fired when a span is chosen.
bmxTraceChange BmxTraceChangeDetail Fired when another trace is shown.

Methods

MethodSignatureDescription
collapseAll collapseAll() => Promise<void> Folds every span that has children (the roots stay open).
compare compare(otherTraceId?: string) => Promise<ReturnType<typeof compareTraces> | undefined> The trace shown compared with another (default the Compare view's choice).
download download(format?: BmxTraceExportFormat, fileName?: string) => Promise<void> Saves an export as a file.
expandAll expandAll() => Promise<void> Unfolds every span.
exportCsv exportCsv() => Promise<string> The trace shown's spans as CSV.
exportOtlp exportOtlp(all?: boolean) => Promise<string> The trace shown as OTLP JSON (all traces read, with all).
exportSvg exportSvg(kind?: "waterfall" | "map") => Promise<string> The timeline or the service map as a stand-alone SVG picture.
find find(query: string) => Promise<string[]> Spans matching the text (name, service, id, attributes, events), in tree order.
getCriticalPath getCriticalPath() => Promise<BmxCriticalSegment[]> The critical path of the trace shown, in time order.
getTrace getTrace(traceId?: string) => Promise<BmxTrace | undefined> A trace (default the one shown), with its tree, self times and critical path.
getTraces getTraces() => Promise<BmxTraceSummary[]> The traces read, newest first (with the baseline's last).
resetZoom resetZoom() => Promise<void> Shows the whole trace again.
selectSpan selectSpan(spanId: string) => Promise<void> Chooses a span (unfolding the way to it) and scrolls it into view.
setTrace setTrace(traceId: string) => Promise<void> Shows a trace.
zoomTo zoomTo(from: number, to: number) => Promise<void> Zooms the timeline to a stretch of the trace (microseconds from its start).

CSS shadow parts

PartDescription
bar A span's bar.
details The span details panel.
header The trace's figures across the top.
row A span's row in the timeline; also row-selected.
table A table (services, operations, comparison).
tabs The row of views.

CSS custom properties

PropertyDescription
--bmx-trace-viewer-critical The critical-path mark.
--bmx-trace-viewer-details-width The details panel's width.
--bmx-trace-viewer-height The height of the timeline's list and the flame graph.
--bmx-trace-viewer-names The width of the names column.
--bmx-trace-viewer-radius Corner radius of the element.
--bmx-trace-viewer-service-1 Service colour 1 (and 2 to 10).