Skip to main content

Tracing

You don't configure tracing. configure() builds a tracer, instrument() wires it in, and every LLM call from that point is traced.

This page explains what gets recorded, how calls end up correlated, and the one thing you do have to do by hand — grouping a multi-step operation into a single trace.

What gets recorded

Every LLM call produces two records, in two places:

An observation, written by the gateway. Provider, model, input and output tokens, cost, latency, status, error message, and the endpoint called. This exists for every request through the gateway, whether or not you use this SDK.

A span, produced by the SDK and pushed to Observra. Duration, status, provider, model, and — on the SDK's own clients — token counts.

The two are joined by trace id. Both records carry it, so the dashboard can show a request's gateway-measured facts and its client-side timeline together.

How correlation works

The SDK generates W3C Trace Context and sends it as a traceparent header on gateway-bound requests. The gateway parses that header and stamps the trace and span ids onto the observation it writes.

Trace context is attached only to requests going to your gateway. If your application also calls unrelated third-party APIs, those requests carry nothing — leaking your internal trace identifiers to someone else's service isn't something you opted into by installing an SDK.

Grouping calls into one trace

By default each LLM call is its own trace. That's right for a single request/response, and wrong for anything multi-step: three calls in one agent run become three unrelated entries.

To group them, open one span around the whole operation. Every call inside it joins that trace.

const config = observra.configure({ serviceName: "my-app" });
await observra.instrument();

await config.tracer.startActiveSpan("summarise-document", async (span) => {
const outline = await groq.chat.completions.create({ /* ... */ });
const draft = await groq.chat.completions.create({ /* ... */ });
const final = await groq.chat.completions.create({ /* ... */ });

span.end();
return final;
});

All three calls now share one trace id and render as a single flow.

config.tracer is a standard OpenTelemetry Tracer — anything you can do with one, you can do here.

:::tip This is the single most common mistake An agent, a chain, a retry loop, a map-reduce over documents — anything that makes more than one LLM call for one logical operation needs a wrapping span. Without it the dashboard has no way to know those calls belong together. :::

startActiveSpan, not startSpan

Use startActiveSpan. It makes the span active for the duration of the callback, which is how nested calls find their parent — including across await boundaries.

startSpan creates a span without activating it, so calls inside won't join it and you'll get the ungrouped behaviour you were trying to avoid.

Custom spans

Add spans for the non-LLM parts of an operation — a database read, a retrieval step, a tool execution — and they'll appear in the same trace:

await config.tracer.startActiveSpan("answer-question", async (root) => {
const docs = await config.tracer.startActiveSpan("retrieve", async (span) => {
const result = await vectorStore.search(question);
span.setAttribute("doc.count", result.length);
span.end();
return result;
});

const answer = await groq.chat.completions.create({ /* ...using docs... */ });

root.end();
return answer;
});

Always end() your spans, including on the error path — a span that never ends is never exported. A try/finally is the reliable shape.

Interoperating with your own OpenTelemetry setup

The SDK builds a private TracerProvider and never calls setGlobalTracerProvider. If your application already runs OpenTelemetry, the two coexist; the SDK won't hijack your global provider or your exporters.

It does register a global context manager if none is set — that's what makes span nesting work across await. If you've already registered one, the SDK leaves yours alone.

@opentelemetry/api is a peer dependency for exactly this reason. Two copies of that package in one process means two separate context stores, and context propagation silently breaks. Installing it yourself guarantees one copy.

Failure behaviour

Tracing never breaks your application:

  • If span export fails, the error is swallowed and your LLM call is unaffected.
  • If the gateway doesn't support span ingest, export degrades to a no-op rather than erroring.
  • If setting a span attribute throws, the attribute is lost — the request is not.

The trade-off is that a broken exporter is quiet. If spans aren't appearing, check your gateway URL and key first.

Seeing traces in the dashboard

Open an observation and choose Request flow. Calls sharing a trace id render as one tree.

:::info The tree needs payload capture Request flow reconstructs the tree from captured request and response bodies. Payload capture is opt-in and off by default — with it off, every step reads "capture is off" and you get a flat single-step flow.

Enable it per Environment in the dashboard, under the Environment's security policy. Be deliberate: it means prompts and responses are stored. :::

Next