Skip to content

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

On this page14

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:

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:

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:

yaml
scrape_configs:
  - job_name: compozy-collector
    scrape_interval: 15s
    static_configs:
      - targets: ["collector:8889"]

Start the stack, enable export, and restart the daemon:

Terminal
$ 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:           available

Open 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:

Terminal
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

toml
[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.

Terminal
$ 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
KeyObservable effect
enabledfalse: no providers, no connections, OTel: disabled. true: export starts at the next daemon start.
protocolWire protocol to the collector; also picks the default endpoint port.
endpointCollector base URL; /v1/traces and /v1/metrics are appended for HTTP.
traces / metricsTurn each signal off independently; status shows each.
sample_ratioFraction 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_intervalHow often metric points are pushed.
service_nameName shown in the backend's service list.
slow_db_write_msA 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_providersOperator 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.

VariableEffect
OTEL_EXPORTER_OTLP_ENDPOINTFills endpoint.
OTEL_EXPORTER_OTLP_PROTOCOLFills protocol.
OTEL_EXPORTER_OTLP_HEADERSFills headers; a non-empty [observability.otel.headers] replaces them entirely.
OTEL_SERVICE_NAMEFills service_name.
OTEL_SDK_DISABLED=trueForces export off.
OTEL_RESOURCE_ATTRIBUTESIgnored.

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:

Terminal
$ 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
toml
[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:

StateLine
disabledOTel: disabled
inactive_observability_disabledOTel: inactive (observability.enabled = false)
okOTel: ok · grpc → collector.local:4317 · traces ok, metrics ok · sample 0.25
ok, metrics offOTel: ok · http/protobuf → localhost:4318 · traces ok, metrics off · sample 1.0
degradedOTel: degraded · grpc → collector.local:4317 · traces ok, metrics http_401 (12 failed) · sample 0.25
forwarding onthe 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:

json
{
  "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:

json
"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

NameKindParentAttributes
<METHOD> <route template> (unmatched: HTTP <METHOD>)serverincoming traceparent or roothttp.route, http.request.method, http.response.status_code
session.turninternalrequest span; queued prompt: root + link to the enqueue requestcompozy.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.spawninternalsession.turngen_ai.system, compozy.outcome
acp <method> (for example acp session/prompt)clientsession.turnrpc.system, rpc.method, compozy.outcome; event first_token
acp.permission.waitinternalsession.turncompozy.outcome (approved/denied/cancelled)
mcp.hosted.callserverthe bound run's session.turn; after the run: root + link to it; unknown bind: rootcompozy.tool, compozy.run_id, compozy.outcome (ok/error/after_run/unbound)
tool.executeinternalcallercompozy.tool, compozy.tool.risk, compozy.outcome, error.type
db.writeinternalcaller (only inside a trace)compozy.db.operation, compozy.db.coalesced, compozy.outcome
hook.dispatchinternalcallercompozy.hook.event, compozy.outcome
loop.run / automation.run / scheduler.tickinternalrootcompozy.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.

NameTypeUnitAttributes
compozy.session.turn.durationhistogramsgen_ai.system, gen_ai.request.model (catalog name, custom, or other), compozy.agent, compozy.outcome
compozy.session.ttfthistogramsgen_ai.system, gen_ai.request.model
compozy.acp.rpc.durationhistogramsgen_ai.system, rpc.method, compozy.outcome
compozy.tool.call.durationhistogramscompozy.tool, compozy.outcome
http.server.request.durationhistogramshttp.route, http.request.method, http.response.status_code
compozy.db.write.durationhistogramscompozy.db.operation, compozy.db.coalesced, compozy.outcome
compozy.db.write.admission_waithistogramscompozy.db.operation
compozy.tokenscounter{token}gen_ai.system, gen_ai.request.model, compozy.agent, compozy.token.type (input/output/cache_read/cache_write)
compozy.costcounter{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.callscounter{call}compozy.tool, compozy.outcome; one count per tool call, on its first completed or failed update
compozy.permission.requestscounter{request}compozy.outcome
compozy.agent.errorscounter{error}gen_ai.system, error.type
compozy.session.activegauge{session}none
compozy.agent.activegauge{agent}none
compozy.uptimegaugesnone

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:

Terminal
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-01

TRACEPARENT 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.

Terminal
$ compozy config set observability.otel.forward_to_providers true
$ compozy daemon stop && compozy daemon start

Overhead

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=10

Means of 10 runs of 10 turns each:

MeasureDisabledEnabled (100%)Difference
Elapsed time per turn (ns/turn)1,172.9 ms1,176.0 ms+0.26%
Process CPU per turn (cpu_ns/turn)145.6 ms161.4 ms+10.9%
Heap allocations per turn295,666296,229+0.19%
OTLP bytes per turn06,614 B6.5 KB
Spans per turn010
  • 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, and db.write spans 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, or tool.execute that 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:

ConditionMessageAction
Bad protocolobservability.otel.protocol must be "http/protobuf" or "grpc"Fix the value.
Bad env protocolOTEL_EXPORTER_OTLP_PROTOCOL must be "http/protobuf" or "grpc"Fix or unset the variable.
Bad endpointobservability.otel.endpoint must be an http(s) URL with a hostFix the URL. Userinfo, a query, or a fragment is also rejected.
Bad env endpointOTEL_EXPORTER_OTLP_ENDPOINT must be an http(s) URL with a hostFix or unset the variable.
Bad env headersOTEL_EXPORTER_OTLP_HEADERS must be a comma-separated list of name=value pairsFix or unset the variable.
Literal headerobservability.otel.headers."api-key" must be an env: or vault:observability/ refStore the secret with compozy vault put and reference it.
Wrong vault namespaceobservability.otel.headers."api-key" is invalid: vault:other/x must use vault:observability/<path>Move the secret under observability/.
Bad header nameobservability.otel.headers."api key" must be a valid HTTP header nameUse a header name without spaces or separators.
Workspace file sets the blockworkspace config "<path>": observability.otel settings are global-onlyMove the block to the global config.toml.
Sample ratioobservability.otel.sample_ratio must be between 0 and 1Fix the value.
Intervalobservability.otel.metric_interval must be at least 5sRaise it.
Slow-write thresholdobservability.otel.slow_db_write_ms must be >= 0Fix the value.
Service nameobservability.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 classMeaningWhat to check
http_400The collector rejected the payload.The endpoint path and protocol match what the collector serves.
http_401, http_403The backend rejected the credentials.The header name and the secret behind its ref; restart the daemon after rotating it.
http_404No OTLP receiver at that URL.The endpoint base URL; HTTP appends /v1/traces and /v1/metrics.
http_413The request was too large for the backend.The backend's payload limits.
http_429The backend is rate limiting.The backend quota; lower sample_ratio or raise metric_interval.
http_5xxThe collector or backend failed.Collector health and logs.
unavailableThe collector is unreachable.That the collector runs and the host and port match protocol (4318 HTTP, 4317 gRPC by default).
deadline_exceededThe export timed out.Network path and collector load.
tlsTLS handshake or certificate verification failed.The https URL, the certificate chain, and the host name.
header_ref_unresolvedA 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.
otherAny 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.