Decorators
@tracing.op traces every call of a function as a span: its bound arguments become the node’s input, its return
value the output, and an exception its ERROR status. Inside an open span the call becomes a child of it; with
nothing open it roots a trace of its own. Sync, async, generator and async generator functions all work.
import metranaimport metrana.tracing as tracingfrom metrana.tracing import SpanKind
@tracing.op # a FUNCTION node named "plan"def plan(task: str, depth: int = 2) -> list[str]: ...
@tracing.op(kind=SpanKind.TOOL_CALL) # bodies land as tool_input / tool_outputdef search(query: str) -> dict: ...
@tracing.op(name="policy", kind=SpanKind.LLM_CALL, postprocess_inputs=lambda a: {**a, "api_key": "***"})def call_model(messages: list, api_key: str) -> str: span = tracing.current_span() # the node this call is span.token_count = 512 span.set_attributes({"model": "claude-y"}) return ...
@tracing.opdef rollout(task: str) -> dict: # roots a trace when called at top level steps = plan(task) hits = search(task) answer = call_model([...]) tracing.current_span().set_attributes({"reward": score(answer)}) return {"answer": answer, "hits": hits["hits"]}
metrana.init(organization="my-org", workspace_name="my-team", project_name="agents", run_name="grpo-7b-v2")tracing.init()
with tracing.step(100): rollout("solve the maze") # a FUNCTION-rooted trace at step 100
tracing.close()metrana.close()What is recorded
Section titled “What is recorded”- Input: the call’s arguments, bound to their parameter names with defaults applied —
{"task": "solve the maze", "depth": 2}— as JSON. A method’sself/clsis left out. A call the signature cannot bind (a wrong arity, which will fail anyway) records the raw{"args": [...], "kwargs": {...}}and closesERROR. - Output: the return value, as JSON; text passes through. An output the function records itself — say
current_span().completion(text)beside itstoken_count— is kept, and the return value is not recorded over it. - Kind:
FUNCTIONby default, whose bodies are the genericinputandoutput. Anotherkind=lands them as that kind’s bodies, so a decorated model call reads as a prompt and completion in the portal. - Name: the function’s qualified name, or
name=.
Anything else — token counts, attributes, the status — goes through tracing.current_span() inside the function,
which is the node the current call is.
Shaping what is recorded
Section titled “Shaping what is recorded”Arguments are not always what you want stored: a secret, a tensor, a whole document. postprocess_inputs= is
applied to the bound-arguments dict before it is recorded and postprocess_output= to the return value; what they
return is what lands, and the caller still gets the real return value.
def without_tensors(arguments: dict) -> dict: return {k: v for k, v in arguments.items() if not hasattr(v, "shape")}
@tracing.op(postprocess_inputs=without_tensors, postprocess_output=lambda r: r["text"][:2000])def generate(model, prompt: str, logits) -> dict: ...Generators
Section titled “Generators”A decorated generator (or async generator) is one node for the whole iteration — a streamed model reply, say. The
node opens at the first pull, not at the call, so a generator that is never iterated records nothing; it is the
current span for the duration of each pull, so ops called from inside the body hang off it wherever the pulling
happens (another task, another thread); and it closes when the generator finishes (OK), raises (ERROR) or is
closed early by its consumer (CANCELLED). Its output is what it yielded: text chunks joined into one string,
anything else as a list.
@tracing.op(kind=SpanKind.LLM_CALL)def stream(messages: list): for event in client.stream(messages): yield event.delta # the completion is the joined deltasNesting and roots
Section titled “Nesting and roots”Decorated calls nest by the call stack: rollout calls plan, so plan’s node hangs off rollout’s. They
also nest under any with block that is open, and blocks nest under them, so the two styles mix freely.
A call with nothing above it roots a trace, the way a top-level call is a trace in other tracing tools. On a tracer
anchored to a run that trace needs the policy step, taken from the enclosing with tracing.step(n): block — a
top-level call outside any such block (setup, evaluation, a worker thread, which inherits no step) runs untraced,
with a warning once. On a project-anchored tracer (inference) it roots with no step.
@tracing.op(tracer=...) names the tracer a rooted call opens its trace on; calls inside an open span follow their
parent’s tracer regardless.
Where nothing traces
Section titled “Where nothing traces”Decorated code runs unchanged where there is nothing to record on: a library function decorated with @tracing.op
is called in processes that never initialize tracing, and METRANA_TRACING_DISABLED=1 switches tracing off without
touching the code. A rooted call then gets an inert node — a NoopSpan, with the whole span surface, that records
nothing — and everything under it is inert too. Nothing initialized and no run to derive from is warned about once
per process; the environment switch is silent. See
Lifecycle & reliability.