Skip to content

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 metrana
import metrana.tracing as tracing
from 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_output
def 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.op
def 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()
  • Input: the call’s arguments, bound to their parameter names with defaults applied — {"task": "solve the maze", "depth": 2} — as JSON. A method’s self / cls is left out. A call the signature cannot bind (a wrong arity, which will fail anyway) records the raw {"args": [...], "kwargs": {...}} and closes ERROR.
  • Output: the return value, as JSON; text passes through. An output the function records itself — say current_span().completion(text) beside its token_count — is kept, and the return value is not recorded over it.
  • Kind: FUNCTION by default, whose bodies are the generic input and output. Another kind= 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.

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:
...

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 deltas

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.

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.