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.
| Label | Answers | Set by |
|---|---|---|
| Function | Which code made this call? | Automatic in Python, track() elsewhere |
| Route | Which product surface is this call for? | route(), always by you |
| Trace | Which calls were part of the same piece of work? | trace(), or automatic per call |
| Session | Which user or job is this? | session() or context() |
| Tags | Anything else you want to slice by | tags(), 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.
Function
Section titled “Function”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 namedef classify(ticket): ...
@metergraph.track("billing.summarize") # pinned, survives a renamedef summarize(invoice): ...export const classify = mg.track("billing.classify", (ticket) => ...);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): ...await mg.route("ticket-classifier", () => client.chat.completions.create({ ... }));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.
When you do not set one
Section titled “When you do not set one”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.
Naming routes
Section titled “Naming routes”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.
| Name | Verdict | Why |
|---|---|---|
ticket-classifier | Good | Names the job. Survives every refactor of the function that does it. |
checkout-summary | Good | A product surface. You can say what regressed when it regresses. |
weekly-digest | Good | One workload, one cadence, one thing to compare against last week. |
rag-answer | Good | Names the task, not the stack that currently implements it. |
app.services.tickets.classify | Bad | That is the function name. You already have that column, and it moves with the code. |
gpt-5.6-luna | Bad | Names the model. Swapping it erases the history that justified swapping it. |
user-8412 | Bad | Unbounded cardinality. That is a session ID. |
prod | Bad | That is an environment, and there is a field for it. |
llm-call | Bad | Every call is an LLM call. One giant route hides the differences you are looking for. |
v2-ticket-classifier | Bad | Versions the name. Keep the route and let the model or prompt be the thing that changed. |
/api/v1/tickets/8412/classify | Bad | A 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()await mg.trace("checkout", async () => { await plan(); await retrieve(); await 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.
Session
Section titled “Session”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"}): ...await mg.withSession(sessionId, async () => { ... });
await mg.withContext({ sessionId, tags: { plan: "pro" } }, async () => { ... });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"): ...await mg.withTags({ customerTier: "enterprise", region: "eu" }, async () => { ... });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.
How they compose
Section titled “How they compose”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_replyawait mg.withContext({ sessionId: user.id, tags: { plan: user.plan } }, () => mg.trace("support-reply", async () => { const topic = await mg.route("ticket-classifier", () => classify(ticket)); const draft = await mg.route("reply-drafter", () => writeReply(ticket, topic)); }));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.
How the scope reaches the call
Section titled “How the scope reaches the call”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.