Skip to main content
Use wrap_agent as a context manager when you want to add tracing to code that already exists without restructuring it. Drop a with block around the body you want to trace — no function extraction required.
This pattern is Python-specific. For TypeScript, use the callable wrapper pattern in Async agent or Sync agent.

Async agent

from uselemma_tracing import register_otel, wrap_agent

register_otel()


async def handle_request(user_message: str) -> str:
    async with wrap_agent("my-agent", input=user_message) as run:
        response = await call_llm(user_message)
        run.on_complete(response)
    return response

Sync agent

from uselemma_tracing import register_otel, wrap_agent

register_otel()


def handle_request(user_message: str) -> str:
    with wrap_agent("my-agent", input=user_message) as run:
        response = call_llm(user_message)
        run.on_complete(response)
    return response

Accessing run_id after the block

The as run variable stays in scope after the block exits:
async with wrap_agent("my-agent", input=user_message) as run:
    response = await call_llm(user_message)
    run.on_complete(response)

# Both available here:
print(run.run_id)
print(run.span)

Tool-calling loop

For multi-turn loops, put the entire loop inside the block. The span stays open for all turns and closes when the block exits.
import json
from opentelemetry import trace
from uselemma_tracing import register_otel, wrap_agent

register_otel()

tracer = trace.get_tracer("my-agent")


async def handle_request(user_message: str) -> str:
    async with wrap_agent("my-agent", input=user_message) as run:
        history = [{"role": "user", "content": user_message}]

        while True:
            response = await call_model(history)

            if response.stop_reason == "end_turn":
                run.on_complete(response.text)
                return response.text

            for tool_call in response.tool_calls:
                with tracer.start_as_current_span("tool.call") as span:
                    span.set_attribute("tool.name", tool_call.name)
                    span.set_attribute("tool.args", json.dumps(tool_call.args))
                    result = await run_tool(tool_call.name, tool_call.args)
                    span.set_attribute("tool.result", str(result))
                history.append({"role": "tool", "content": result})

Error handling

Unhandled exceptions are recorded on the span automatically — no try/except needed for the common case:
async with wrap_agent("my-agent", input=user_message) as run:
    response = await call_llm(user_message)  # if this raises, error is recorded automatically
    run.on_complete(response)
To record an error without re-raising, call run.record_error manually:
async with wrap_agent("my-agent", input=user_message) as run:
    try:
        response = await call_llm(user_message)
        run.on_complete(response)
    except SomeRecoverableError as err:
        run.record_error(err)
        run.on_complete("fallback response")

What happens if on_complete is not called

The span ends cleanly when the block exits, but ai.agent.output is not set. The run appears in the dashboard without an output value. Call run.on_complete(result) before the block exits to record the output.

Callable wrapper vs context manager

Callable wrapperContext manager
Syntaxwrap_agent("name", fn)wrap_agent("name", input=...)
Requires extracting a functionYesNo
Output auto-captured from return valueYesNo — call run.on_complete
Reusable wrapped callableYesNo — inline only
Async and sync supportYesYes
run_id accessFrom return tuple (result, run_id, span)From run.run_id after block