Export traces and metrics
Send CompozyOS daemon traces and metrics to an OpenTelemetry collector over OTLP, read export status, and fix export failures.
15 min read10 pages in Operations
The daemon can export traces and metrics over OTLP to any OpenTelemetry collector or hosted backend. Export is off by default. When it is on, every exported value passes a sanitizing exporter that keeps IDs, fixed names, and counts, and never prompt text, tool payloads, paths, or credentials.
Configuration lives in the global config.toml under
[observability.otel]. Every key is
restart-required, so restart the daemon after a change.
Try it with a local stack
This stack accepts both signals: an OpenTelemetry Collector (OTLP on 4317/4318) forwards traces to Jaeger (UI on 16686) and metrics to Prometheus (UI on 9090).
~/otel-local/compose.yaml:
services:
collector:
image: otel/opentelemetry-collector-contrib:0.138.0
command: ["--config=/etc/otelcol/config.yaml"]
volumes:
- ./collector.yaml:/etc/otelcol/config.yaml:ro
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
jaeger:
image: jaegertracing/jaeger:2.11.0
ports:
- "16686:16686"
prometheus:
image: prom/prometheus:v3.7.3
command: ["--config.file=/etc/prometheus/prometheus.yaml"]
volumes:
- ./prometheus.yaml:/etc/prometheus/prometheus.yaml:ro
ports:
- "9090:9090"~/otel-local/collector.yaml:
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls: { insecure: true }
prometheus:
endpoint: 0.0.0.0:8889
service:
pipelines:
traces: { receivers: [otlp], exporters: [otlp/jaeger] }
metrics: { receivers: [otlp], exporters: [prometheus] }~/otel-local/prometheus.yaml:
scrape_configs:
- job_name: compozy-collector
scrape_interval: 15s
static_configs:
- targets: ["collector:8889"]Start the stack, enable export, and restart the daemon:
$ cd ~/otel-local && docker compose up -d
$ compozy config set observability.otel.enabled true
Config
======
Path: observability.otel.enabled
Value: true
Scope: user
Target: /home/ana/.compozy/config.toml
Redacted: false
Lifecycle: restart-required
Applied: false
Next Action: restart-daemon
Apply Record: cfgapp-2f7faa2253b9fd65
Active Generation: 0
Restart Required: true
Restart Scope: daemon
Status: --
Winning Layer: --
$ compozy daemon stop && compozy daemon start
$ compozy session prompt sess-9c1e "list my open sessions"
$ compozy status
Runtime
=======
Status: running
...
Config: ok
OTel: ok · http/protobuf → localhost:4318 · traces ok, metrics ok · sample 1.0
Log Tail: availableOpen Jaeger at localhost:16686, pick service compozy-daemon and operation session.turn. The trace
shows POST /api/workspaces/:workspace_id/sessions/:session_id/prompt → session.turn →
acp.process.spawn on a cold session and acp session/prompt, with mcp.hosted.call →
tool.execute (compozy.tool = compozy__session_list) nested inside session.turn.
Open Prometheus at localhost:9090 and query:
histogram_quantile(0.95, sum by (le) (rate(compozy_session_turn_duration_seconds_bucket[5m])))Metrics show pending in compozy status until the first push, which happens every 60 seconds by
default. The query returns NaN after a single turn, because rate() needs an increase between two
samples; send a second prompt within the 5-minute window to get a value.
[observability] enabled must stay true; with it off, status reports
inactive (observability.enabled = false) and nothing is exported.
For a fuller setup, the repository ships a Grafana OSS stack in
infra/observability: Tempo, Loki,
Prometheus and Grafana, with a provisioned CompozyOS daemon dashboard and cross-linked data sources. Start it
with docker compose up -d in that directory and point observability.otel.endpoint at
http://localhost:4318.
Configure export
[observability]
enabled = true # existing; OTel export requires it
[observability.otel]
enabled = false # master switch for OTLP export
protocol = "http/protobuf" # "http/protobuf" | "grpc"
endpoint = "" # "" → OTEL_EXPORTER_OTLP_ENDPOINT → http://localhost:4318 (grpc: http://localhost:4317)
traces = true # export spans when enabled
metrics = true # export metrics when enabled
sample_ratio = 1.0 # 0.0–1.0, new traces and unsampled parents
metric_interval = "60s" # minimum "5s"
service_name = "compozy-daemon" # service.name; letters, digits, . _ - (max 64)
slow_db_write_ms = 50 # write-span threshold; 0 = no write spans
forward_to_providers = false # give provider processes OTEL_* + TRACEPARENT (operator policy)
[observability.otel.headers] # values must be env: or vault:observability/ refs
# "api-key" = "vault:observability/newrelic-license"
# "Authorization" = "env:GRAFANA_OTLP_AUTH"Scalar keys are settable with compozy config set; edit headers in the file.
$ compozy config set observability.otel.protocol grpc
$ compozy config set observability.otel.endpoint http://collector.local:4317
$ compozy config set observability.otel.sample_ratio 0.25
$ compozy config get observability.otel.enabled
observability.otel.enabled: true| Key | Observable effect |
|---|---|
enabled | false: no providers, no connections, OTel: disabled. true: export starts at the next daemon start. |
protocol | Wire protocol to the collector; also picks the default endpoint port. |
endpoint | Collector base URL; /v1/traces and /v1/metrics are appended for HTTP. |
traces / metrics | Turn each signal off independently; status shows each. |
sample_ratio | Fraction of new traces kept, also applied to unsampled incoming parents (what the CompozyOS web and desktop send). An external caller's sampled parent is always kept. |
metric_interval | How often metric points are pushed. |
service_name | Name shown in the backend's service list. |
slow_db_write_ms | A write inside a trace becomes a db.write span when it is not part of streaming coalescing, or when it takes at least this long. Histograms record every write regardless. |
forward_to_providers | Operator policy that gives provider processes started afterwards the OTLP environment. Off by default. |
headers.* | Header sent on every export request, resolved from the ref at daemon start. |
All keys are global-only. A workspace config.toml that sets [observability.otel] fails to load,
workspace-scope writes are rejected, and profile overlays deny the observability block.
Environment variables
Standard OpenTelemetry variables fill only the values config.toml does not set. A value written in
the file wins even when it equals the default; an empty endpoint counts as not set.
| Variable | Effect |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | Fills endpoint. |
OTEL_EXPORTER_OTLP_PROTOCOL | Fills protocol. |
OTEL_EXPORTER_OTLP_HEADERS | Fills headers; a non-empty [observability.otel.headers] replaces them entirely. |
OTEL_SERVICE_NAME | Fills service_name. |
OTEL_SDK_DISABLED=true | Forces export off. |
OTEL_RESOURCE_ATTRIBUTES | Ignored. |
Their values are checked only while export can run ([observability] enabled, observability.otel.enabled,
and at least one of traces or metrics), so variables set for other tools never block a daemon that exports
nothing. Only the daemon's process environment counts; a workspace .env file never sets them.
compozy config get resolves them the same way, from the environment of the shell that runs it.
Invalid environment endpoints are redacted in config inspection even while export is inactive;
credentials belong in header refs, never in endpoint URLs.
Header secrets
Hosted backends authenticate with a header. Header values must be env: or vault:observability/
refs; literal values are rejected. Store the secret in the vault, then reference it:
$ printf '%s' "$NEW_RELIC_LICENSE_KEY" | compozy vault put vault:observability/newrelic-license --kind token --value-stdin
$ compozy config edit # add the [observability.otel.headers] entry[observability.otel.headers]
"api-key" = "vault:observability/newrelic-license"The daemon resolves refs at start. compozy config get shows the ref, never the resolved value.
Short header values, such as a tenant ID, are masked when they exactly match an exported string;
they do not enter process-wide substring redaction or rewrite unrelated saved session content.
Agents
Agents can read the keys but not change them. compozy__config_set refuses every
observability.otel.* key with config_trust_root_forbidden: telemetry settings decide what leaves
the machine and where, so only the operator sets them with compozy config set or
compozy config edit. compozy__config_get reads one key per call. It shows effective values, including
the daemon's OTEL_* environment fallback where the file does not set a value. Header values read as
[REDACTED] because the daemon masks every env:/vault: ref in agent-facing output. The operator reads
the ref with compozy config get.
Read export status
compozy status shows one OTel line in the Runtime section:
| State | Line |
|---|---|
disabled | OTel: disabled |
inactive_observability_disabled | OTel: inactive (observability.enabled = false) |
ok | OTel: ok · grpc → collector.local:4317 · traces ok, metrics ok · sample 0.25 |
ok, metrics off | OTel: ok · http/protobuf → localhost:4318 · traces ok, metrics off · sample 1.0 |
degraded | OTel: degraded · grpc → collector.local:4317 · traces ok, metrics http_401 (12 failed) · sample 0.25 |
| forwarding on | the line ends with · forwarding to providers |
Export reads disabled when observability.otel.enabled is false, when OTEL_SDK_DISABLED=true, or when both
traces and metrics are false.
compozy status --json and GET /api/status (HTTP and UDS) carry the same data in otel:
{
"otel": {
"state": "ok",
"protocol": "grpc",
"endpoint_host": "collector.local:4317",
"sample_ratio": 0.25,
"forward_to_providers": false,
"traces": {
"enabled": true,
"last_export_at": "2026-11-07T14:02:11Z",
"failed_exports": 0
},
"metrics": {
"enabled": true,
"last_export_at": "2026-11-07T14:02:00Z",
"failed_exports": 0
},
"dropped_spans": 0
}
}A degraded signal reports its last error class and failure count:
"metrics": { "enabled": true, "last_export_at": "2026-11-07T13:20:00Z", "last_error_class": "http_401", "failed_exports": 12 }A daemon older than OTLP export omits otel; treat a missing object as unknown. Export failures
never fail a command, and compozy status exits 0 whenever the daemon answers.
Every HTTP route accepts a W3C traceparent header, so an external caller can join its own trace.
Invalid values are ignored and the request is served normally; tracestate is ignored.
What is exported
Everything below passes the daemon's sanitizing exporter. The resource carries service.name
(config), service.version (daemon version), service.instance.id (daemon boot ID), os.type, and
host.arch. Span status descriptions are always empty; failures carry error.type.
Spans
| Name | Kind | Parent | Attributes |
|---|---|---|---|
<METHOD> <route template> (unmatched: HTTP <METHOD>) | server | incoming traceparent or root | http.route, http.request.method, http.response.status_code |
session.turn | internal | request span; queued prompt: root + link to the enqueue request | compozy.session_id, compozy.turn_id, compozy.run_id, compozy.workspace_id, compozy.profile_id, compozy.agent, gen_ai.system, gen_ai.request.model, compozy.outcome, compozy.db.flushes, compozy.db.write_ms_total, error.type; event recovery |
acp.process.spawn | internal | session.turn | gen_ai.system, compozy.outcome |
acp <method> (for example acp session/prompt) | client | session.turn | rpc.system, rpc.method, compozy.outcome; event first_token |
acp.permission.wait | internal | session.turn | compozy.outcome (approved/denied/cancelled) |
mcp.hosted.call | server | the bound run's session.turn; after the run: root + link to it; unknown bind: root | compozy.tool, compozy.run_id, compozy.outcome (ok/error/after_run/unbound) |
tool.execute | internal | caller | compozy.tool, compozy.tool.risk, compozy.outcome, error.type |
db.write | internal | caller (only inside a trace) | compozy.db.operation, compozy.db.coalesced, compozy.outcome |
hook.dispatch | internal | caller | compozy.hook.event, compozy.outcome |
loop.run / automation.run / scheduler.tick | internal | root | compozy.loop.id / compozy.automation.id |
Metrics
Prometheus renames dots to underscores and adds the unit, so compozy.session.turn.duration becomes
compozy_session_turn_duration_seconds.
| Name | Type | Unit | Attributes |
|---|---|---|---|
compozy.session.turn.duration | histogram | s | gen_ai.system, gen_ai.request.model (catalog name, custom, or other), compozy.agent, compozy.outcome |
compozy.session.ttft | histogram | s | gen_ai.system, gen_ai.request.model |
compozy.acp.rpc.duration | histogram | s | gen_ai.system, rpc.method, compozy.outcome |
compozy.tool.call.duration | histogram | s | compozy.tool, compozy.outcome |
http.server.request.duration | histogram | s | http.route, http.request.method, http.response.status_code |
compozy.db.write.duration | histogram | s | compozy.db.operation, compozy.db.coalesced, compozy.outcome |
compozy.db.write.admission_wait | histogram | s | compozy.db.operation |
compozy.tokens | counter | {token} | gen_ai.system, gen_ai.request.model, compozy.agent, compozy.token.type (input/output/cache_read/cache_write) |
compozy.cost | counter | {USD} | gen_ai.system, gen_ai.request.model, compozy.agent, compozy.cost.status (actual = reported by the provider, estimated = from the pricing catalog). Plan-included and unknown costs, and non-USD amounts, add nothing. |
compozy.tool.calls | counter | {call} | compozy.tool, compozy.outcome; one count per tool call, on its first completed or failed update |
compozy.permission.requests | counter | {request} | compozy.outcome |
compozy.agent.errors | counter | {error} | gen_ai.system, error.type |
compozy.session.active | gauge | {session} | none |
compozy.agent.active | gauge | {agent} | none |
compozy.uptime | gauge | s | none |
Value rules
- Agent, provider, model, and tool values not known to their registries export as
other. - A model exports by name only when it comes from the model catalog (built-in or models.dev). A model
that exists only in your configuration exports as
custom. - Identity and enum values outside their owner's format are omitted.
- Any value containing a registered secret exports as
invalid. - Unregistered span names export as
compozy.unknown. - No exemplars are exported.
Never exported
The daemon never exports prompt or response text, tool arguments or results, file contents,
filesystem paths, environment values, header values, credentials, error message text, span or
attribute values outside the registries above, or incoming tracestate.
Forward to providers
Provider processes no longer inherit OTEL_* or TRACEPARENT from the daemon's process environment,
even when daemon export is off. To keep independent provider telemetry configured before this change,
move those variables into the agent's session_env. To share the daemon's collector settings instead,
enable export and forward_to_providers as described below.
With forward_to_providers = true and export running, provider processes started afterwards
receive:
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_HEADERS=api-key=<resolved value>
OTEL_RESOURCE_ATTRIBUTES=compozy.session_id=sess-9c1e,compozy.agent=general
TRACEPARENT=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01TRACEPARENT is the provider's acp.process.spawn span, so a provider that honors it nests its own
spans under the turn. It is set only when that span is sampled. Header values are resolved once at
daemon start and percent-encoded.
This is an operator policy, not covered by the daemon privacy guarantee. The provider decides
what it exports — some providers include prompt text or tool payloads in their own telemetry — and
it receives the resolved header values. Whether a provider exports anything depends on that provider
(some need their own enable flag, set through the agent's environment). Agent-defined OTEL_*
values win. compozy status shows forwarding to providers while it is on.
Nothing is forwarded while export is disabled, inactive (observability.enabled = false), or
degraded at start, for example by a header ref that does not resolve. Providers already running keep
the environment they started with.
$ compozy config set observability.otel.forward_to_providers true
$ compozy daemon stop && compozy daemon startOverhead
BenchmarkTurnTelemetryFootprint measures one turn with export disabled and with export enabled at a
sample ratio of 1, exporting to an in-process OTLP/HTTP receiver with the daemon's database write
instrumentation installed. Each turn runs through the ACP
driver against a mock agent on a warm session: 200 streamed chunks, one hosted tool call, and a usage
event. Reproduce it from the repository root:
go test ./internal/session -run '^$' -bench BenchmarkTurnTelemetryFootprint -benchmem -benchtime=10x -count=10Means of 10 runs of 10 turns each:
| Measure | Disabled | Enabled (100%) | Difference |
|---|---|---|---|
Elapsed time per turn (ns/turn) | 1,172.9 ms | 1,176.0 ms | +0.26% |
Process CPU per turn (cpu_ns/turn) | 145.6 ms | 161.4 ms | +10.9% |
| Heap allocations per turn | 295,666 | 296,229 | +0.19% |
| OTLP bytes per turn | 0 | 6,614 B | 6.5 KB |
| Spans per turn | 0 | 10 |
- Elapsed time is dominated by the mock agent's streaming, so it includes provider I/O.
- CPU is the benchmark process's user and system time from
getrusage, around the measured turns and their final export. Individual runs ranged from 82 ms to 197 ms in both modes, so the CPU difference is within run-to-run noise. Allocations, which do not depend on machine load, grow by 563 per turn. The receiver runs in the same process, so the enabled CPU and allocation figures also include its decoding work; measuring with the receiver in a separate process is tracked in #1622. - OTLP bytes are the serialized trace requests per turn plus one full metric push, counted as if every turn triggered a push.
- The spans are
session.turn,acp session/prompt, anddb.writespans for the turn's store writes. The benchmark runs the session runtime without the daemon's HTTP server and hosted tool service, so it does not count the request span,mcp.hosted.call, ortool.executethat a daemon turn also exports.
Measured on Linux amd64 with an Intel Core 7 240H (16 threads), with other agent sessions running (load average 3 to 7).
Troubleshoot export
Configuration errors stop compozy config set and config load with one of these
messages:
| Condition | Message | Action |
|---|---|---|
| Bad protocol | observability.otel.protocol must be "http/protobuf" or "grpc" | Fix the value. |
| Bad env protocol | OTEL_EXPORTER_OTLP_PROTOCOL must be "http/protobuf" or "grpc" | Fix or unset the variable. |
| Bad endpoint | observability.otel.endpoint must be an http(s) URL with a host | Fix the URL. Userinfo, a query, or a fragment is also rejected. |
| Bad env endpoint | OTEL_EXPORTER_OTLP_ENDPOINT must be an http(s) URL with a host | Fix or unset the variable. |
| Bad env headers | OTEL_EXPORTER_OTLP_HEADERS must be a comma-separated list of name=value pairs | Fix or unset the variable. |
| Literal header | observability.otel.headers."api-key" must be an env: or vault:observability/ ref | Store the secret with compozy vault put and reference it. |
| Wrong vault namespace | observability.otel.headers."api-key" is invalid: vault:other/x must use vault:observability/<path> | Move the secret under observability/. |
| Bad header name | observability.otel.headers."api key" must be a valid HTTP header name | Use a header name without spaces or separators. |
| Workspace file sets the block | workspace config "<path>": observability.otel settings are global-only | Move the block to the global config.toml. |
| Sample ratio | observability.otel.sample_ratio must be between 0 and 1 | Fix the value. |
| Interval | observability.otel.metric_interval must be at least 5s | Raise it. |
| Slow-write threshold | observability.otel.slow_db_write_ms must be >= 0 | Fix the value. |
| Service name | observability.otel.service_name must match ^[A-Za-z0-9._-]{1,64}$ | Use a simple name. |
Runtime failures never fail a command. They show as degraded in compozy status, with the error
class per signal in last_error_class:
| Error class | Meaning | What to check |
|---|---|---|
http_400 | The collector rejected the payload. | The endpoint path and protocol match what the collector serves. |
http_401, http_403 | The backend rejected the credentials. | The header name and the secret behind its ref; restart the daemon after rotating it. |
http_404 | No OTLP receiver at that URL. | The endpoint base URL; HTTP appends /v1/traces and /v1/metrics. |
http_413 | The request was too large for the backend. | The backend's payload limits. |
http_429 | The backend is rate limiting. | The backend quota; lower sample_ratio or raise metric_interval. |
http_5xx | The collector or backend failed. | Collector health and logs. |
unavailable | The collector is unreachable. | That the collector runs and the host and port match protocol (4318 HTTP, 4317 gRPC by default). |
deadline_exceeded | The export timed out. | Network path and collector load. |
tls | TLS handshake or certificate verification failed. | The https URL, the certificate chain, and the host name. |
header_ref_unresolved | A header ref did not resolve at start; both enabled signals report it. | The daemon log names the header (the ref itself is masked); check it with compozy config get observability.otel.headers, store the secret or set the variable, then restart. |
other | Any other failure. | The daemon log at $COMPOZY_HOME/logs/compozy.log. |
dropped_spans counts spans dropped because the export queue was full; a growing count means the
collector cannot keep up.