Guides · Concepts
Cost attribution & custom dimensions
Bear Lumen groups cost by whatever you tag each event with. A little thought at track() time turns raw token cost into the numbers your business actually runs on: cost per outcome, per feature, per customer, per merged PR.
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.
How attribution works
Every usage event carries a few built-in attribution fields plus two open-ended bags: metadata for categorical tags and dimensions for numeric ones. There is no schema to define first. Any key you send becomes a queryable dimension automatically, so the taxonomy is entirely yours.
| Kind | Where it goes & how to query | Examples |
|---|---|---|
| Built-in | Dedicated event fields, usually set by spans. Query with attribution.byDimension / byAgent / byWorkflow / sessions / sessionTree / costs.byOutcome. | feature, agentId, workflowId, userId (end user), sessionId / sessionType (the run), spanId / parentSpanId (the tree), outcome, model, provider, experiment. |
| Custom categorical | metadata: {}, any string key. Group by value with attribution.byDimension({ dimension }). Labels set on a span cascade to every call inside it. | environment, customer_tier, git_pr, intent, region. |
| Custom numeric | dimensions: {}, number values. Aggregate with dimensions.numericSummary (SUM / AVG / MIN / MAX). | tickets_resolved, documents_generated, energy_kwh. |
Trace multi-step work (agents & workflows)
One outcome rarely comes from one AI call. Resolving a ticket might run a triage agent, a retrieval agent, and a drafting agent, several calls in one unit of work. To get cost per unit right, you group all the calls that belong to the same run. Spans do this for you (see Tracing agentic work); underneath, four built-in fields carry the structure, and they answer different questions:
| Field | Grain | Answers |
|---|---|---|
workflowId | The workflow type. Constant across every run. | “What does my ticket-triage workflow cost in total?” via costs.byWorkflow(). |
sessionId | One run / trace. A single unit of work spanning many calls. A root span mints it for you. | “What did resolving THIS ticket cost across all its agents?” via attribution.sessions() / sessionDetail(). |
agentId | One step inside a run. | “Which agent is the expensive one?” via costs.byAgent(). |
spanId / parentSpanId | One node and edgein the run's call tree. Set automatically by nested spans. | “Which branch of this run is burning the money?” via attribution.sessionTree(id). |
sessionId is the trace id, and spans set it for you
sessionId is the field Bear Lumen groups traces on. Wrap a run in withSpan() and every tracked call inside shares it automatically; attribution.sessions() then sums the whole run as one row, while sessionDetail(id) lists every call inside it. An optional sessionType (constant per run) categorizes traces so you can filter and compare them. A Bear Lumen session maps 1:1 to an OpenTelemetry trace, and the SDK adopts an active OTel context when one exists.
Agentic patterns
Wrapping the client plus a span at your work boundary covers the common agentic architectures. The recipe is the same idea every time: wrap() captures the cost of every call no matter who makes it, and the span carries the structure that answers the question you care about.
| Pattern | Tag each call with | Read it back |
|---|---|---|
| Single-agent loop (reason → act → observe) | wrap(client) + one withSpan per run with outcome. Framework loops: the wrapped client captures every internal step; add the framework's step hook for per-iteration events. | attribution.sessionDetail(id); event-count outliers flag runaway loops |
| Sequential pipeline (agent A → B → C) | a root span with workflowId + outcome, one child span per stage with agentId | costs.byWorkflow(), costs.byAgent(); costs.byOutcome() surfaces runs that died mid-pipeline |
| Parallel fan-out / map-reduce | child spans inside Promise.all branches. Context is async-local, so concurrent branches parent correctly with no id threading. | attribution.sessionTree(id) (sibling groups aggregate: 38 map spans, total, max) |
| Hierarchical (supervisor → workers, recursion) | nested spans; recursion nests for free. Manual spanId / parentSpanId on track() is the escape hatch. | attribution.sessionTree(id): own cost vs subtree cost per branch (see below) |
| Multi-turn conversation | explicit sessionId = the conversation id on each turn's span, sessionType to categorize | attribution.sessionComparison(); per-turn spans show where long conversations get expensive |
| Tool / non-LLM step (search, code-exec, TTS) | track(null, { units | costOverride }) inside the current span, kind: 'tool'. Track leaves, never wrap already-tracked subtrees, or the cost double-counts. | folds into the session total and the tree |
| A/B agent strategy | experiment: { id, variant } once on the root span, inherited by the whole session. Assign the variant before the first call and keep it constant per run. | costs.byOutcome() per variant: cost per outcome is the honest A/B readout, cost alone favors the worst agent |
| Queue / cross-process handoff | traceContext() on the message; parent + unitKey on the consumer span so redelivery counts as attempt N, not a new unit | attribution.sessionTree(id); retry cost = units with attempts > 1 |
Hierarchical agents (nested spans)
A supervisor that delegates to workers, which spawn their own sub-workers, is a tree, not a flat list. Nested spans preserve who called whom without any id plumbing: each span records its caller, recursion nests naturally, and the tree reads back with cost per branch, not just per run.
// Hierarchical agents: a supervisor delegates to workers, which may spawn
// their own sub-workers. Nest spans and the tree builds itself: each span
// links to its caller, recursion included, and parallel branches under
// Promise.all parent correctly because context is async-local.
const openai = bear.wrap(new OpenAI());
async function runAgent(name: string, task: Task): Promise<Result> {
return bear.withSpan(name, { agentId: name, kind: 'agent' }, async () => {
const plan = await openai.chat.completions.create(planFor(task)); // tracked, tree-linked
const results = await Promise.all(
delegationsIn(plan).map((t) => runAgent(t.agent, t)) // children of THIS span
);
return synthesize(plan, results);
});
}
await bear.withSpan('deep-research', {
workflowId: 'deep-research',
outcome: 'report_delivered',
budget: { usd: 2.0, onExceeded: 'throw' }, // the whole tree stops at $2
}, () => runAgent('supervisor', rootTask));
// No spans available at a call site? track() accepts the tree fields
// directly: sessionId, spanId, parentSpanId. Same wire, manual edges.
Own cost vs subtree cost
attribution.sessionTree(id)reports both numbers for every node: the span's own tracked cost (the supervisor's planning calls) and its subtree roll-up (everything it delegated). Calls tracked without span context still land in the session as a visible untraced bucket, and branches whose parent never arrived are flagged as orphans rather than dropped, so the tree total always equals the flat session total. Partial instrumentation degrades gracefully instead of lying.
Recipe: outcome-based COGS
Token cost tells you what a call cost. It does not tell you what a unit of value cost. If your product resolves support tickets, generates documents, or closes deals, the number that matters is cost per resolved ticket, per document, per deal. Declare the outcome on the span that produces it and the platform computes that division for you, failed runs included.
1. Wrap the run in a span that declares its outcome
// One resolution usually runs SEVERAL AI calls (triage, retrieval, drafting),
// often across multiple agents. Wrap the resolution in a SPAN: every call
// made inside it (through your wrapped client) shares one session
// automatically, and the outcome is counted exactly once when the span
// completes. No trace id to mint, no terminal call to remember, no way to
// overcount. If the run throws, the outcome is NOT counted but every dollar
// spent inside is still attributed, so failed work stays visible.
const openai = bear.wrap(new OpenAI()); // wrap once, at startup
await bear.withSpan('resolve-ticket', {
workflowId: 'ticket-triage', // the workflow TYPE (constant across runs)
sessionType: 'ticket',
outcome: 'ticket_resolved', // the denominator, counted structurally
}, async () => {
await bear.withSpan('retriever', { agentId: 'retriever' }, async () => {
await openai.chat.completions.create(/* ... */); // tracked + tree-linked
});
await bear.withSpan('drafter', { agentId: 'drafter' }, async () => {
await openai.chat.completions.create(/* ... */);
});
});
2. Read cost per outcome
// Numerator and denominator in ONE query. Because cost and outcomes live on
// the same unit of work, the platform computes cost per outcome natively,
// with failed units side by side instead of hiding inside the averages.
const perOutcome = await bear.costs.byOutcome({ lastDays: 30 });
const resolved = perOutcome.items.find((o) => o.outcome === 'ticket_resolved');
// TRUE COGS per resolved ticket: full multi-agent cost / resolutions.
// Not cost per token, and not the cost of a single call.
console.log(resolved.completed_count, resolved.cost_per_completed_unit);
// -> 412 0.94 (each resolved ticket cost $0.94 of AI spend, all agents in)
// The number the observability tools cannot show you: what failure costs.
console.log(resolved.failed_count, resolved.failed_cost);
// -> 31 47.10 (31 runs burned $47.10 and resolved nothing)
// Drill into one expensive run: the full call tree, cost per branch.
const tree = await bear.attribution.sessionTree(sessionId);
tree.roots.forEach((n) => console.log(n.name, n.own_cost, n.subtree_cost));
This is your real unit economics
Cost per outcome is the COGS figure you price against and watch for drift. A model that is cheaper per token but needs three agent calls and two retries to resolve a ticket can be more expensive per resolved ticket. Only the full run cost divided by delivered outcomes surfaces that. Because the outcome rides the span lifecycle, it is counted exactly once per run by construction, and the cost of runs that failed stays visible as its own line instead of quietly inflating your averages.
Recipe: developer & feature-building costs
AI spend during development is real COGS too, and it is usually invisible. Tag the calls you make while building, and you can answer “what did this feature cost to build?” and “which merged PR burned the budget?”
1. Tag by environment, feature, and code
// Tag calls made while building a feature.
bear.track(response, {
feature: 'search-rerank',
metadata: {
environment: 'dev', // isolate development spend from production
git_pr: '1421', // the PR this work landed in
git_branch: 'feat/rerank',
developer: 'alice',
},
});
2. Attribute the spend
// Cost of building each feature, in development only.
const byFeature = await bear.attribution.byDimension({
dimension: 'feature',
filterKey: 'environment',
filterValue: 'dev',
lastDays: 30,
});
// Cost attributable to each merged PR, or each developer.
const byPr = await bear.attribution.byDimension({ dimension: 'git_pr', lastDays: 30 });
const byDeveloper = await bear.attribution.byDimension({ dimension: 'developer', lastDays: 30 });
Associate merged code with its cost
Tagging git_pr (or a commit SHA) at track() time is what lets you tie AI spend back to specific merged code. Emit the tag from CI or your app config so it is set automatically. Filter on environment to keep build-time spend separate from production.
Workflow & agent cost
The same tags answer the aggregate questions too. Group by the workflow type to see what each workflow costs you, by agent to find the expensive step, or by trace to cost a single run.
// Cost per workflow TYPE (every run of each workflow, all agents inside it).
// lastDays resolves client-side to a trailing window; absolute dates still
// work for a fixed period: byWorkflow({ startDate: '2026-01-01', endDate: '2026-01-31' }).
const byWorkflow = await bear.costs.byWorkflow({ lastDays: 30 });
// Cost per AGENT, within or across workflows.
const byAgent = await bear.costs.byAgent({ lastDays: 30 });
// Per-RUN cost: each session is one run/trace. sessions() returns each
// run's total (summed over all its calls); sessionDetail() lists a single
// run call-by-call; sessionTree() reconstructs its call tree with
// per-branch roll-ups.
const runs = await bear.attribution.sessions({ lastDays: 7 });
runs.sessions.forEach((s) => console.log(s.session_id, s.total_cost, s.event_count));
Type vs run
workflowId is the workflow type (every run of ticket-triage). sessionId is one run of it. Use costs.byWorkflow() for the type-level total and attribution.sessions() for per-run cost. Divide one by the other for average cost per run.
Designing your dimensions
Custom attributes are only as useful as they are consistent. A few principles keep them clean:
- Decide the taxonomy up front. A typo is a new dimension value, so agree on key names (
git_pr, notprandprNumber) before you instrument. - Categorical for grouping, numeric for rates. Put anything you will divide cost by (counts of outcomes, units produced) in
dimensions; put anything you will group by inmetadata. - Keep categorical cardinality low. Tags with a bounded set of values (tiers, environments, features) group cleanly. A unique id per event is not a dimension.
- Trace multi-call work with a span, not an id. If a unit of work spans several calls, wrap it in
withSpan(): the session, the tree, and the once-per-run outcome come structurally instead of by discipline. Reach for a manualsessionIdonly where a span cannot wrap the work (long-lived conversations). - Tag at the call site. The context you need (feature, user, PR) is richest at
track()time. You cannot backfill a tag you did not send.
Next steps
Tracing agentic work
The span API behind these recipes: wrap(), withSpan(), outcomes, budgets, and cross-process carriers.
Reading your costs
The attribution and dimension query methods, in Node.js and Python.
REST API Examples
Tag events and query attribution over plain HTTP.
How it works
The lifecycle of a usage event and the building blocks this guide builds on.