Skip to content

Overview ​

A trace is one end-to-end operation (a page load, a checkout flow, an SPA route transition); a span is one timed step inside it (a fetch call, a render, a heavy computation). Spans nest under one another and all share the same traceId, so the dashboard can reconstruct the whole operation as a waterfall — see Viewing traces on the dashboard.

In the browser, browserTracingIntegration does most of this for you: it opens a pageload span for the initial load and a navigation span for every SPA route change, and everything below lands under it. The rest of this page is about timing your own code.

There's no separate "start tracing" call. Wrap the code you want timed in span()/asyncSpan() and a trace is created lazily the first time one is needed:

ts
Buglapse.span('render-cart', () => {
    renderCart(items)
})

await Buglapse.asyncSpan('checkout', async (span) => {
    await chargeCard(order)
    span.metric('order.total', order.total)
})

Use span() for synchronous work and asyncSpan() for anything returning a Promise — both finish (and send) the span automatically when the callback returns, resolves, or throws; an exception thrown inside still gets the span sent before it propagates.

Nesting spans ​

Call span()/asyncSpan() again from inside another one and it picks up the enclosing span as its parent automatically — there's no explicit parent id to pass:

ts
await Buglapse.asyncSpan('checkout', async () => {
    await Buglapse.asyncSpan('validate-cart', async () => {
        await validateCart(items)
    })

    await Buglapse.asyncSpan('charge-card', async () => {
        await chargeCard(order)
    })
})

This produces one trace with checkout as the root span and validate-cart/charge-card as its children. "Active" here means: whatever span/trace is current is tracked on a single mutable frame on the client, not an explicit value you thread through your own function calls — nesting works as long as the nested call happens synchronously inside the outer callback, or after an await inside it (the browser has no AsyncLocalStorage, so once fn yields with an await, anything running concurrently on the same client — e.g. a second, unrelated asyncSpan() kicked off in parallel — would clobber which span looks "active" to code that reads it in between). For concurrent, overlapping operations, use startInactiveSpan() instead.

Leaf spans for concurrent work ​

span()/asyncSpan() make their span "active", which makes them unsafe to hold open across an await while something else on the same client might also be timing a step concurrently (e.g. two parallel fetch calls). startInactiveSpan() avoids that: it reads whatever span is active once, synchronously, to use as its parent, then never touches shared state again — call .end() whenever the operation actually finishes, from wherever that happens to be:

ts
async function fetchWithTiming(url: string) {
    const span = Buglapse.startInactiveSpan(`fetch ${url}`)

    try {
        return await fetch(url)
    } finally {
        span.end()
    }
}

await Promise.all([
    fetchWithTiming('/api/cart'),
    fetchWithTiming('/api/recommendations'),
])

Because it never becomes the active span, anything nested inside a startInactiveSpan() operation that itself calls span()/asyncSpan() won't see it as a parent — it's meant for leaf operations, not ones that need their own children. end() is idempotent, so it's safe to call more than once.

Traces are scoped to a pageload/navigation in the browser ​

Unlike the default one-trace-per-operation model, @buglapse/browser's init() wires resolveTraceId to an id that's generated once per pageload and rotated on every SPA navigation (pushState/popstate) — the same trace boundaries Sentry uses for browser tracing. A root span/asyncSpan only creates a new trace when none is already active on the client, so every top-level span you start during the same page view joins one trace; navigating to a new route (or a hard reload) starts a fresh one. This is deliberately separate from the hit counter's sessionId (currentSessionId(), a much longer-lived id persisted in localStorage that rolls over after 30 minutes of inactivity) — that groups pageview hits into a visit for analytics, not spans into a trace. If you want your own grouping instead, pass a resolveTraceId to init().

With browserTracingIntegration each of those page traces gets a root span (pageload or navigation), and a root span()/asyncSpan()/ startInactiveSpan() started while it's open becomes its child instead of a separate root.

Correlating other events with the active span ​

While a span is active, every other event captured on the same client — a log, an exception, a nested span — is stamped with trace.id (and span.id, for the innermost active span) as flat context keys, the same way a replay stamps replay.id:

ts
await Buglapse.asyncSpan('checkout', async () => {
    Buglapse.log.info('starting checkout') // ships with trace.id + span.id already attached

    try {
        await chargeCard(order)
    } catch (error) {
        Buglapse.captureException(error) // this exception is linked to the trace too
    }
})

This is what lets the dashboard link a log or exception back to the trace/span it happened during, without you having to pass an id around yourself.

