Trace

Span-based tracing for agents, model calls, tools, memory and retrieval, with OpenTelemetry export.

What it does

The trace package records every agent run as a tree of spans: the run itself, each model call, each tool call, handoffs, memory access, retrieval, and LLM middleware such as retries. Each span carries a typed payload with the full input and output of the operation, so backends can show the exact prompt, response, tool arguments and results.

Spans are parented through the context.Context. Concurrent runs never collide, a sub-agent used as a tool nests under that tool call, and a run started under an existing span (yours, or an OpenTelemetry one) becomes its child.

Quick start

import (
    "github.com/henomis/phero/v2/trace"
    "github.com/henomis/phero/v2/trace/text"
)

p := trace.NewProvider(trace.WithProcessor(text.New(os.Stderr)))
a.SetTracing(p)

result, err := a.Run(ctx, llm.Text("What is 2+2?"))
fmt.Println(result.Summary.StopReason, result.Summary.Usage.CostUSD)

An agent without its own provider uses the one in the context (trace.WithProvider(ctx, p)), so sub-agents inherit their parent's provider with no extra wiring. Without any provider, spans are still recorded to build Result.Summary, but nothing is exported.

Span kinds

KindPayloadRecords
invoke_agent*trace.Runagent, input, output, iterations and limit, stop reason, handoffs, session, time to first token
chat*trace.LLMCallprovider, models, prompt messages (delta since the previous call plus offset), tools, tool choice, response, usage (incl. cache and reasoning tokens), cost, finish reason, first-token time, cache hit
execute_tool*trace.ToolCallname, call ID, arguments, result; the span error is the tool error
handoff*trace.Handofffrom, to, context
memory*trace.Memoryretrieve or save, query, messages, count
retrieval / embeddings*trace.Retrieval / *trace.EmbeddingRAG query, top-k, filter, hits with scores; embedding inputs
middleware*trace.Middlewareretry attempts (reason, wait) and rate-limiter waits; cache hits and guardrail blocks are events on the model call span
custom*trace.Customyour own spans: trace.Start(ctx, "step", &trace.Custom{Type: "x"})

Durations are End - Start. A run's stop reason is one of final, max_iterations, handoff, stopped (a tool called agent.StopRun; the span is not marked as failed), cancelled, guardrail or error.

Processors

Pass several trace.WithProcessor options to fan out to several backends. Wrap a processor to change what it sees without affecting the others:

p := trace.NewProvider(
    trace.WithProcessor(text.New(os.Stderr)),
    trace.WithProcessor(trace.Batch(
        trace.Content(trace.ContentPolicy{Capture: true, MaxBytes: 4096}, myExporter),
    )),
    trace.WithProcessor(trace.Redact(func(d *trace.SpanData) { /* mask PII on a copy */ }, other)),
)
defer p.Shutdown(ctx)

Processors are called synchronously; wrap exporters that do I/O in trace.Batch. ContentPolicy clears content, truncates it, or replaces inline images with their type, size and sha256.

OpenTelemetry

import pherootel "github.com/henomis/phero/v2/trace/otel"

exp := pherootel.New(tp) // tp: your OpenTelemetry TracerProvider (SDK + OTLP exporter)
p := trace.NewProvider(
    trace.WithIDSource(exp.IDSource()), // phero and OTel share trace/span IDs
    trace.WithProcessor(exp),
)
a.SetTracing(p)

Run summary

Every Result carries a *trace.RunSummary computed from the run span, including the work of sub-agents run as tools:

Context: sessions, attributes, propagation

ctx = trace.WithSession(ctx, "conversation-42")              // copied to every span
ctx = trace.WithAttributes(ctx, trace.String("tenant", "acme")) // added to every span

// Across processes (W3C trace context):
header := trace.Inject(ctx)                // traceparent of the current span
ctx = trace.Extract(ctx, incomingHeader)   // later spans become its children

// Correlate logs with traces:
slog.InfoContext(ctx, "step done", slog.Any("trace", trace.LogAttrs(ctx)))

A handoff records a span referenced by Handoff.Span. When you run the target agent outside the same span, link it back with trace.WithLinks(ctx, h.Span).

Trace raw LLM calls

Outside an agent, wrap a client with trace.InstrumentLLM to record a chat span for each call. Inside an agent the wrapper adds nothing, since the agent already records the call.

client := trace.InstrumentLLM(openai.New(apiKey))
res, err := client.Execute(trace.WithProvider(ctx, p), messages)

Try it

# from repo root
go run ./examples/tracing        # text span tree
go run ./examples/tracing-otel   # OpenTelemetry spans with GenAI attributes

Related packages