Skip to content

Commit 80c7a28

Browse files
feat(otel): add semantic reconciliation trace hierarchy
agent-identity: unknown agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.3 agent-runtime: OMP 18.0.3 tooling-profile: dotfiles@e4789b0
1 parent d9e98a2 commit 80c7a28

9 files changed

Lines changed: 891 additions & 225 deletions

docs/vrs/06-observability/.decisions/0001-all-signals-via-pr-stack.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# All three signals via a three-PR gh stack
22

3-
Status: draft
3+
Status: accepted
44

55
Recorded 2026-08-25 from the aligned observability interview (axe decision catalog Q1 + Q3).
66

@@ -17,6 +17,12 @@ is shared by every signal; once it lands, metrics and logs are incremental. Defe
1717
invites "traces shipped, rest never happens" — and the CI proof obligation (otelite capture +
1818
assertions) is signal-generic anyway.
1919

20+
## Evidence and Argument
21+
22+
The exporter/provider plumbing and otelite capture harness are shared across all three signals,
23+
while rates/durations require metrics and correlated diagnostics require logs. Splitting delivery
24+
at signal boundaries isolates review and CI failures without treating traces as the finished scope.
25+
2026
## Decision
2127

2228
**Q1 — Scope**: all three signals (traces, metrics, logs) are the target. Not traces-only; not
@@ -31,18 +37,14 @@ traces-first-with-maybe-later.
3137
Each PR lands green independently; 2 and 3 build on 1's shared plumbing
3238
(provider/resource/exporter) only.
3339

34-
## Alternatives considered
35-
36-
- **Traces-first, defer the rest** — rejected: defers most of the value (rates/durations live in
37-
metrics; correlated diagnostics in logs) for no real risk reduction, since the risky part
38-
(feature set, sync export, flush-at-exit) is identical across signals.
39-
- **Single PR** — rejected: couples an unreviewable diff (instrumentation across `run.rs`,
40-
`exec_backend.rs`, `hooks.rs`, plus unit changes and test harness) to the plumbing; a regression
41-
in any slice blocks all of it.
42-
- **Spool-files instead of direct OTLP export** — writing telemetry records to local spool files
43-
for a separate shipper to forward — rejected: adds a moving part st2 must own (rotation,
44-
retention, crash-safety) to solve a problem the fleet pipeline already solves at
45-
`127.0.0.1:4318`; the ambient-endpoint no-op contract would need re-inventing.
40+
## Options
41+
42+
| Option | Result | Reason |
43+
| --- | --- | --- |
44+
| All three signals in a three-PR stack | Selected | Shares the risky plumbing while isolating signal-specific review and CI failures. |
45+
| Traces first, defer the rest | Rejected | Defers rates, durations, and correlated diagnostics without reducing exporter risk. |
46+
| Single PR | Rejected | Couples all instrumentation sites and the capture harness into one blocking review surface. |
47+
| Spool files instead of direct OTLP | Rejected | Adds rotation, retention, and crash-safety machinery for a pipeline the fleet already provides. |
4648

4749
## Consequences
4850

docs/vrs/06-observability/.decisions/0002-subsystem-placement-and-central-reference.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Numbered subsystem under st2's VRS root, referencing dotfiles' central tree
22

3-
Status: draft
3+
Status: accepted
44

55
Recorded 2026-08-25 from the aligned observability interview (axe decision catalog Q2).
66

@@ -19,6 +19,20 @@ central tree (`01-conventions`, `09-integration/spec.md`). But st2-specific deci
1919
feature set, trace roots in `src/run.rs`, unit env propagation — have no home there and would be
2020
invisible to anyone working in this repo.
2121

22+
## Evidence and Argument
23+
24+
The repository already uses numbered VRS subsystems for product-specific lifecycle contracts,
25+
while the dotfiles observability tree owns fleet-wide semantics. A local subsystem with explicit
26+
central references preserves both contributor locality and one authority per shared rule.
27+
28+
## Options
29+
30+
| Option | Result | Reason |
31+
| --- | --- | --- |
32+
| Numbered st2 subsystem | Selected | Keeps local mechanisms and evidence beside their code while referencing central rules. |
33+
| Standalone st2 documentation root | Rejected | Duplicates the established VRS hierarchy. |
34+
| Central dotfiles tree only | Rejected | Leaves st2-specific crate, process, and CI decisions without repository-local authority. |
35+
2236
## Decision
2337

