Tracing¶
Tracer is a HookProvider. Add it and every run becomes a span tree.
import Loopl
func traced(_ model: any Model) async throws {
let tracer = Tracer(agentName: "chat")
let agent = Agent(model: model, tools: [CalculatorTool()], hooks: [tracer])
for try await event in agent.stream("What is 23 × 47?") {
if case .metrics(let m) = event { tracer.record(m, runtime: .mlx, modelId: "qwen3-1.7b-4bit") }
}
for span in tracer.spans {
print(span.name, Int(span.durationMs ?? 0), "ms", span.status.rawValue)
}
}
// chat 412 ms ok · execute_tool calculator 1 ms ok · chat 380 ms ok · invoke_agent chat 795 ms ok
One invoke_agent span per run, a chat child per model call, an execute_tool child per tool — parallel calls keep their own spans. Attribute names follow OpenTelemetry's GenAI conventions; SpanAttribute has the constants.
Nothing leaves the phone¶
loopl doesn't link OpenTelemetry and never sends. tracer.otlpJSON() returns the OTLP/JSON body a collector accepts — posting it is your call.
Spans carry numbers and names — tokens, durations, tool names, stop reasons — not prompts or results. Content is opt-in, for debugging:
let tracer = Tracer(agentName: "chat", options: .init(recordContent: true, keep: 256))
_ = tracer.spans
keep bounds memory (default 512); keep: 0 with onSpanEnd: streams spans to your own sink.