Skip to content

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.

Four things are required, and the event is dropped if any of them is missing:

  1. the route name, as the first positional argument
  2. the model that actually served the task
  3. a session key
  4. 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.

app/support.py
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,
)

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.

FieldTypeValid rangeWhat it says
task_completed / taskCompletedbooleanrequired, must be a booleanThe task the user asked for finished
feedback_score / feedbackScorenumber-1 to 1 inclusive, finiteExplicit or derived satisfaction. Use 1 for a thumbs up, -1 for a thumbs down
turns_to_resolution / turnsToResolutioninteger1 to 1,000,000How many exchanges it took
escalatedbooleanboolean if presentThe task left the model and went to a person or a stronger path
abandonedbooleanboolean if presentThe user walked away before the task finished
edit_distance_ratio / editDistanceRationumber0 to 1 inclusive, finiteHow much of the model’s output the user rewrote. 0 means untouched
regeneration_count / regenerationCountinteger0 to 1,000,000How many times the user asked for another attempt
event_id / eventIdstringup to 128 charactersYour 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 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.

  1. Everything captured inside the context inherits the session id.

    with metergraph.context(session_id=ticket_id):
    ...

    metergraph.session(ticket_id) and mg.withSession(ticketId, fn) are narrower forms of the same thing.

  2. With the context open, session_key can be omitted and the context’s session id is used. Passing session_key explicitly 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,
    )
  3. 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()

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.

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.