OpenTelemetry export
Export every spens session to an OTLP collector using the GenAI semantic conventions
Spens can export every session to an OpenTelemetry collector using the GenAI semantic conventions. At the completion of a run — successful or not — the session's captured traffic is sent as a single OTLP trace whose trace id is derived from the spens session id, so it lines up with the session everywhere else (log viewer, session_summary.json, the .spens/sessions/<session-id>/ directory).
Exporting is off by default: with no OTel configuration, nothing is ever sent. Configure it either in .spens.config.json or with the standard OTEL environment variables.
Install the optional extra
The OpenTelemetry libraries are an optional install:
pip install 'spens-ai[otel]'If export is configured but the libraries are missing, spens emits a warning and skips the export. A session never fails because of OTel.
Enable export
Add an otel section to .spens.config.json:
{
"otel": {
"endpoint": "http://localhost:4317",
"protocol": "grpc",
"service_name": "spens",
"include_messages": true
}
}Or set the standard environment variables, which win over the config file:
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317"
export OTEL_EXPORTER_OTLP_PROTOCOL="grpc"
export OTEL_SERVICE_NAME="spens"An endpoint is required to enable export. Without one, spens stays silent even if other OTel settings are present.
Configuration reference
| Setting | Env var | Default | Description |
|---|---|---|---|
endpoint | OTEL_EXPORTER_OTLP_ENDPOINT | — (disabled) | The OTLP collector endpoint. Required to enable export |
protocol | OTEL_EXPORTER_OTLP_PROTOCOL | http/protobuf | grpc or http/protobuf (http is accepted as shorthand). For http/protobuf, /v1/traces is appended to the endpoint when not already present |
service_name | OTEL_SERVICE_NAME | spens | The OTel service name reported on the trace resource |
include_messages | SPENS_OTEL_INCLUDE_MESSAGES | false | Explicit opt-in: attach the full message payloads to the spans. See Message payloads |
headers | OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_TRACES_HEADERS | — | OTLP request headers, e.g. collector authentication. See Headers |
Environment variables always win over the config file. The include_messages env var is truthy for 1, true, yes, or on (case-insensitive).
Headers
Use headers to authenticate to your collector. In the config file, headers is an object:
{
"otel": {
"endpoint": "https://otlp.example.com",
"headers": {"api-key": "your-collector-key"}
}
}The environment variables use the standard OTel key=value comma-separated format (values are URL-encoded):
export OTEL_EXPORTER_OTLP_HEADERS="api-key=your-collector-key"Header precedence follows the OTel specification:
OTEL_EXPORTER_OTLP_TRACES_HEADERS(signal-specific) wins overOTEL_EXPORTER_OTLP_HEADERS(generic), which wins over- the
headerssetting in the config file.
When no header source is configured, spens passes nothing and the official exporters read the OTEL_EXPORTER_OTLP_*_HEADERS environment variables themselves, which is the default OTel behavior.
What gets exported
The span tree is inferred from the captured network traffic. A captured LLM call becomes a chat span, any other HTTP request the agent makes becomes an execute_tool span, and a new conversation chain — a chat whose first user message differs from every chain seen so far, i.e. a subagent's own task prompt — opens a nested invoke_agent span that owns that chain's chats.
TRACE (trace_id derived from the spens session id)
└── invoke_agent "node_pi" ← root span, the whole spens session
├── chat gpt-4o ← LLM call (model, tokens, latency)
├── execute_tool api.example.com ← tool the model picked (HTTP call)
├── invoke_agent "gpt-4o-mini" ← subagent (a distinct conversation chain)
│ └── chat gpt-4o-mini
└── chat gpt-4o ← final synthesis callAll three captured API formats are supported — OpenAI chat completions, OpenAI Responses, and Anthropic Messages, streaming and buffered — because the export reuses the exact same trace-parsing code the log viewer uses. That means the viewer and OTel share one request/response format and one set of metadata (models, tokens, finish reasons, latencies).
Span attributes
Every span uses the GenAI semantic conventions:
invoke_agent(root) —gen_ai.agent.name,spens.session_id,spens.exit_code, optionalspens.environment, session totalsgen_ai.usage.input_tokens/gen_ai.usage.output_tokens/gen_ai.usage.cost, and the call countsspens.llm.calls/spens.tool.calls.invoke_agent(subagent) —gen_ai.agent.name(the subagent's model) andspens.subagent: true.chat—gen_ai.system.name,gen_ai.request.model,gen_ai.usage.input_tokens,gen_ai.usage.output_tokens,gen_ai.response.finish_reasons,spens.request.id,spens.latency_ms, and, when present,gen_ai.request.streaming,gen_ai.tool.names, andgen_ai.usage.cost.execute_tool—gen_ai.tool.name(the request host),http.request.method,url.full,http.response.status_code, andspens.latency_ms.
Costs
Costs ride along with the traces. Each chat span whose response reports usage carries its own estimated cost (gen_ai.usage.cost, USD), and the root invoke_agent span carries the session total. The figures come from the same costing code the session recap uses, so the exported trace, session_summary.json, and the log viewer always agree: a positive provider-reported cost wins over the pricing table, and cached tokens are billed at their own rates. Calls with no usage are left without a cost (unknown, not $0).
See Pricing for how costs are resolved.
Message payloads
By default, chat spans carry only metadata — model, tokens, cost, finish reason, latency. No prompt or response content leaves the host unless you explicitly opt in.
Set include_messages to attach the full message payloads. With it enabled:
- Each
chatspan gets the new-turn messages as span events:gen_ai.system.message,gen_ai.user.message,gen_ai.tool.message,gen_ai.assistant.message, andgen_ai.choice(tool-call decisions). - Each
invoke_agentspan (the root and any subagents) records its own input and output — the request that opened its conversation chain and the last response it produced — as the GenAI semconvgen_ai.input.messages/gen_ai.output.messagesattributes.
Only the display delta is sent, never the whole replayed context, matching what the log viewer shows.
Spens