Dashboard →

Guides

Attribution

Token counts alone tell you what you spent. Attribution tells you what it cost to serve any one customer, feature, or team, and that split doesn't exist anywhere in the provider's own data.

Why this matters

OpenAI's invoice, Anthropic's usage export, Bedrock's Cost Explorer line: none of them have a customer field. They know an API key spent $4.02 on gpt-4.1 today. They have no idea whether that was one customer running a heavy report or a thousand customers each sending one message. Without attribution captured at call time, per-customer margin is not a hard problem. It's an impossible one, because the information was never recorded anywhere.

Attribution has to happen at the call, not after

There is no way to reconstruct customer/feature/team after the fact from provider billing data. If a call goes out untagged, that spend is permanently unattributed: it still shows up (Token Meter never hides unattributed spend from a total), but it can't be split by customer margin.

The tags

Every wrapped call made inside an action() scope is stamped with these fields, plus a trace_id / parent_span_id pair so nested actions form a span chain.

TagPython kwargTypeScript fieldMeaning
actionname (positional)name (positional)A short label for what the call is doing, e.g. "generate-report".
customer_refcustomer_id / customer_refcustomerRefThe end customer this call serves: the field that makes per-customer margin possible.
featurefeaturefeatureThe product surface making the call, e.g. "reports".
teamteamteamThe internal team that owns this call path.
environmentenvironmentenvironmente.g. "prod" / "staging": keeps test traffic out of real margin numbers.
projectprojectprojectA cross-team grouping: several teams can share one project.
member_refmember_refmemberRefThe person making the call (their email, matching their Token Meter account) for per-member usage.
tagsn/atagsFree-form extra key/values, merged from outer to inner scopes (TypeScript only).

Python's action() accepts either customer_id or customer_ref as the same kwarg. Both spellings show up across the docs and either works.

action.ts
await action(
  "generate-report",
  { customerRef: "acme", feature: "reports", team: "growth", environment: "prod" },
  async () => {
    await openai.chat.completions.create({ ... });
  },
);

Nested action() calls form a span chain: the inner scope's parent_span_id chains to the outer one's span_id, and any tag not passed at the inner level inherits from whichever enclosing scope set it, innermost wins.

Context propagation

action() is backed by native async-local context (Python's contextvars, TypeScript's AsyncLocalStorage) so it propagates automatically through the cases that matter most:

  • Async fan-out: asyncio.gather/create_task in Python, Promise.all in TypeScript, all inherit the enclosing context.
  • Nested scopes: a nested action() inherits unset tags from its parent.
  • Streams handed off to be iterated later: the context is snapshotted at call time, not read lazily, so it survives the stream outliving the scope it was opened in.

What it can't see across

Real OS thread boundaries and serialize hops break automatic propagation: Python's concurrent.futures.ThreadPoolExecutor.submit does not auto-propagate context, and neither language's async-local storage crosses a queue/worker or message-broker hop. Those are the two cases the escape hatch below exists for.

current() / attach()

For any hop context can't see across on its own (a thread pool, a queue publish/consume boundary), capture the active context with current() on the producing side, then restore it with attach() on the consuming side. Python's TraceContext is a plain dataclass of strings, so it serializes straight into a queue payload.

escape-hatch.ts
// producing side
const ctx = current(); // ActionContext | undefined

// consuming side (e.g. inside a worker picked up off a queue)
if (ctx) {
  attach(ctx, () => {
    return openai.chat.completions.create({ ... }); // picks up the restored context
  });
}