Spens logoSpens

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

SettingEnv varDefaultDescription
endpointOTEL_EXPORTER_OTLP_ENDPOINT— (disabled)The OTLP collector endpoint. Required to enable export
protocolOTEL_EXPORTER_OTLP_PROTOCOLhttp/protobufgrpc or http/protobuf (http is accepted as shorthand). For http/protobuf, /v1/traces is appended to the endpoint when not already present
service_nameOTEL_SERVICE_NAMEspensThe OTel service name reported on the trace resource
include_messagesSPENS_OTEL_INCLUDE_MESSAGESfalseExplicit opt-in: attach the full message payloads to the spans. See Message payloads
headersOTEL_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:

  1. OTEL_EXPORTER_OTLP_TRACES_HEADERS (signal-specific) wins over
  2. OTEL_EXPORTER_OTLP_HEADERS (generic), which wins over
  3. the headers setting 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 call

All 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, optional spens.environment, session totals gen_ai.usage.input_tokens / gen_ai.usage.output_tokens / gen_ai.usage.cost, and the call counts spens.llm.calls / spens.tool.calls.
  • invoke_agent (subagent) — gen_ai.agent.name (the subagent's model) and spens.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, and gen_ai.usage.cost.
  • execute_tool — gen_ai.tool.name (the request host), http.request.method, url.full, http.response.status_code, and spens.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 chat span gets the new-turn messages as span events: gen_ai.system.message, gen_ai.user.message, gen_ai.tool.message, gen_ai.assistant.message, and gen_ai.choice (tool-call decisions).
  • Each invoke_agent span (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 semconv gen_ai.input.messages / gen_ai.output.messages attributes.

Only the display delta is sent, never the whole replayed context, matching what the log viewer shows.

Next steps

On this page