Record decisions and telemetry
A box records every policy decision it makes, every change to its own authority, and
every span, log record, and metric the workload exports. A [telemetry.<label>] table
in box.toml declares the target those records reach, and include states which
records that target receives.
This guide covers a file target, which appends records to a path you name. A box can
also send records to an OTLP collector with kind = "otlp";
[telemetry.<label>] in the box.toml
reference lists its keys, including the collector’s secret.
The default target
Section titled “The default target”A box with no [telemetry] table writes one file inside its own box directory:
<box_dir>/private/telemetry/records.jsonlThat target receives every record: each effective denial, each effective permit, the box’s own control-plane records, and the workload’s relayed spans, log records, and metrics. The workload can’t reach the file.
The file grows for as long as the box is used. Rotating or removing it is your task.
The file isn’t tamper evident. Box signs no record and chains no record to the next, so anything running under your own account can edit it afterwards. What the box keeps out is the workload.
A declared target replaces this default rather than adding to it, so a box that
declares a file of its own writes no record to private/telemetry/records.jsonl.
Declare a file target
Section titled “Declare a file target”[telemetry.decisions] # every decision this box takes, recordedkind = "file"destination = "~/boxes/codex/records.jsonl"include = ["deny", "permit", "trace"]decisions is a label you choose. An unknown key is refused.
| Key | Type | Default | Meaning |
|---|---|---|---|
kind | string | None, required | file appends records to a file. |
destination | string | None, required | The file the records go to. |
include | array of strings | Every record | Which records reach this target, as Filter with include describes. |
A file target receives one OTLP JSON request per line, appended. Each line is a
complete resourceLogs, resourceSpans, or resourceMetrics request.
The destination is an absolute path, ~, or a path that begins with ~/. Box expands
a leading ~ to your home. These destinations are refused:
| Destination | Message |
|---|---|
A relative path, such as records.jsonl | a file destination must be absolute or begin with `~/` |
A path that carries a .. component | carries a `..` component |
A path inside box_dir and outside private/ | is inside the box directory ... but outside its private tree |
Declare one target per file. Two boxes that append to one file can tear a line.
Filter with include
Section titled “Filter with include”Omit include and the target receives every record. Write include and the target
receives only the records the words in the list name:
| Word | The target then receives |
|---|---|
deny | Every effective denial. |
permit | Every effective permit. |
trace | The workload’s own spans, and this box’s control-plane records. |
So include = ["deny"] writes a file of refusals, and leaves out the permits and the
control-plane records.
The workload’s log records and metrics are on by default. They reach every target, and
no value of include keeps them out. The file include = ["deny"] writes therefore holds the
denials, plus whatever log records and metrics the workload itself exported.
trace names two things at once. A target that omits trace receives no
control-plane record, so this box’s box_started, policy_installed, and
box_stopped records are absent from it.
An empty list is refused, and so is a repeated word.
Export the agent’s own signals
Section titled “Export the agent’s own signals”OpenTelemetry calls each kind of telemetry a signal. Three of them apply here: traces, logs, and metrics.
Box relays the workload’s spans, log records, and metrics, and generates no signal of that kind itself. A target receives one only when the workload exports it, so two settings apply: the workload’s own exporter setting, and a target that accepts the signal.
Box binds one loopback port for the workload and sets three variables in its environment:
| Variable | Value |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | http://127.0.0.1:<port>, where the port is this box’s own |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | The same address with /v1/traces appended |
OTEL_EXPORTER_OTLP_PROTOCOL | http/protobuf |
Box owns every name that begins with OTEL_EXPORTER_OTLP_, so [agent] env can’t set
one. The receiver answers /v1/traces, /v1/logs, and /v1/metrics, and answers
404 on every other route.
The exporter switches belong to the workload, so write them in [agent] env:
[agent.env]OTEL_TRACES_EXPORTER = "otlp"OTEL_LOGS_EXPORTER = "otlp"OTEL_METRICS_EXPORTER = "otlp"A switch left at none means that signal never arrives, whatever include names. The
OpenTelemetry default metric interval is 60 seconds, so a short run ends before the
first metric leaves. Set OTEL_METRIC_EXPORT_INTERVAL in [agent] env for a short run.
The Strands CLI reads OTEL_TRACES_EXPORTER but sets up no meter.
Collect the Strands CLI’s traces and metrics turns on both
for the box from Getting started.
What a record carries
Section titled “What a record carries”Each box record names its origin on its resource:
| Key | Value |
|---|---|
service.name | strands-box |
strands.box.name | This box’s generated identifier, as box- and 16 hexadecimal characters |
strands.box.source | box for a record the box wrote, and agent for a relayed payload |
strands.box.run.id | One identifier per box run invocation |
A decision
Section titled “A decision”Each effective decision produces one log record and one span that share a trace ID and
a span ID. The log record’s event_name field is strands.box.policy.decision, and the
scope is strands-box.policy.
| Group | Keys |
|---|---|
| The decision | strands.box.policy.action, .resource, .verdict, .cause, .reason on a denial, and .principal |
| The governing rule | strands.box.policy.rule, .description, .category, and .determining.ids, which lists every policy that determined the decision |
| Correlation | strands.box.request.id on an egress decision, jsonrpc.request.id on an MCP request, and strands.box.trace.parent_span_id on the log record |
| What it was about | server.address and server.port on a network decision, http.request.method on a request, file.path on an fs:* decision, and process.command, process.command_args, and process.working_directory on a shell decision |
strands.box.policy.verdict holds deny or permit. strands.box.policy.cause holds
one of six values: permitted, forbidden, no_match, policy_pending,
internal_fault, and enforcement. A record whose cause is internal_fault also
carries the key error.type, with the value strands.box.policy.evaluation_error.
The action value carries the vocabulary term alone, so a reader sees fs:read rather
than Box::Action::"fs:read". Every other value is reported as the enforcement point
holds it. Two keys are excluded: url.full and process.command_line.
process.command_args reports an argument the script stated literally. For an argument
that arrived from an expansion, the record reports:
| The expanded argument | What the record carries |
|---|---|
A short flag, such as -l | The flag |
A long flag of at most 32 lowercase letters and dashes, such as --verbose | The flag |
A long flag with a value, such as --output=secret | The name, and <redacted> for the value |
| Anything else | <redacted> |
At most 64 arguments are reported. One attribute carries at most 4 KiB, and a longer
value is cut on a character boundary and ends with … (truncated).
A control-plane record
Section titled “A control-plane record”A control-plane record states one change to the authority this box holds. The scope is
strands-box.control, and each operation also produces a span named
control <operation>.
| Key | Value |
|---|---|
strands.box.control.operation | policy_installed, policy_refused, schema_installed, discovery_complete, box_started, or box_stopped |
strands.box.control.outcome | ok, or refused |
strands.box.control.subject | What the operation was about |
strands.box.control.reason | Why a refusal happened |
strands.box.control.detail | Further text the operation carries |
Read a records file
Section titled “Read a records file”A records file holds both scopes, so a query selects one. A box records that it started before it decides anything, so the first record in the file is a control-plane record.
Every denial, one per line:
jq -r 'select(.resourceLogs) | .resourceLogs[].scopeLogs[] | select(.scope.name == "strands-box.policy") | .logRecords[].attributes | map({(.key): .value.stringValue}) | add | select(.["strands.box.policy.verdict"] == "deny") | "\(.["strands.box.policy.action"]) \(.["strands.box.policy.resource"])"' \ ~/boxes/codex/records.jsonlfs:read ~/.ssh/id_rsaWhat this box did to its own authority:
jq -r 'select(.resourceLogs) | .resourceLogs[].scopeLogs[] | select(.scope.name == "strands-box.control") | .logRecords[].attributes | map({(.key): .value.stringValue}) | add | "\(.["strands.box.control.operation"]) \(.["strands.box.control.outcome"])"' \ ~/boxes/codex/records.jsonlbox_started okpolicy_installed okdiscovery_complete okbox_stopped okSelect on strands.box.source to tell a box record from a relayed one. A severity
number is the box’s own routing, and it isn’t a filter for a reader.
The file needs no conversion, so any viewer that reads OTLP JSON opens it directly.
otel-tui is one such tool:
otel-tui --from-json-file ~/boxes/codex/records.jsonlErrors
Section titled “Errors”A telemetry error stops the run before the workload starts, so the agent never runs.
Box prints the reason on stderr and exits 1. A workload is free to exit 1 as well,
so the exit code alone doesn’t prove that Box refused. The message does: Box prefixes
every line it prints with strands-box:.
| What you see | Cause |
|---|---|
names no signal, so nothing would reach it | include is an empty list. |
names <word> twice | include repeats a word. |
a file destination must be absolute or begin with `~/` | A file destination is relative. |
carries a `..` component | A destination carries ... |
is inside the box directory ... but outside its private tree | A file destination sits where a process could truncate it. |
Each message names the table it came from.
A target that fails while the box runs loses the record. Box reports no such loss, and it doesn’t retry.
Next steps
Section titled “Next steps”- Check what policy decided: read the decisions of a run while you write a policy.
[telemetry.<label>]: every key of a telemetry target, including theotlpkind and itssecret.- How telemetry works, in the Box repository: which process records, what a workload can’t forge, and where a record can be lost.