SDK Reference · Tracing
Tracing agentic work
A span is a unit of work with a P&L: cost flows in automatically from the calls made inside it, your attribution context flows through, and the unit of value it produced flows out. Wrap an agent run in a span and you get the call tree, cost per branch, cost per outcome, and a budget fuse, without threading a single id by hand.
Requires node-sdk 0.12.0 or python-sdk 0.10.0a1
This surface ships in @bearlumen/node-sdk 0.12.0 and bearlumen 0.10.0a1 and later. Run npm install @bearlumen/node-sdk@latest or pip install -U bearlumen before using wrap, spans, outcomes, or budgets.
Everything is a track() event
track() is the only thing that ever reaches the API. Spans emit nothing themselves: they set async-local context that decorates the tracked calls made inside them, so the tree, the outcomes, and the retry attempts are all reconstructed from ordinary cost events. Each rung below is one small change, and each is independently valuable. Stop at any rung and you are on a fully supported path, not a degraded one.
| Step | You add | You get |
|---|---|---|
| 1 | wrap(client), one line | Every LLM call costed automatically: provider, model, tokens, cost. |
| 2 | A session id at your request boundary | Cost per run or per conversation, summed across every call inside it. |
| 3 | Spans around your agent functions | The full call tree: cost per branch, inherited attribution, outcomes, budgets. |
| 4 | traceContext() on queue messages | Trees that survive process boundaries, with honest retry accounting. |
Methods
| Method | Description |
|---|---|
bear.wrap(client) | Wrap a provider client once; every call through it is tracked automatically and inherits the ambient span context. |
bear.withSpan(name, opts?, fn) | Run a unit of work. Tracked calls inside share one session and link to their caller, reconstructing the call tree. |
bear.traced(name, opts?, fn) | The same span as a function wrapper / decorator. Prefer it where agent functions are defined. |
bear.setAttribute(key, value) | Add a metadata label to the active span mid-run. Applies to subsequent events in the span, not retroactively. |
bear.traceContext() | W3C trace context carrier for crossing a process boundary (queues, workers, webhooks): a traceparent, plus a tracestate entry when the session id is your own string. Resume with the parent span option. |
Spans
Wrap the code you own: the request handler, the agent function, the pipeline stage. Every tracked call inside (including calls made through a wrapped client, at any depth) shares one session and records who called whom. Context is async-local, so parallel branches under concurrent execution parent correctly with zero effort. Do not try to wrap a framework's internal loop; wrap your boundary around it and let the wrapped client capture the calls it makes.
// A span is a unit of work: every tracked call inside it (including calls
// through a wrapped client) shares one session and is linked to its caller,
// so the full call tree reconstructs. Spans nest freely, and context is
// async-local: parallel branches under Promise.all never cross-wire.
await bear.withSpan('resolve-ticket', { agentId: 'triage-agent' }, async () => {
await openai.chat.completions.create({ /* ... */ }); // child of resolve-ticket
await bear.withSpan('escalation-check', { agentId: 'escalator' }, async () => {
await openai.chat.completions.create({ /* ... */ }); // grandchild
});
});
// traced() is the same span as a function wrapper. Prefer it where you DEFINE
// your agent functions, so call sites stay clean.
const research = bear.traced('web-researcher', async (query: string) => {
return openai.chat.completions.create({ /* ... */ });
});
| Span option | Description |
|---|---|
agentId | The agent this span represents. Inherited by tracked calls inside; slice with costs.byAgent(). |
workflowId | The workflow TYPE (constant across every run). Set once at the root span and inherited by the whole session; slice with costs.byWorkflow(). |
kind | OpenTelemetry-aligned span kind: "agent", "tool", "llm", "retrieval", "task". Enables cost-by-kind roll-ups per workflow. |
sessionId | Join or resume an existing session (multi-turn conversations). Omit at the root and the span mints one. |
sessionType | Categorize the session. Set once at the root span. |
userId | Your end-user id. Inherited by tracked calls inside; powers per-user cost and margins. |
metadata | String labels inherited by every tracked call in the span and its children. The call site wins on conflict. Numeric dimensions never cascade. |
outcome | The unit of value this span produces. Counted exactly once when the span completes; not counted when it throws (cost still attributed). |
budget | Cost cap for the span: { usd, onExceeded: "throw" | "flag" }. The breaching call raises BudgetExceededError before executing. Budgets nest. |
unitKey | Stable identity across retries (a job id). Re-execution becomes attempt N of the same unit: cost sums, the outcome counts once. |
parent | A traceContext() carrier from another process. The span resumes that tree instead of starting a new one. Also accepts a bare W3C traceparent or { traceparent, tracestate }. Mutually exclusive with sessionId. |
experiment | A/B experiment { id, variant }, set once at the root span and inherited by every call in the session. The variant must stay constant per session. |
Attributes cascade (labels in, measures never)
metadata labels set on a span are inherited by every tracked call inside it and its children, with the call site winning on conflict. Numeric dimensions never cascade: they are measures you will sum, and copying them onto every call would multiply-count them. Declare a measure on the exact event it belongs to.
await bear.withSpan('resolve-ticket', {
sessionType: 'ticket',
userId: user.id,
metadata: { customer_tier: 'enterprise', environment: 'prod' }, // labels cascade
}, async () => {
await openai.chat.completions.create({ /* ... */ }); // carries tier + environment
// Learned something mid-run? Applies to events from here on (not retroactive).
bear.setAttribute('intent', 'refund');
bear.track(null, {
model: 'gpt-4o-mini',
metadata: { customer_tier: 'internal' }, // the call site wins on conflict
dimensions: { documents_generated: 3 }, // numeric measures NEVER cascade
});
});
Sessions that outlive a process
A root span normally mints its own session id. For work that spans requests and days, a multi-turn conversation being the canonical case, the session id is yours: pass the conversation id explicitly on each turn's span and the whole conversation reads back as one session.
// Long-lived sessions (multi-turn chat) outlive any one process, so pass
// the session id explicitly instead of letting the root span mint one.
// Each turn is a span inside the conversation-long session.
app.post('/chat/:conversationId', async (req, res) => {
await bear.withSpan('turn', {
sessionId: req.params.conversationId,
sessionType: 'support-chat',
}, async () => {
const reply = await openai.chat.completions.create({ /* ... */ });
res.json({ reply });
});
});
Outcomes: cost per unit of value
Token cost tells you what a call cost; it does not tell you what a resolved ticket or a delivered report cost. Declare the outcome on the span that produces it. Completion counts it exactly once, structurally, so there is no terminal-call bookkeeping and no way to inflate the denominator. Failure does not count it, but the money spent is still fully attributed.
// Declare the outcome on the span. If the function completes, the outcome is
// counted exactly once, structurally: there is no terminal call to remember
// and no way to double-count. If it throws, the outcome is NOT counted, but
// every dollar spent inside is still attributed, so the cost of failed work
// stays visible instead of pooling silently with the successes.
await bear.withSpan('resolve-ticket', { outcome: 'ticket_resolved' }, async () => {
// ...several agent calls, tool calls, sub-spans...
});
// Read it back: unit economics with success and failure side by side.
const perOutcome = await bear.costs.byOutcome({ lastDays: 30 });
// -> ticket_resolved: completed 412 ($389.20), failed 31 ($47.10)
Failed work is a first-class number
Cost with no outcome is the most actionable COGS signal an agentic product has: the run that hit its step cap, the pipeline that died at stage two, the branch that crashed. Because outcomes ride the span lifecycle, costs.byOutcome shows completed, failed, and unresolved units side by side instead of pooling failures into your averages. Even a run whose process died mid-flight reads back as an unresolved unit with its spend attached, rather than vanishing.
Budgets: stop the runaway, do not report it tomorrow
Because the wrapped client sees every call's usage as it happens, a span can enforce a cost cap in the call path. The budget is checked before every call against a conservative estimate of the pending request: the call that would breach it raises BudgetExceededError instead of executing, so overshoot is at most one call. Your loop or framework surfaces it like any other provider error.
// A budget turns tracking into control. The wrapped client keeps a running
// cost estimate inside the span; the call that would breach the cap throws
// BudgetExceededError instead of executing. A runaway loop stops at $0.50
// instead of running all night. Estimates are client-side and approximate
// (within a few percent); the billing-grade ledger stays server-side.
await bear.withSpan('deep-research', {
outcome: 'report_delivered',
budget: { usd: 0.5, onExceeded: 'throw' }, // or 'flag' to record and continue
}, async () => {
// ...supervisor and worker calls...
});
// Budgets nest: cap one worker branch inside a larger run budget.
await bear.withSpan('web-worker', { budget: { usd: 0.1, onExceeded: 'throw' } }, work);
Estimates guard, the ledger decides
Budget enforcement uses a client-side cost estimate from a bundled price table, refreshed from your organization's rate card at init. It is accurate to within a few percent, which is exactly what a circuit breaker needs. Your billing-grade cost ledger is always computed server-side from the delivered events.
Crossing process boundaries
Async-local context cannot follow a job onto a queue. The carrier can: attach traceContext() to the message and resume it on the consumer with the parent option. The carrier is standard W3C trace context: a traceparent, plus a tracestate entry when the session id is a string of your own. If your queue is already OpenTelemetry-instrumented the context is already flowing and the SDK picks it up without the explicit field.
// Producer: inside any span, hand the trace to the job. traceContext()
// returns a W3C trace context carrier, so an OTel-instrumented queue can carry
// it natively and consumers in any language can resume it.
await queue.add('embed-doc', { docId: doc.id, trace: bear.traceContext() });
// Consumer: a different process resumes the tree via parent. unitKey makes
// redelivery honest: the same key means attempt #2 of the SAME unit. Cost
// sums across attempts (real money was spent twice), the outcome counts
// once, and retry cost becomes queryable instead of invisible.
await bear.withSpan('embed-worker', {
parent: job.data.trace,
unitKey: job.id,
outcome: 'doc_embedded',
}, async () => {
await openai.embeddings.create({ /* ... */ });
});
Attributes do not ride the carrier
The carrier holds identity only (which trace, which parent). Metadata labels do not teleport through a queue: re-declare what the consumer knows on its own span, usually from the job payload it already has.
OpenTelemetry alignment
A Bear Lumen session maps to an OTel trace and a span to an OTel span. When an active OpenTelemetry span context exists, root spans adopt its ids instead of minting new ones (calls through wrap() inherit the enclosing span), so your cost tree lines up with the traces you already collect. The optional kindoption mirrors the OTel GenAI operation taxonomy ("agent", "tool", "llm", "retrieval"), and Bear Lumen never captures prompts or outputs: cost and structure only.