When no span is active — an exception caught after its request span already ended, a global error handler, a log from an idle page — the event is stamped with the open pageload/ navigation span, if there is one. Otherwise it's still stamped with trace.id of the current page trace (just no span.id, since there's no span to point at). So an error is linked to the page it happened on even when it isn't thrown inside a span. That id is the page's, not the error's own: it's the trace the page's spans go to, and it changes when the page navigates.

Attaching metrics to a span ​

A span metric is a named number attached to one span: a cart total, an item count, a payload size in bytes, a custom timing you measured yourself. It isn't a standalone event — there's no Buglapse.metric() call — it rides along on the span it describes and is sent with it when the span finishes, so every value is tied to the trace and the moment it was recorded. (Not to be confused with the project's Metrics page, which counts page views and custom hits — see Metrics.)

The callback passed to span()/asyncSpan() receives a handle with a metric(name, value) method; call it as many times as you like before the callback returns:

ts
await Buglapse.asyncSpan('checkout', async (span) => {
    const order = await submitOrder(cart)

    span.metric('cart.items', cart.items.length)
    span.metric('order.total', order.total)
})

startInactiveSpan()'s return value exposes the same method — record metrics any time before end():

ts
async function loadImage(url: string) {
    const span = Buglapse.startInactiveSpan(`image ${url}`, { op: 'resource.img' })

    try {
        const blob = await fetch(url).then((response) => response.blob())
        span.metric('image.bytes', blob.size)
        return blob
    } finally {
        span.end()
    }
}

A few rules:

  • Values are numbers. Integers and floats both work; the dashboard shows integers as-is and rounds everything else to two decimals. For strings, booleans, or objects use span.setData(key, value) instead — that's attached to the span as context, not charted.
  • Last value wins. Calling metric() again with the same name on the same span overwrites the previous value — there's no summing or averaging. Accumulate in a local variable if you need a total, and record it once at the end.
  • Only while the span is open. Anything recorded after the callback returns (or after end()) is not sent — the span has already left the client.
  • Scope is per span. A nested span doesn't inherit its parent's metrics. Reuse the same name on different spans when you want to compare them across the trace (see Viewing traces on the dashboard).
  • Names are free-form. A dotted area.measurement convention (order.total, image.bytes) keeps them readable; put the unit in the name when it isn't obvious (upload.ms, payload.kb).

Every span already records its own duration, so wrapping a step in its own span() is usually the simplest way to time it. Reach for a metric when what you want to measure isn't the whole span — e.g. time to first byte inside a longer download:

ts
await Buglapse.asyncSpan('download-report', async (span) => {
    const started = performance.now()
    const response = await fetch('/api/report')
    span.metric('report.ttfb_ms', performance.now() - started)

    const body = await response.arrayBuffer()
    span.metric('report.bytes', body.byteLength)
})

Before init() has run (e.g. the CDN script is still loading), span()/asyncSpan()/ startInactiveSpan() still run your code but with a no-op handle — metric() calls are silently ignored, so there's nothing to guard.

Span metrics have their own monthly quota on the organization's plan, counted per metric entry, not per span: a span with three metric names counts as three, and spans without metrics don't consume it. Once that quota is exhausted, spans carrying metrics are rejected at ingestion.

Sampling ​

tracesSampleRate keeps a random share of traces, like Sentry's option of the same name:

ts
init({ dsn: '{dsn}', tracesSampleRate: 0.2 })

With the CDN embed, set Traces sample rate under Browser tracing in Settings → Loader Script.

The decision is made once per trace, not per span, so a trace and every span (and metric) that shares its traceId are kept or dropped together. You never get a waterfall missing some of its spans. Errors and logs from an unsampled trace are still sent and still carry its trace.id.

Quota ​

Tracing is billed per span: each sent span counts as one toward the monthly spans limit. A trace itself costs nothing — it isn't an event of its own, just the traceId its spans share. Once the spans limit is reached, new spans are rejected and tracing stops until the next month.

Viewing traces on the dashboard ​

The Traces list shows one row per root span; opening one renders the full nested span tree as a waterfall (drag to zoom, scroll to pan) with each span's duration, name, and any metrics attached to it.

Span metrics live here too, in the side panel:

  • With no span selected, it charts every metric name used anywhere in the trace, one small line chart per name. The x-axis walks the trace's spans in order, so reusing a name across several spans (e.g. image.bytes on every image load) shows how the value changes through the trace; the number next to the name is the last recorded value.
  • Selecting a span in the waterfall switches the panel to that span's metrics as plain name/value pairs.

AI clients get the same data through Buglapse's MCP server: the get-trace tool returns every span with its timing and metrics.