Skip to content

Telemetry

wardcell reports itself — what ran, under which policy, what the sandbox refused, and how it ended. It reports nothing about what an agent was doing; parsing a vendor API is not wardcell’s job.

Off until /etc/wardcell/config.toml names an endpoint. With one set, telemetry is on by default: every run exports the same event stream it writes to its log, filtered to min_level and above. Off means nothing is collected, no thread is started and no socket is opened.

/etc/wardcell/config.toml
[telemetry]
endpoint = "https://collector.example.com:4318"
service_name = "acme-fleet"
timeout_ms = 2000
min_level = "info"
headers = { "x-api-key" = "..." }
endpointan OTLP/HTTP base URL. /v1/traces and /v1/logs are appended, so a path in the endpoint (https://host/otlp) is kept as a prefix. Unset, or empty, is off.
service_namewhat the data calls wardcell. Default "wardcell".
timeout_mshow long one export may take before the run stops waiting. Default 2000.
min_levelthe least-severe level exported — trace debug info warn error, and above. Default "info". The run file keeps more (down to logs.file_min_level); the export is the quieter cut of the same stream.
headersadded to each export, for a collector that authenticates. These are secrets, and this file being root-owned is what keeps them from the users whose runs they describe.

One span per run, named wardcell run, whose trace id is the run’s own identifier — a UUIDv7, so a fleet’s run ids are time-ordered and a run id alone says when the run started. It is written as 32 hex characters with no dashes, the form a trace id always takes; insert dashes at 8-4-4-4-12 and it parses as a UUID. The same value appears on every line of the run’s log and in every question an approval plugin is asked.

The span’s attributes:

Attribute
wardcell.commandargv, as the user wrote it
wardcell.profilethe fragment resolved, when --profile selected one
wardcell.zonethe zone, when the run was in one
wardcell.backendseatbelt or bwrap
wardcell.fragmentsevery fragment the policy resolved from
wardcell.exit_code
wardcell.exit_sourcecommand if the code is the command’s, wardcell if wardcell ended the run itself
wardcell.dropped_recordsonly when a run produced more log records than it keeps

The span’s status is ERROR for a non-zero exit and OK otherwise.

A log record per event, for every event in the run’s log at min_level and above, sent while the run is still going. The event’s fields become OTLP attributes. Two carry more than their message:

  • A net.denied event is a WARN carrying wardcell.denial.operation, wardcell.denial.target and — when there is more to say than “nothing granted this” — wardcell.denial.reason.
  • A zone entry’s audit.decision and audit.completed events mirror the run’s log line for line: every field becomes an wardcell.zone.* attribute, including what an approval plugin answered. A denied or refused entry is a WARN; the rest are INFO.

Every log record carries the run’s trace and span ids, so a trace found in one view leads to that run’s records in the other.

Records leave while the run is going; the span waits for the end

Section titled “Records leave while the run is going; the span waits for the end”

Log records are posted in batches within about half a second of being made. Only the run’s span waits, because it is not complete until the exit code is.

That split is about what survives a crash. A log record does not need its span to exist yet — it names the span, and a receiver correlates them whenever both arrive. So a run that crashes, is killed, or wedges has already delivered everything it knew; what is lost is the root span, which a backend shows as a trace whose root is missing and whose records are all still there.

A run queues at most 2048 records waiting to be sent. Past that they are counted rather than kept, and the count arrives on the span as wardcell.dropped_records — so a gap is visible rather than silent.

Nothing a collector does is waited for on a thread doing real work. Records go into a bounded queue and a shipper thread posts them; a full queue drops rather than blocks. The end of a run waits timeout_ms × 2 for the span and whatever is still queued, then stops waiting — the threads are left to the process exiting.

It is not silent, though. Telemetry that has gone nowhere for a month is worse than telemetry that is off, so a failure is reported once, with a count if there were more:

wardcell: telemetry was not delivered: http://collector.example.com:4318 answered HTTP 503 (and 4 more)

One line, not one per batch: a collector that is down fails every batch, and the command’s own output is what the person is reading.

wardcell sends OTLP’s JSON encoding over HTTP. An OpenTelemetry Collector accepts it on /v1/traces and /v1/logs out of the box. An endpoint configured for protobuf only answers 415, and wardcell says so:

wardcell: telemetry was not delivered: https://example.com answered HTTP 415 (the endpoint does not accept OTLP's JSON encoding)

Point wardcell at a collector and let the collector speak protobuf to whatever is behind it.

An https endpoint is verified against what this machine trusts: the platform store, and SSL_CERT_FILE where one is set. An internal collector with a private certificate needs that certificate in one of those two places.

  • A zone entry is two log records, not a child span. An entry has a start and an end and would read well as a span beneath the run’s, but a span can only be sent once it has ended, and an entry waiting on a human approval can be outstanding for hours. Two records as they happen say more, sooner, than one span that appears when it is over.
  • A denial does not name the fragment that caused it. A denial is usually the absence of a grant rather than a rule that fired, and the matcher the proxy gates on is compiled from every applied fragment at once with the origins dropped. The span lists every fragment the run resolved, which is what can be said truthfully today.