Skip to content

Overview ​

A trace is one end-to-end operation — an incoming request, a queue job, a cron run. A span is one timed step inside it — a database query, an outgoing HTTP call, a render. Spans nest and share the trace's id, so the dashboard can show the whole operation as a waterfall.

Two integrations do most of the work for an HTTP server:

  • HttpServerIntegration makes every incoming request a trace, with an http.server root span named GET /users/42;
  • HttpTracingIntegration records every outgoing request as an http.client span under whatever span is active.

The rest of this page is about timing your own code.

Spans ​

Wrap the code you want timed in span() (synchronous) or asyncSpan() (returns a Promise):

ts
import { asyncSpan, span } from '@buglapse/node'

const report = span('build-report', () => buildReport(rows))

await asyncSpan('db.query', async () => {
    return db.query('select * from orders where id = $1', [id])
}, { op: 'db' })

Both return what the callback returns and finish the span when it returns, resolves or throws. A span that throws is sent with status error before the error propagates. op is an optional category for the span (db, cache, queue.process, ...).

There's no "start trace" call: a span started with no other span active becomes the root of a new trace.

Nesting spans ​

A span started inside another becomes its child — there's no parent id to pass around:

ts
await asyncSpan('checkout', async () => {
    await asyncSpan('reserve-stock', () => reserveStock(cart))
    await asyncSpan('charge-card', () => chargeCard(order))
})

This produces one trace: checkout with reserve-stock and charge-card as children. Because the Node SDK keeps the active span in AsyncLocalStorage, nesting follows the async call chain — across await, timers and promise callbacks — and concurrent requests never see each other's spans. Two requests running at the same time each build their own tree.

Setting status and data ​

The callback receives a handle for the span:

ts
await asyncSpan('sync-inventory', async (span) => {
    const result = await syncInventory()

    span.setData('inventory.provider', result.provider)
    span.metric('inventory.items', result.count)

    if (result.partial) span.setStatus('error')
})
  • setStatus('ok' | 'error' | 'cancelled') — overrides the status; by default a span is ok, or error if its callback threw.
  • setData(key, value) — attaches any value to the span as context.
  • metric(name, value) — attaches a number, charted on the trace page. See Metrics.

Leaf spans for concurrent work ​

startInactiveSpan() creates a span that never becomes the active one — you end it yourself, whenever the operation finishes. Use it for leaf operations that don't need children of their own, especially when you start several at once:

ts
import { startInactiveSpan } from '@buglapse/node'

async function cached<T>(key: string, load: () => Promise<T>): Promise<T> {
    const span = startInactiveSpan(`cache ${key}`, { op: 'cache' })

    try {
        const hit = await redis.get(key)
        span.setData('cache.hit', hit !== null)
        return hit !== null ? JSON.parse(hit) : await load()
    } finally {
        span.end()
    }
}

await Promise.all([cached('user:42', loadUser), cached('plan:pro', loadPlan)])

It takes the active span as its parent, so inside a request it lands in the request's trace. Spans started inside it with span()/asyncSpan() don't see it as their parent. end() is safe to call more than once.

This is how HttpTracingIntegration records outgoing requests.

Traces for jobs and scripts ​

Anything that isn't an incoming HTTP request becomes a trace as soon as you wrap it in a root span:

ts
import { asyncSpan } from '@buglapse/node'

queue.process('send-invoice', async (job) => {
    await asyncSpan(`job ${job.name}`, () => sendInvoice(job.data), { op: 'queue.process' })
})

Each call starts its own trace. Code that runs outside any span — at startup, in a top-level timer — isn't part of a trace, and events captured there carry no trace.id.

Correlating other events with the active span ​

While a span is active, every event captured in its async context — an exception, a log, a nested span — is stamped with trace.id and span.id (the innermost active span):

ts
import { asyncSpan, captureException, log } from '@buglapse/node'

await asyncSpan('checkout', async () => {
    log.info('Starting checkout') // linked to the trace

    try {
        await chargeCard(order)
    } catch (error) {
        captureException(error) // linked to the trace too
    }
})

This is what lets the dashboard link an exception or a log to the trace it happened in. With HttpServerIntegration, everything captured while handling a request is linked to that request's trace.

Sampling ​

tracesSampleRate keeps a random share of traces:

ts
init({ dsn: process.env.BUGLAPSE_DSN, tracesSampleRate: 0.1 })

The decision is made once per trace, so a trace arrives with all its spans or not at all. 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. Span metrics ride on their span and cost nothing extra — see Metrics.

Viewing traces on the dashboard ​

The Traces page lists one row per root span. Opening one shows the span tree as a waterfall (drag to zoom, scroll to pan) with each span's duration, name and metrics. AI clients get the same through Buglapse MCP server — the get-trace tool.