Attribution model
The six fields every event carries, how nested scopes resolve, and why attribution stays isolated across threads and asyncio tasks.
Fields
| Field | Set by | Answers |
|---|---|---|
agent | @agent(), tag(), or agent_name= in init() | Which logical unit spent this |
team | the same | Which team owns the spend |
task_type | the same | What kind of work it was |
session_id | set_session() or tag() | Which conversation or request it belonged to |
customer_id | @agent() or tag() | Which of your customers to bill |
cost_center | the same | Which finance budget line |
Every event also carries the model, provider, token counts, cost, latency, environment, and the file and line that made the call. You do not supply any of these.
agent has a fallback. A call made with no attribution in scope is recorded as
unknown. It is never dropped, because an unattributed cost is still a cost.
Nested scopes resolve per field
@capsera.agent("pipeline", team="research")
def run(question):
plan = make_plan(question) # agent=pipeline, team=research
return summarize(plan)
@capsera.agent("summarizer") # no team set
def summarize(text):
... # agent=summarizer, team=research
summarize overrides agent but sets no team, so team still comes from the
enclosing scope. Fields resolve one at a time, so an inner decorator can refine
attribution without restating what its caller established.
Isolation under concurrency
The context stack uses contextvars, not threading.local.
Threads. Each thread has its own stack. Eight workers in a ThreadPoolExecutor
with different attribution produce eight correctly attributed streams.
asyncio tasks. contextvars propagate into tasks at creation and stay isolated
per task, so asyncio.gather over ten differently tagged coroutines keeps their
attribution separate. threading.local would not, because every task on one event
loop shares a thread.
The failure this prevents is one agent's spend appearing on another agent's line. The SDK's test suite asserts zero context bleed under both models, and the evaluation harness treats any bleed as a run failure.
Choosing between the three APIs
Use a decorator when the unit of work is a function. This covers most cases, including graph nodes and agent classes.
Use a context manager when the unit is narrower than a function or is determined at runtime, such as one branch of a handler or one iteration of a per-customer loop:
with capsera.tag("enricher", customer_id=customer.id):
...
Use a session for a label that should apply to everything downstream until cleared, such as a request ID set in middleware:
capsera.set_session(request_id)
See Agents and Tags and sessions for details.
Limits
Attribution does not cross a process boundary. If your agents run as separate
services, each process needs its own init() and its own attribution. Nothing
propagates over HTTP automatically. To correlate spend across services, pass a
session_id between them yourself.