2438
This tree is a **numbered subsystem under st2's existing `docs/vrs` root**: `06-observability`

docs/vrs/06-observability/.decisions/0003-blocking-http-json-exporter.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Blocking HTTP-JSON exporter, single-client feature set, explicit flush at exit
22

3-
Status: draft
3+
Status: accepted
44

55
Recorded 2026-08-25 from the aligned observability interview, backed by prototype evidence
66
([../.experiments/2026-08-25-rust-to-otelite-capture.md](../.experiments/2026-08-25-rust-to-otelite-capture.md)).
@@ -24,6 +24,22 @@ Two traps surfaced during prototyping:
2424
2. **The async-batch trap.** The default async batch exporter requires a tokio runtime; under
2525
st2's sync process model it panicked at export time.
2626

27+
## Evidence and Argument
28+
29+
The linked otelite prototype exported real OTLP/HTTP JSON from a synchronous Rust process only
30+
with the blocking client and an exclusive reqwest-client feature selection. It reproduced both
31+
the dual-client `NoHttpClient` failure and the async exporter's missing-reactor panic, and proved
32+
that explicit shutdown delivers short-lived spans.
33+
34+
## Options
35+
36+
| Option | Result | Reason |
37+
| --- | --- | --- |
38+
| Blocking HTTP-JSON with one reqwest client feature | Selected | Proven end to end in the synchronous process model. |
39+
| Async HTTP client/batch runtime | Rejected | st2 has no tokio reactor and the prototype panicked at export time. |
40+
| Default or dual reqwest client features | Rejected | Compilation succeeds but exporter construction fails with `NoHttpClient`. |
41+
| gRPC | Rejected | The fleet ingestion contract is OTLP/HTTP JSON only. |
42+
2743
## Decision
2844

