events.jsonl is the telemetry interface, with traces.jsonl beside it for OpenTelemetry tools (Traces): decree adds no metrics endpoint and no exporter. Any log shipper that tails files can read it; the reference setup is Grafana Alloy into Loki, shown in examples/observability/.
- Ship
.decree/runs/*/events.jsonl(events) and, optionally,.decree/runs/*/*.log(script output).traces.jsonlgoes to a trace store instead (Traces). - Timestamps. Use the event's
tsas the log timestamp, so back-filled and late-shipped events land at the right time. - Labels must stay low-cardinality:
machine,type, and for script outputscript.run_id,stateandseqare fields or structured metadata, never labels, because a label per run makes Loki slow. The one exception isstateonrun_finishedlines, whose values are a machine's few final states, for Metrics. - Script output files carry their context in the path:
runs/<run_id>/<NNNN>-<state>-<script>.log, orruns/<run_id>/<NNNN>-<state>.logwhen the script is named as its state. State and script names cannot contain-, so the regex/runs/(?P<run_id>[^/]+)/(?P<n>\d{4,})-(?P<state>[^-/]+?)(?:-(?P<script>[^-/]+))?\.log$is unambiguous; a missingscriptis thestate. - Retention. Loki's retention is the history.
decree prune --older-than <age>deletes finished run folders (cli.md), and nothing else does, so the localruns/is a working copy: ship runs before pruning them, and prune with an age longer than the shipper's lag. - Stability. Field names and meanings in events.jsonl are a public contract under
v: 1. Dashboards may depend on them.
Example LogQL, also in examples/observability/README.md:
# p95 script duration by script, last 24 h
quantile_over_time(0.95, {job="decree", type="script"} | json | unwrap duration_ms [24h]) by (script)
# runs that ended in failed, per machine, per day
sum by (machine) (count_over_time({job="decree", type="run_finished"} | json | state="failed" [1d]))
# interrupted runs waiting for `decree process --retry`
{job="decree", type="interrupted"} | json
# model decisions below a confidence floor
{job="decree", type="decision"} | json | kind="model" and confidence < 0.6
decree serves no metrics. A Prometheus metric is derived from events.jsonl by the log shipper, as Alloy's stage.metrics does in examples/observability/config.alloy. It reads each run_finished line and serves two metrics on Alloy's own /metrics, which Prometheus scrapes:
| Metric | Type | Labels | From |
|---|---|---|---|
decree_runs_total |
counter | machine, state |
one per run_finished; success is state="done" |
decree_run_duration_milliseconds |
histogram | machine, state |
run_finished.duration_ms |
- Drop the file name label before the metrics, because it names the run and would make one series per run.
- Set
max_idle_durationlonger than the slowest cadence you alert on. Alloy drops a series that has seen no new line for that long, so a weekly job's series would otherwise vanish between runs. - A counter starts at zero when Alloy restarts.
increase()andrate()handle that.
# a backup machine that has not finished in done for two days
increase(decree_runs_total{machine="backup", state="done"}[48h]) == 0
# any run that ended in failed in the last 10 minutes
increase(decree_runs_total{state="failed"}[10m]) > 0
# p95 run duration per machine, in seconds
histogram_quantile(0.95, sum by (machine, le) (rate(decree_run_duration_milliseconds_bucket[1d]))) / 1000
A run is also a trace: a tree of timed spans, into which child runs, router calls and the scripts' own calls to services and models nest. decree writes it to files in standard formats and passes its context to scripts in standard environment variables; it has no exporter and no network code. An OpenTelemetry Collector ships the files (decisions.md).
Standards:
- W3C Trace Context (w3.org/TR/trace-context): a
traceparentis00-<trace id>-<parent span id>-<trace flags>, 32, 16 and 2 lowercase hex digits; a trace id or span id of all zeros is invalid, andtracestateis only read beside a validtraceparent. - OpenTelemetry's environment variable carrier (Environment Variables as Context Propagation Carriers): the
traceparentandtracestatekeys, uppercased, are the environment variablesTRACEPARENTandTRACESTATEa parent process sets for a child process. - OTLP/JSON (OTLP, JSON Protobuf Encoding): the JSON encoding of an OTLP
ExportTraceServiceRequest, with the fields oftrace.proto. Field names are lowerCamelCase, trace and span ids hex strings, enums integers, and 64-bit integers (times,intValue) decimal strings. One request per line is the OTLP file exporter format, which the Collector'sotlp_json_filereceiver reads.
Ids. A run takes its trace id from its message's traceparent frontmatter key if it is valid, and its run span's parent is the span traceparent names; otherwise the run starts a new trace with a random trace id and its run span is a root. Trace ids (16 bytes) and span ids (8 bytes) are read from /dev/urandom. Every event in events.jsonl carries the run's trace_id, and each event that starts or ends a span carries its span_id (events.jsonl), so the record and the trace name the same spans.
Context passed on.
- Scripts get
TRACEPARENT=00-<trace id>-<the script's span id>-01, andTRACESTATEwhen the message carried one (Environment). An instrumented call a script makes (an OpenTelemetry SDK, or a CLI that readsTRACEPARENT) becomes a child of the script's span. - Child runs get
traceparentin theirmessage.md, naming the invoking state's span: the router'sdecisionspan for amodelinvoke, the wait span for amachineinvoke (Sub-machines). decree emitrun by a script setstraceparent(andtracestate) on the new message fromTRACEPARENT(andTRACESTATE), so follow-up work joins the trace under the emitting script.- Any inbox message may carry
traceparentfrom outside, for example from a webhook relaying an upstream trace (Frontmatter keys). An invalid one is ignored, and the run starts a new trace.
Spans. Every span's start and end are the times of the events that bound it, so the trace and events.jsonl agree:
| Span | Name | Parent | From | To |
|---|---|---|---|---|
| The run | run <machine> |
The span the message's traceparent names, else none |
The claim event, or a retry event |
run_finished or interrupted |
A script execution (onentry, invoke attempt, onexit) |
script <state>/<script> |
The run span | started_at of its script event |
started_at + duration_ms |
A decision (check, model, person) |
decision <kind> <state> |
The run span | Its decision event; for a router run, the waiting event for it |
Its decision event |
A wait (a person reply, or a machine invoke's child run) |
wait <state> |
The run span | The waiting event |
The received event |
A router run's run span is a child of its model decision's span, and a machine invoke's child run's run span is a child of its wait span. A run continued by decree process --retry gets a new run span in the same trace, from the retry event, linked to the previous run span. Every span has the attributes decree.run_id, decree.machine and decree.state; script spans add decree.attempt and process.exit.code (from OpenTelemetry's semantic conventions, when the script exited rather than being killed), and decision and wait spans add decree.event. A script that exits non-zero, times out or is killed, a model decision with a router_error, a run ending in failed, and an interrupted run have status ERROR. All spans are of kind INTERNAL.
The file. runs/<id>/traces.jsonl holds the run's spans, each appended when it ends as one OTLP/JSON ExportTraceServiceRequest line, with a single write on a file opened with O_APPEND, as events.jsonl is written. Each line has one resource (service.name: decree and service.version, from the semantic conventions), one scope (decree) and one span. A script span of the recorded develop migration in examples/project, its keys in trace.proto order (decree writes them in alphabetical order, which a JSON reader ignores):
{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"decree"}},{"key":"service.version","value":{"stringValue":"0.5.0-beta.1"}}]},"scopeSpans":[{"scope":{"name":"decree","version":"0.5.0-beta.1"},"spans":[{"traceId":"b872e2142bcdeefd1e77db420727a461","spanId":"ebc969f42644ed25","parentSpanId":"189ed21326b51409","name":"script test/test","kind":1,"startTimeUnixNano":"1790866179700000000","endTimeUnixNano":"1790866271900000000","attributes":[{"key":"decree.run_id","value":{"stringValue":"20261001T143005.000000Z-189ed2"}},{"key":"decree.machine","value":{"stringValue":"develop"}},{"key":"decree.state","value":{"stringValue":"test"}},{"key":"decree.attempt","value":{"intValue":"1"}},{"key":"process.exit.code","value":{"intValue":"0"}}]}]}]}]}A span still open when the process is killed is not written. The run span of a run interrupted by a signal is written with the interrupted event; after a crash, the recovery that appends interrupted (process or daemon starting) writes it, ended at that event, with status ERROR. A run waiting for a reply has written its finished spans; its run span is written when it finishes.
Shipping. The OpenTelemetry Collector (contrib distribution) reads the files with otlp_json_file and exports them to any OTLP endpoint (Jaeger, Grafana Tempo, Honeycomb, a vendor's agent). A minimal configuration, also in examples/observability/otel-collector.yaml:
receivers:
otlp_json_file:
include:
- /srv/project/.decree/runs/*/traces.jsonl
start_at: beginning
exporters:
otlp_http:
endpoint: http://localhost:4318
service:
pipelines:
traces:
receivers: [otlp_json_file]
exporters: [otlp_http]Without a storage extension the receiver keeps its file offsets in memory, so a restarted Collector sends the files again from the beginning; add the file_storage extension and storage: file_storage to keep them. As with events, ship runs before decree prune removes them.