Skip to content

The identity model

A captured call is not interesting on its own. What makes it useful is knowing what it was for, and Metergraph answers that with five independent labels. They are independent on purpose. Each answers a different question, and none of them is derivable from the others.

LabelAnswersSet by
FunctionWhich code made this call?Automatic in Python, track() elsewhere
RouteWhich product surface is this call for?route(), always by you
TraceWhich calls were part of the same piece of work?trace(), or automatic per call
SessionWhich user or job is this?session() or context()
TagsAnything else you want to slice bytags(), context()

There is also environment, which is a plain field rather than a scope: set METERGRAPH_ENV or pass environment= to init() and every row from that process carries it. Use it to keep staging out of your production numbers, and do not smuggle it into a route name.

The function is the call site: the code that actually invoked the model.

In Python it is free. At call time the SDK walks the stack to the nearest frame under your application root, skipping site-packages, its own frames, thread-pool plumbing and anything you named in skip_frames. You get module:function without writing anything.

That name moves when you rename or move the function, which is fine for browsing and bad for a series you want to compare over months. Pin it with track:

@metergraph.track # uses the real function name
def classify(ticket): ...
@metergraph.track("billing.summarize") # pinned, survives a rename
def summarize(invoice): ...

In TypeScript, stack attribution is only a best-effort fallback, because a bundler will have renamed your functions to single letters long before the SDK sees them. Put the stable name first and wrap every function that calls a wrapped client.

The route is the product surface. It is the only label Metergraph will never guess for you, and the most valuable one you can set.

route() opens a scope. Every wrapped provider call made inside it, at any depth, through any number of intermediate functions, is attributed to it. It works as a context manager and as a decorator in Python, and takes a callback in TypeScript.

with metergraph.route("ticket-classifier"):
client.chat.completions.create(...)
@metergraph.route("checkout-summary")
def summarize_cart(cart): ...

A route scope can also carry tags, a capture_text override for a sensitive surface, and a unit with a unit_count when you want cost per unit of work rather than cost per call: unit="ticket" on a route that handles a batch of forty tickets gives you a denominator the raw call count cannot.

The row still arrives. The server falls back to the call’s template_hash, a content-free fingerprint of the request’s structure, and files it under template:<hash>. The dashboard shows that as Unlabeled template plus the first eight characters of the hash.

That is a useful default and a bad destination. Traffic clusters correctly but the cluster has no meaning, an analysis cannot tell you which product surface it just evaluated, and two unrelated surfaces that happen to share a prompt shape end up in the same bucket.

The comparison every part of the product makes is route against route over time. A name that changes when the code changes destroys the series, and a name with unbounded cardinality produces thousands of groups of one.

NameVerdictWhy
ticket-classifierGoodNames the job. Survives every refactor of the function that does it.
checkout-summaryGoodA product surface. You can say what regressed when it regresses.
weekly-digestGoodOne workload, one cadence, one thing to compare against last week.
rag-answerGoodNames the task, not the stack that currently implements it.
app.services.tickets.classifyBadThat is the function name. You already have that column, and it moves with the code.
gpt-5.6-lunaBadNames the model. Swapping it erases the history that justified swapping it.
user-8412BadUnbounded cardinality. That is a session ID.
prodBadThat is an environment, and there is a field for it.
llm-callBadEvery call is an LLM call. One giant route hides the differences you are looking for.
v2-ticket-classifierBadVersions the name. Keep the route and let the model or prompt be the thing that changed.
/api/v1/tickets/8412/classifyBadA URL with an ID in it. If you must use paths, template them: /api/v1/tickets/:id/classify.

Keep route count in the tens. It should track your product surfaces, not your traffic.

A trace groups the calls that belong to one piece of work. A checkout that plans, then retrieves, then drafts, then checks is four provider calls and one trace.

Without trace(), each call gets its own trace ID, which is correct and not very useful. With it, the whole workflow gets a shared ID and a name.

with metergraph.trace("checkout"):
plan()
retrieve()
draft()

Nested trace() calls reuse the active trace rather than starting a new one, so a helper that opens its own trace does not fragment a workflow that already started one. Passing an explicit trace_id is how you deliberately start a different trace, and how you join work across a process boundary: hand the ID and a parent_span_id to the other side and its spans attach to the same tree. That is the mechanism behind attribution across threads.

A session ties a series of calls to one user, one conversation, or one job. It is what makes a multi-turn conversation legible as a conversation, and it is the stickiness key that keeps a session on one side of a canary instead of flipping between arms mid-conversation.

with metergraph.session(session_id):
...
with metergraph.context(session_id=session_id, tags={"plan": "pro"}):
...

Tags are your own string key-value labels, merged into every call made inside the scope. Inner scopes merge over outer ones rather than replacing them.

with metergraph.tags(customer_tier="enterprise", region="eu"):
...

set_default_tags() sets process-wide tags inherited by new scopes. Reserve it for deliberate service-level labels like service=api. It is not a substitute for a route and not a place for anything per-request.

Keep tag values low-cardinality for the same reason as routes. A tag with one value per request is not a dimension, it is a session ID in the wrong field.

The five labels nest independently, and that is the point. One route serves thousands of sessions. One trace can span several routes. One function can be called from two routes and should be, if two product surfaces genuinely share that code.

with metergraph.context(session_id=user.id, tags={"plan": user.plan}):
with metergraph.trace("support-reply"):
with metergraph.route("ticket-classifier"):
topic = classify(ticket) # function: tickets:classify
with metergraph.route("reply-drafter"):
draft = write_reply(ticket, topic) # function: replies:write_reply

Two provider calls, one trace, one session, two routes, two functions, one set of tags. Each of the five answers a question the others cannot.

Context propagates through contextvars in Python and AsyncLocalStorage in Node. Both follow await and both follow ordinary function calls, so you never have to thread an argument through.

Neither follows work handed to a thread pool. In Python, pass the executor through metergraph.wrap_executor() and submitted work inherits the route, trace, session and tags that were active at submission time. Without it, calls made on the pool arrive with no scope at all and fall back to the template fingerprint.