Record real outcomes
Capture tells you what a call cost. It cannot tell you whether the work the
call was doing succeeded. record_outcome() closes that gap: you report, once
per finished task, whether the task completed and how well it went. The event
is content-free by construction. It carries counts, flags and scores, never
text.
Once outcomes and calls share a session key, the two can be divided into each other, and the number you get is cost per completed task rather than cost per call. Those are very different numbers for anything that retries, escalates or regenerates.
What the call needs
Section titled “What the call needs”Four things are required, and the event is dropped if any of them is missing:
- the route name, as the first positional argument
- the model that actually served the task
- a session key
task_completed, which must be a real boolean
Everything else is optional. Anything out of range is rejected and the function
returns false rather than raising.
import metergraph
with metergraph.context(session_id=ticket_id): reply = client.chat.completions.create(...)
metergraph.record_outcome( "ticket-classifier", model="gpt-5.6-luna", task_completed=True, feedback_score=0.8, turns_to_resolution=3, escalated=False, abandoned=False, edit_distance_ratio=0.12, regeneration_count=1, )import * as mg from "metergraph";
await mg.withContext({ sessionId: ticketId }, async () => { const reply = await client.chat.completions.create(...);
mg.recordOutcome("ticket-classifier", { model: "gpt-5.6-luna", taskCompleted: true, feedbackScore: 0.8, turnsToResolution: 3, escalated: false, abandoned: false, editDistanceRatio: 0.12, regenerationCount: 1, });});Neither example passes a session key. Both are inside a Metergraph context, and the session key falls back to the context’s session id. See the session key below.
The signals
Section titled “The signals”| Field | Type | Valid range | What it says |
|---|---|---|---|
task_completed / taskCompleted | boolean | required, must be a boolean | The task the user asked for finished |
feedback_score / feedbackScore | number | -1 to 1 inclusive, finite | Explicit or derived satisfaction. Use 1 for a thumbs up, -1 for a thumbs down |
turns_to_resolution / turnsToResolution | integer | 1 to 1,000,000 | How many exchanges it took |
escalated | boolean | boolean if present | The task left the model and went to a person or a stronger path |
abandoned | boolean | boolean if present | The user walked away before the task finished |
edit_distance_ratio / editDistanceRatio | number | 0 to 1 inclusive, finite | How much of the model’s output the user rewrote. 0 means untouched |
regeneration_count / regenerationCount | integer | 0 to 1,000,000 | How many times the user asked for another attempt |
event_id / eventId | string | up to 128 characters | Your own idempotency key. A UUID is generated when you omit it |
route_name and model are trimmed to 512 characters. A float value that is
NaN or infinite is rejected. In Python, True is a valid bool but not a
valid int, so a boolean passed to turns_to_resolution is rejected rather
than counted as 1.
The session key
Section titled “The session key”The session key is the join. Calls carry the session id from their surrounding context; the outcome carries the session key you record. Anything that does not match is a call with no outcome, or an outcome with no calls.
Pick a key that already exists in your domain and is stable for the whole life of the task:
- a support ticket id
- a background job id
- a conversation or thread id
- a checkout or order id
Avoid a per-request id, a per-call id, or anything you regenerate on retry. Those give every call its own session and make the ratio meaningless. Avoid a raw user id too, unless one user genuinely has one task. Keys are trimmed to 512 characters.
-
Open a context around the work
Section titled “Open a context around the work”Everything captured inside the context inherits the session id.
with metergraph.context(session_id=ticket_id):...await mg.withContext({ sessionId: ticketId }, async () => {...});metergraph.session(ticket_id)andmg.withSession(ticketId, fn)are narrower forms of the same thing. -
Record the outcome inside that context
Section titled “Record the outcome inside that context”With the context open,
session_keycan be omitted and the context’s session id is used. Passingsession_keyexplicitly overrides it, which is what you want when the outcome is reported from somewhere else: a webhook, a feedback endpoint, a nightly job.app/feedback.py # No surrounding context here, so the key is passed explicitly.metergraph.record_outcome("ticket-classifier",model=model_used,task_completed=resolved,session_key=ticket_id,feedback_score=1.0 if thumbs_up else -1.0,) -
Make sure the event is delivered
Section titled “Make sure the event is delivered”Outcomes ride the same background queue as captured calls, so a process that exits immediately after recording one can lose it. In a short-lived process, flush before you return.
metergraph.flush()await mg.flush();
Record the model that ran, not the model you asked for
Section titled “Record the model that ran, not the model you asked for”model should be the model that actually served the task. If your code picks a
model at runtime, capture the value it picked and pass that same value to
record_outcome(). An outcome attributed to a model that did not run makes
every per-model comparison wrong in a way nothing downstream can detect.
One outcome per task
Section titled “One outcome per task”Record once, when the task reaches a terminal state. If you record twice for
one task, you have two outcomes and one session, and the completion rate is no
longer a rate. Use event_id when your delivery path can fire twice: outcomes
are unique per workspace and event_id, so a repeat with the same id is
deduplicated on the way in.
See also
Section titled “See also”- Python SDK reference for the full signature
- TypeScript SDK reference
- Serverless and short-lived jobs for flushing
- Write your own client to send outcome rows without the SDK