2945
st2 uses `opentelemetry-otlp` 0.30 with `default-features = false` and exactly
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Semantic reconcile trace hierarchy
2+
3+
Status: accepted
4+
5+
Recorded 2026-08-26 from the PR3 otelite hierarchy proof
6+
([../.experiments/2026-08-26-otelite-reconcile-hierarchy.md](../.experiments/2026-08-26-otelite-reconcile-hierarchy.md)).
7+
8+
## Context
9+
10+
PR3's tracing-facade migration exported exactly one `st2.reconcile_pass` span. The span correlated
11+
logs and carried final pass counts, but the trace remained flat: a slow or failing pass could not
12+
show whether catalog locking, desired-state discovery, hook verification, materialization, runtime
13+
observation, or runner mutation owned the time or failure. Function-level instrumentation would
14+
make that question worse by exposing planning and wrapper structure rather than reconciliation
15+
semantics.
16+
17+
The hierarchy must preserve the existing root and control flow, remain bounded per pass, obey the
18+
central non-empty `span.label` and cardinality contract, and construct no child telemetry when the
19+
tracer exporter is absent even though the stderr tracing subscriber is always installed.
20+
21+
## Evidence and Argument
22+
23+
The linked otelite capture proves the flat baseline can become one root plus five direct,
24+
same-trace children on an empty catalog without changing the existing metric/log assertions.
25+
Reading the reconciliation lifecycle identifies exactly six external operation boundaries; all
26+
other candidate seams are pure planning, bookkeeping, wrappers, or unbounded per-task detail.
27+
28+
## Decision
29+
30+
Keep `st2.reconcile_pass` as the compatibility root and add only aggregate direct children at real
31+
I/O or mutation boundaries:
32+
33+
- `st2.catalog.lock`
34+
- `st2.catalog.discover`
35+
- `st2.hooks.verify`, only when verification is required
36+
- `st2.catalog.materialize`
37+
- `st2.runtime.observe`, only for an authoritative in-pass `Runner::list_sessions`
38+
- `st2.reconcile.execute`
39+
40+
Every span has a bounded, public `span.label`. Attributes contain bounded enums and counts only;
41+
ids, paths, selectors, and error prose are excluded. A child gets OTel `ERROR` status when its
42+
boundary fails or reports errors; the root gets `ERROR` when the pass fails. All child constructors
43+
early-return behind the tracer-export-enabled `AtomicBool`, not `tracing::enabled!`. Children are
44+
always direct root children, never nested by incidental call structure.
45+
46+
## Options
47+
48+
| Option | Result | Reason |
49+
| --- | --- | --- |
50+
| Bounded semantic aggregate children | Selected | Locates lifecycle latency/failure while keeping one fixed detail budget. |
51+
| Flat root only | Rejected | Cannot locate latency or failure within a reconcile pass. |
52+
| Function-mirroring spans | Rejected | Exposes implementation structure rather than external work. |
53+
| Unbounded per-task or per-owner spans | Rejected | Volume and cardinality scale with catalog and mutation size. |
54+
55+
56+
### Flat root only
57+
58+
Rejected. It preserves minimum volume but cannot locate latency or failure within a reconcile
59+
pass; the trace adds little beyond the correlated completion log.
60+
61+
### Function-mirroring spans
62+
63+
Rejected. Spans for validation, planning, compilation, debounce, wrappers, watcher callbacks,
64+
and report absorption would expose implementation structure, inflate volume, and make refactors
65+
look like telemetry contract changes without representing additional external work.
66+
67+
### Unbounded per-task or per-owner spans
68+
69+
Rejected for this hierarchy. They may diagnose individual runner operations but cardinality and
70+
volume scale with catalog size and mutation count. Such detail requires a separately specified hard
71+
budget and privacy/cardinality review before adoption.
72+
73+
## Consequences
74+
75+
- One pass exports at most the fixed aggregate set; hook verification and external-snapshot
76+
observation are truthfully omitted when not performed.
77+
- Grafana can attribute pass duration and error status to lifecycle boundaries without querying
78+
error strings.
79+
- The empty-catalog otelite test proves one root, five direct children, shared trace identity,
80+
exact parent ids, and non-empty labels.
81+
- Detail below aggregate execution remains unavailable by design; a future per-task proposal must
82+
define and prove its own bound rather than extending this decision implicitly.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Otelite reconciliation hierarchy proof
2+
3+
2026-08-26. End-to-end evidence for the bounded semantic hierarchy in
4+
[decision 0004](../.decisions/0004-semantic-reconcile-trace-hierarchy.md).
5+
6+
## Question
7+
8+
One real `st2 up --catalog <empty> --once` invocation can export a non-flat reconcile trace over
9+
OTLP/HTTP JSON while retaining exactly one compatibility root, one metric set, and one correlated
10+
completion log. Every semantic child should be a direct root child with the same trace id and a
11+
non-empty bounded `span.label`.
12+
13+
## Method
14+
15+
Ran the repository integration test against the flake-provided real `otelite` receiver:
16+
17+
```console
18+
nix develop -c cargo test --test otel_export -- --nocapture
19+
```
20+
21+
The test launches the real `st2` binary through `otelite run --protocol http/json`, supplies an
22+
empty catalog, decodes `traces.ndjson`, `metrics.ndjson`, and `logs.ndjson`, and asserts ids and
23+
attributes from the raw OTLP export request rather than an in-memory tracing subscriber.
24+
25+
## Result
26+
27+
The command passed both integration tests (`2 passed; 0 failed; 0 ignored`). The exported trace
28+
for the endpoint-enabled invocation contained exactly six spans:
29+
30+
```text
31+
st2.reconcile_pass span.label=catalog
32+
├── st2.catalog.lock span.label=shared
33+
├── st2.catalog.discover span.label=catalog
34+
├── st2.catalog.materialize span.label=catalog
35+
├── st2.runtime.observe span.label=all sessions
36+
└── st2.reconcile.execute span.label=apply plan
37+
```
38+
39+
The decoded/raw assertions proved:
40+
41+
- exactly one `st2.reconcile_pass` root;
42+
- every listed child occurred exactly once;
43+
- every child's `parentSpanId` equaled the root `spanId`;
44+
- every child's `traceId` equaled the root `traceId`;
45+
- every root and child had a non-empty `span.label`;
46+
- `st2.hooks.verify` was absent because an empty catalog has no hook consumer;
47+
- the pre-existing one reconcile counter point, one reconcile duration sample, one completion log
48+
correlated to the root trace/span ids, and no `BatchLogProcessor.Emit.AfterShutdown` assertions
49+
remained green;
50+
- the endpoint-unset invocation completed and left traces, metrics, and logs absent or empty.
51+
52+
## Conclusion
53+
54+
The aggregate semantic hierarchy is visible in real OTLP output, direct parentage is stable, and
55+
conditional omissions represent operations that did not occur. The same test is the durable CI
56+
reproduction required by O11Y-R03.
57+
58+
## VRS Impact
59+
60+
The hierarchy, labels, attributes/status rules, path applicability, rejected spans, and disabled
61+
construction guard are normative in [the specification](../spec.md#reconciliation-trace-hierarchy).
62+
No requirement text changes: the evidence strengthens O11Y-R01, O11Y-R02, and O11Y-R03 without
63+
changing their scope.

docs/vrs/06-observability/spec.md

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ One module, `src/telemetry.rs`, owns init and teardown via `Telemetry::init(unit
4848
- **Endpoint**: none configured in code. The exporter resolves `OTEL_EXPORTER_OTLP_ENDPOINT` and
4949
related `OTEL_*` variables from the environment automatically. Unset → no SDK provider or
5050
exporter is built (R02); since PR3 the human-readable stderr `tracing` layer still runs so
51-
diagnostics stay visible — see [Log bridge](#log-bridge-pr3).
51+
diagnostics stay visible — see [Log bridge](#log-bridge-pr3-landed).
5252
- **Protocol**: HTTP JSON (`http-json` + protobuf-free wire), batch exporter, targeting the local
5353
Alloy forwarder at `127.0.0.1:4318` by convention.
5454
- **Resource**: `service.name` = `st2-<unit>` selected per entrypoint (below; `src/main.rs`
@@ -69,28 +69,62 @@ One module, `src/telemetry.rs`, owns init and teardown via `Telemetry::init(unit
6969
| One-shot CLI invocations | `st2-cli` |
7070
| Hook executions (`st2 driver claude-observe`; other hook surfaces not instrumented yet) | `st2-hook` |
7171

72-
## Trace roots
72+
## Reconciliation trace hierarchy
7373

74-
Instrumented via the `tracing` facade since PR3 (`tracing::info_span!`, not manual SDK tracer
75-
calls), one root span per unit of work:
74+
Instrumented exclusively through the `tracing` facade (`tracing::info_span!` plus
75+
`tracing-opentelemetry` status extension), one bounded trace represents one reconcile pass:
7676

77-
- **Supervisor loop pass** — each iteration of the `up_loop_until` loop (`src/run.rs`,
78-
`up_loop_until`) wraps one reconcile pass in a span named `st2.reconcile_pass` with field
79-
`st2.host`; after the pass it records `st2.crash_loops` and `st2.unparked` counts.
80-
- **One-shot up**`up_once` (`src/run.rs`, `up_once`) wraps its single pass in the same
81-
`st2.reconcile_pass` shape.
82-
- **Single-file spec path**`reconcile_pass_specs_with_sessions` emits the same root span.
83-
84-
A deterministic INFO event (target `st2`, message `reconcile pass complete`, `result` =
85-
`pass`\|`fail`) closes every pass so log-based assertions need no fault injection.
86-
87-
Not yet instrumented (follow-ups, not PR1 scope):
88-
89-
- Provider session lifecycles (claude / codex / opencode spawn, attach, teardown) beyond the
90-
PR2 launch/reap counters, exec sidecars (`src/exec_backend.rs`).
77+
```
78+
st2.reconcile_pass
79+
├── st2.catalog.lock
80+
├── st2.catalog.discover
81+
├── st2.hooks.verify # only when a consumer requires hooks
82+
├── st2.catalog.materialize
83+
├── st2.runtime.observe # omitted for an externally supplied snapshot
84+
└── st2.reconcile.execute
85+
```
9186

92-
Span names follow the central `01-conventions` rules (`span.label` discipline included). Names are
93-
registered st2-side; this list plus PR2's metric set is that registry's seed.
87+
`st2.reconcile_pass` remains the compatibility root at the supervisor-loop, one-shot catalog,
88+
selected-task, and single-file-spec sites. Its `span.label` and `st2.reconcile.path` are the enum
89+
`catalog | selected | spec`. Root outcome attributes are `st2.host`, `st2.crash_loops`,
90+
`st2.unparked`, `st2.report.errors`, `st2.report.warnings`, `st2.reconcile.skipped`, and
91+
`st2.result = pass | fail`; non-empty errors set OTel status `ERROR`. A deterministic INFO event
92+
(target `st2`, message `reconcile pass complete`, `result = pass | fail`) closes every root so
93+
log-based assertions need no fault injection.
94+
95+
| Span name | Parent | `span.label` | Operation boundary | Attributes and status | Path applicability |
96+
| --- | --- | --- | --- | --- | --- |
97+
| `st2.reconcile_pass` | none | `catalog` \| `selected` \| `spec` | One complete reconcile pass | Root attributes above; `ERROR` when the pass returns/collects an error | Catalog loop/once, selected task, spec loop/once |
98+
| `st2.catalog.lock` | `st2.reconcile_pass` | `shared` | Shared catalog-authoring lock acquisition | `st2.result`; `ERROR` on acquisition failure | Catalog, selected |
99+
| `st2.catalog.discover` | `st2.reconcile_pass` | `catalog` | Recursive desired-state snapshot | `st2.catalog.spec_count`, report warning/error counts, `st2.result`; `ERROR` when discovery reports errors even though the pass may continue | Catalog, selected |
100+
| `st2.hooks.verify` | `st2.reconcile_pass` | `lifecycle hooks` | Required lifecycle-hook receipt/set verification | `st2.hooks.consumer = codex \| pi \| codex+pi`, `st2.result`; `ERROR` on verification failure | Catalog or selected, only when required |
101+
| `st2.catalog.materialize` | `st2.reconcile_pass` | `catalog` \| `selected owner` | Aggregate catalog/selected-owner materialization call | Materialization failure and report warning/error counts, `st2.result`; `ERROR` when materialization reports errors | Catalog, selected |
102+
| `st2.runtime.observe` | `st2.reconcile_pass` | `all sessions` | Authoritative `Runner::list_sessions` call | `st2.runtime.session_count`, `st2.result`; `ERROR` on list failure | Catalog, selected, spec; omitted by `_with_sessions` because that snapshot is external |
103+
| `st2.reconcile.execute` | `st2.reconcile_pass` | `apply plan` | Aggregate mutation call around `execute_with_presentation_cursor` | Plan launch/GC/teardown counts, newly added report warning/error counts, `st2.result`; `ERROR` only when execution adds errors | Catalog, selected, spec |
104+
105+
Every first-party root and child has a non-empty `span.label`. The exporter-enabled
106+
`AtomicBool` in `src/telemetry.rs` is the hierarchy gate; `tracing::enabled!` is insufficient
107+
because the stderr formatter remains installed without an endpoint. When the tracer exporter is
108+
unset, child constructors return before span construction, label handling, collection allocation,
109+
or count inspection. All children are aggregates and trace volume is bounded by the table.
110+
111+
Attribute policy follows the central `01-conventions` contract:
112+
113+
| Attribute family | Value type | Cardinality | Privacy | Metric-label policy |
114+
| --- | --- | --- | --- | --- |
115+
| `span.label` | enum string | bounded | public | forbidden |
116+
| `st2.reconcile.path`, `st2.result`, `st2.hooks.consumer` | enum string | tiny/bounded | public | spanmetrics-only |
117+
| All `*_count`, `st2.crash_loops`, `st2.unparked`, `st2.report.errors`, `st2.report.warnings` | integer | bounded numeric | public | forbidden |
118+
| `st2.reconcile.skipped` | boolean | tiny | public | spanmetrics-only |
119+
| `st2.host` | string | bounded fleet identity | internal | forbidden |
120+
121+
No span or status description carries an id, filesystem path, selector, or error prose.
122+
123+
Explicitly rejected spans: pure reconcile planning, identity validation,
124+
`compile_generated_tasks`, debounce, report absorption, wait/sleep, watcher callbacks, and
125+
wrapper functions. Per-task and per-owner spans are also rejected from this hierarchy: they need
126+
a separately specified hard detail budget. Provider-session lifecycles and exec sidecars remain
127+
follow-up surfaces beyond the PR2 launch/reap counters.
94128

95129
## Metrics (PR2)
96130

0 commit comments

Comments
 (0)