From 2181e8861fd99d4d62a019fff86fd9d4a3d4e443 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:34:07 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(otel):=20first-class=20OpenTelemetry?= =?UTF-8?q?=20export=20=E2=80=94=20traces,=20otelite=20CI=20gate,=20o11y?= =?UTF-8?q?=20VRS=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/telemetry.rs: OTLP/HTTP JSON exporter behind OTEL_EXPORTER_OTLP_ENDPOINT, hard no-op when unset; service.name per process unit (st2-supervisor/st2-cli), service.version from the build stamp, host.name from detect_host. - run.rs: st2.reconcile_pass root span per supervisor pass and up_once with st2.host/st2.crash_loops/st2.unparked attributes. - service.rs: captured ambient OTEL_* vars serialize into systemd Environment= lines (injectable, unit-tested). - tests/otel_export.rs + checks.otel-export: end-to-end proof exporting real st2 spans into an otelite receiver (effect-utils flake package); gate is non-vacuous (no skip escape in CI). - docs/vrs/06-observability/: requirements, spec, decisions, experiment evidence for the all-signals PR stack (metrics and log bridge follow). opentelemetry-otlp pinned to http-json + reqwest-blocking-client: enabling both reqwest client features compiles but fails at runtime with NoHttpClient. 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@929dc21 --- Cargo.lock | 1131 +++++++++++++++-- Cargo.toml | 3 + .../0001-all-signals-via-pr-stack.md | 53 + ...bsystem-placement-and-central-reference.md | 42 + .../0003-blocking-http-json-exporter.md | 49 + .../2026-08-25-rust-to-otelite-capture.md | 84 ++ docs/vrs/06-observability/open-questions.md | 28 + docs/vrs/06-observability/requirements.md | 60 + docs/vrs/06-observability/spec.md | 133 ++ flake.lock | 162 ++- flake.nix | 37 + src/lib.rs | 1 + src/main.rs | 14 +- src/run.rs | 50 +- src/service.rs | 88 +- src/telemetry.rs | 77 ++ tests/otel_export.rs | 122 ++ 17 files changed, 2045 insertions(+), 89 deletions(-) create mode 100644 docs/vrs/06-observability/.decisions/0001-all-signals-via-pr-stack.md create mode 100644 docs/vrs/06-observability/.decisions/0002-subsystem-placement-and-central-reference.md create mode 100644 docs/vrs/06-observability/.decisions/0003-blocking-http-json-exporter.md create mode 100644 docs/vrs/06-observability/.experiments/2026-08-25-rust-to-otelite-capture.md create mode 100644 docs/vrs/06-observability/open-questions.md create mode 100644 docs/vrs/06-observability/requirements.md create mode 100644 docs/vrs/06-observability/spec.md create mode 100644 src/telemetry.rs create mode 100644 tests/otel_export.rs diff --git a/Cargo.lock b/Cargo.lock index 703fe975..9da83bd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,12 +70,35 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.1" @@ -100,6 +123,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" @@ -120,7 +149,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -248,6 +277,23 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "equivalent" version = "1.0.2" @@ -270,6 +316,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -279,6 +334,78 @@ dependencies = [ "libc", ] +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -289,6 +416,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -297,8 +436,8 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -313,6 +452,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.5.0" @@ -323,6 +468,29 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" @@ -338,6 +506,153 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -368,18 +683,44 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "kdl" version = "6.7.1" @@ -424,6 +765,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.33" @@ -507,118 +854,360 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "opentelemetry" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6" dependencies = [ - "unicode-ident", + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", ] [[package]] -name = "quote" -version = "1.0.47" +name = "opentelemetry-http" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d" dependencies = [ - "proc-macro2", + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", ] [[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.10.2" +name = "opentelemetry-otlp" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b" dependencies = [ - "chacha20", - "getrandom", - "rand_core", + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "serde_json", + "thiserror", + "tracing", ] [[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rustix" -version = "1.1.4" +name = "opentelemetry-proto" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "2e046fd7660710fe5a05e8748e70d9058dc15c94ba914e7c4faa7c728f0e8ddc" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", + "base64", + "hex", + "opentelemetry", + "opentelemetry_sdk", + "prost", + "serde", + "tonic", ] [[package]] -name = "same-file" -version = "1.0.6" +name = "opentelemetry_sdk" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b" dependencies = [ - "winapi-util", + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.5", + "serde_json", + "thiserror", ] [[package]] -name = "serde" -version = "1.0.229" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "serde_core" -version = "1.0.229" +name = "pin-project" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ - "serde_derive", + "pin-project-internal", ] [[package]] -name = "serde_derive" -version = "1.0.229" +name = "pin-project-internal" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 2.0.119", ] [[package]] -name = "serde_json" -version = "1.0.151" +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "serde_spanned" -version = "1.1.1" +name = "potential_utf" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ - "serde_core", + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", ] [[package]] @@ -643,6 +1232,28 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "st2" version = "0.1.0" @@ -654,6 +1265,9 @@ dependencies = [ "kdl", "libc", "notify", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "serde", "serde_json", "sha2", @@ -671,6 +1285,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -699,6 +1319,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -706,7 +1346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -732,6 +1372,40 @@ dependencies = [ "syn 3.0.2", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -771,6 +1445,109 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tonic" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +dependencies = [ + "async-trait", + "base64", + "bytes", + "http", + "http-body", + "http-body-util", + "percent-encoding", + "pin-project", + "prost", + "tokio-stream", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.30.0" @@ -782,7 +1559,7 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.10.2", "sha1", "thiserror", ] @@ -805,6 +1582,24 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -827,12 +1622,95 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -946,6 +1824,115 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index f32814f2..4d420f14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,9 @@ sha2 = "0.10" st2-wire = { path = "crates/st2-wire" } tempfile = "3" toml = "0.9" +opentelemetry = "0.30" +opentelemetry-otlp = { version = "0.30", default-features = false, features = ["http-json", "reqwest-blocking-client", "internal-logs"] } +opentelemetry_sdk = "0.30" tungstenite = "0.30" [dev-dependencies] diff --git a/docs/vrs/06-observability/.decisions/0001-all-signals-via-pr-stack.md b/docs/vrs/06-observability/.decisions/0001-all-signals-via-pr-stack.md new file mode 100644 index 00000000..61b24329 --- /dev/null +++ b/docs/vrs/06-observability/.decisions/0001-all-signals-via-pr-stack.md @@ -0,0 +1,53 @@ +# All three signals via a three-PR gh stack + +Status: draft + +Recorded 2026-08-25 from the aligned observability interview (axe decision catalog Q1 + Q3). + +## Context + +st2 needs OpenTelemetry instrumentation, but scope and delivery were open: + +- **Scope**: traces only (the classic first step), or all three OTel signals? +- **Delivery**: one large PR, or a stack? + +Arguments for traces-first: smallest diff, fastest feedback, metrics/logs can wait. Arguments for +one PR: single review surface. Against both: the exporter/provider plumbing is the hard part and +is shared by every signal; once it lands, metrics and logs are incremental. Deferring them +invites "traces shipped, rest never happens" — and the CI proof obligation (otelite capture + +assertions) is signal-generic anyway. + +## Decision + +**Q1 — Scope**: all three signals (traces, metrics, logs) are the target. Not traces-only; not +traces-first-with-maybe-later. + +**Q3 — Delivery**: a gh stack of three PRs: + +1. SDK init + OTLP/HTTP-JSON exporter + trace spans (+ this VRS tree); +2. metrics; +3. log bridge. + +Each PR lands green independently; 2 and 3 build on 1's shared plumbing +(provider/resource/exporter) only. + +## Alternatives considered + +- **Traces-first, defer the rest** — rejected: defers most of the value (rates/durations live in + metrics; correlated diagnostics in logs) for no real risk reduction, since the risky part + (feature set, sync export, flush-at-exit) is identical across signals. +- **Single PR** — rejected: couples an unreviewable diff (instrumentation across `run.rs`, + `exec_backend.rs`, `hooks.rs`, plus unit changes and test harness) to the plumbing; a regression + in any slice blocks all of it. +- **Spool-files instead of direct OTLP export** — writing telemetry records to local spool files + for a separate shipper to forward — rejected: adds a moving part st2 must own (rotation, + retention, crash-safety) to solve a problem the fleet pipeline already solves at + `127.0.0.1:4318`; the ambient-endpoint no-op contract would need re-inventing. + +## Consequences + +- The done-condition (CI-proven signals, zero-overhead no-op, unit env propagation, complete VRS + tree) is fully met only after PR3; PR1 alone meets it for traces. +- Reviewers see plumbing once, then small incremental slices. +- The stack order fixes the open-question deadlines: metric set must settle before PR2 opens, log + bridge approach before PR3 ([open questions](../open-questions.md)). diff --git a/docs/vrs/06-observability/.decisions/0002-subsystem-placement-and-central-reference.md b/docs/vrs/06-observability/.decisions/0002-subsystem-placement-and-central-reference.md new file mode 100644 index 00000000..c2fd6241 --- /dev/null +++ b/docs/vrs/06-observability/.decisions/0002-subsystem-placement-and-central-reference.md @@ -0,0 +1,42 @@ +# Numbered subsystem under st2's VRS root, referencing dotfiles' central tree + +Status: draft + +Recorded 2026-08-25 from the aligned observability interview (axe decision catalog Q2). + +## Context + +Observability semantics could live in three places: + +1. A numbered subsystem directory under st2's existing `docs/vrs` root, alongside + [01-ding](../../01-ding/) through [05-harness-state](../../05-harness-state/). +2. A standalone documentation root inside st2, separate from the VRS hierarchy. +3. Only in the fleet-wide dotfiles context `observability` tree, with st2 documenting nothing of + its own. + +Fleet-wide naming/provenance rules and producer obligations genuinely belong to dotfiles' +central tree (`01-conventions`, `09-integration/spec.md`). But st2-specific decisions — crate +feature set, trace roots in `src/run.rs`, unit env propagation — have no home there and would be +invisible to anyone working in this repo. + +## Decision + +This tree is a **numbered subsystem under st2's existing `docs/vrs` root**: `06-observability` +(Q2). It follows the established subsystem shape — requirements/spec/open-questions plus `.decisions/` +and `.experiments/`. + +Fleet-wide semantics stay owned centrally by dotfiles context `observability` (`01-conventions` +for naming/provenance/span-label rules; `09-integration/spec.md` for the six producer +obligations). This tree **references them and does not duplicate them**: it defines only what is +st2-side — registered span/metric names, concrete `service.name` values, the crate stack, and the +CI proof strategy. Central obligations that remain cross-repo work (registry entry, dashboard, +census subject) are named explicitly as deferred in O11Y-R08 rather than restated here. + +## Consequences + +- st2 contributors find their telemetry contract next to the code it describes, in the same + conventions as every other subsystem. +- One authority per rule: central rules are cited, never copied; drift between fleet convention + and st2 usage shows up as a broken reference, not divergent prose. +- Renumbering risk exists if another subsystem claims 06 first; the number carries no meaning + beyond ordering, so a rename is mechanical. diff --git a/docs/vrs/06-observability/.decisions/0003-blocking-http-json-exporter.md b/docs/vrs/06-observability/.decisions/0003-blocking-http-json-exporter.md new file mode 100644 index 00000000..35cd628e --- /dev/null +++ b/docs/vrs/06-observability/.decisions/0003-blocking-http-json-exporter.md @@ -0,0 +1,49 @@ +# Blocking HTTP-JSON exporter, single-client feature set, explicit flush at exit + +Status: draft + +Recorded 2026-08-25 from the aligned observability interview, backed by prototype evidence +([../.experiments/2026-08-25-rust-to-otelite-capture.md](../.experiments/2026-08-25-rust-to-otelite-capture.md)). + +## Context + +The exporter design had to satisfy four constraints simultaneously: + +- Fleet pipeline is **OTLP/HTTP JSON only** — no gRPC clients anywhere. +- st2's process model is **synchronous**: there is no tokio reactor in the supervisor or CLI. +- Telemetry must be a **zero-overhead no-op** when `OTEL_EXPORTER_OTLP_ENDPOINT` is unset. +- Spans from short-lived CLI runs must actually arrive — batched exports die with the process if + nobody flushes. + +Two traps surfaced during prototyping: + +1. **The feature-interaction trap.** `opentelemetry-otlp` 0.30 with *both* `reqwest-client` and + `reqwest-blocking-client` enabled (the defaults include blocking) compiles cleanly but fails + at runtime with `NoHttpClient`: all three client-selection cfg arms require the absence of the + other client feature. The failure appears only when the first span exports. +2. **The async-batch trap.** The default async batch exporter requires a tokio runtime; under + st2's sync process model it panicked at export time. + +## Decision + +st2 uses `opentelemetry-otlp` 0.30 with `default-features = false` and exactly +`features = ["http-json", "reqwest-blocking-client", "internal-logs"]`. Exactly one reqwest +client feature is ever enabled. + +The blocking client backs the **batch exporter**, with explicit `force_flush` + `shutdown` +registered at process exit so short-lived CLI spans reach the collector. + +Endpoint configuration stays ambient: the exporter resolves `OTEL_EXPORTER_OTLP_ENDPOINT` itself, +and init checks it once before installing any SDK object — unset means no provider, no threads, +no allocation (O11Y-R02). + +## Consequences + +- No tokio dependency is pulled into st2's runtime path for telemetry. +- The compile-clean/runtime-dead feature combination is structurally excluded by pinning the + exact feature list in `Cargo.toml`; any future feature addition must re-check the cfg-arm + interaction. +- Export happens on st2's own threads via the blocking client; a slow local Alloy forwarder can + stall an exporting thread up to the client timeout. Accepted for now — the forwarder is + loopback-local; revisit only if real stalls appear. +- `internal-logs` keeps exporter failures diagnosable instead of silently dropped. diff --git a/docs/vrs/06-observability/.experiments/2026-08-25-rust-to-otelite-capture.md b/docs/vrs/06-observability/.experiments/2026-08-25-rust-to-otelite-capture.md new file mode 100644 index 00000000..909de136 --- /dev/null +++ b/docs/vrs/06-observability/.experiments/2026-08-25-rust-to-otelite-capture.md @@ -0,0 +1,84 @@ +# Rust sync exporter to otelite capture, proven end-to-end + +2026-08-25. Prototype run by the parent agent before implementation begins. Question, method, and +result recorded here so PR1's crate choices rest on evidence, not hope. + +## Question + +Can a synchronous Rust program using `opentelemetry-otlp` 0.30 export spans over OTLP/HTTP JSON +into an `otelite` capture receiver — without a tokio runtime — and can the result be asserted +with `inspect`? Which crate feature set actually works? + +## Method + +A minimal sync Rust program: + +- `opentelemetry` + `opentelemetry-sdk` + `opentelemetry-otlp`, HTTP-JSON wire; +- two spans emitted under distinct `service.name` resource values; +- ambient `OTEL_EXPORTER_OTLP_ENDPOINT` pointed at a local `otelite capture` instance. + +Receiver: `otelite` (`@overeng/utils-dev/otelite` on effect-utils main, exposed as the +effect-utils flake package output `otelite`) — native axum/tonic OTLP receiver accepting +`/v1/{traces,metrics,logs}` in JSON and protobuf from any process. Capture mode runs +receiver-only until SIGINT/SIGTERM/stdin EOF; `inspect` summarizes and asserts on the capture. + +## Result + +- Both spans were received over HTTP JSON into the capture; `otelite inspect` showed them grouped + by `service.name`. End-to-end path proven: Rust sync → otlp 0.30 → HTTP JSON → otelite. +- The ambient-endpoint contract holds: the program set no endpoint in code; exporter resolution + picked up `OTEL_EXPORTER_OTLP_ENDPOINT`. + +### Finding 1: the NoHttpClient feature trap + +`opentelemetry-otlp` 0.30 with **both** `reqwest-client` and `reqwest-blocking-client` features +enabled compiles cleanly and fails at runtime with `NoHttpClient`. Cause: all three client- +selection cfg arms require the *absence* of the competing client feature, so no client is ever +constructed. Defaults include blocking alongside async, so the naive dependency line hits this. + +Working feature set (the one PR1 pins): + +``` +default-features = false, features = ["http-json", "reqwest-blocking-client", "internal-logs"] +``` + +### Finding 2: async batch export needs a reactor + +The default async batch exporter panicked under st2's sync process model — there is no tokio +reactor to drive it. The blocking client + batch exporter combination works. Consequence carried +into [decision 0003](../.decisions/0003-blocking-http-json-exporter.md): blocking client chosen, +with batch exporter plus explicit `force_flush`/`shutdown` at exit. + +### Caveat: otelite capture stdin EOF kills it + +`otelite capture` treats **stdin EOF as termination**. A test harness spawning capture with +stdin closed or `/dev/null` loses the receiver mid-test. The planned cargo integration tests must +hold the child's stdin open (held-open pipe) until the capture window ends. Recorded in the +[specification](../spec.md#testing-strategy). + + +## Conclusion + +A synchronous Rust program exports spans end-to-end into an `otelite` capture receiver over +OTLP/HTTP JSON with no tokio runtime, and `otelite inspect` asserts on them grouped by +`service.name`. Two constraints are load-bearing: exactly one reqwest client feature may be +enabled (the both-features combination compiles but dies with `NoHttpClient` at export time), +and the batch exporter must be driven by the blocking client since there is no reactor. A test +harness must also hold the capture child's stdin open — EOF terminates it mid-test. + +## VRS Impact + +- Evidence behind [decision 0003](../.decisions/0003-blocking-http-json-exporter.md) and + requirement O11Y-R07 (sync process model): blocking client + batch exporter + explicit flush + at exit is the proven configuration, pinned verbatim in the [specification](../spec.md). +- Ambient-endpoint resolution confirmed for O11Y-R02 before any code was written. +- The stdin-EOF caveat is recorded in the specification's testing strategy and is baked into + the integration-test harness design. Metrics/logs ingestion remains unproven until PR2/PR3; + no requirement text changes. + +## Limits + +- Prototype exercised traces only; metrics and log ingestion through the same receiver are + untested until PR2/PR3 (otelite accepts all three endpoints, but assertions must be proven). +- Single-run evidence; the CI integration tests are themselves the durable reproduction, which is + why R03 makes them part of the done-condition. diff --git a/docs/vrs/06-observability/open-questions.md b/docs/vrs/06-observability/open-questions.md new file mode 100644 index 00000000..ced4a64a --- /dev/null +++ b/docs/vrs/06-observability/open-questions.md @@ -0,0 +1,28 @@ +# Observability open questions + +Kept minimal; each blocks exactly one delivery slice, not the tree. + +- **PR2 metric set.** Which metrics exactly, and their labels/cardinality budget? The shape + (counters for passes/spawns/reaps/hooks/errors, histograms for reconcile duration and session + start latency) is proposed in the [specification](spec.md#metrics-pr2); concrete instrument + names and label sets need one pass over `src/run.rs` before PR2 opens. +- **`st2 up ` span coverage.** The catalog reconcile paths are instrumented + (`st2.reconcile_pass` at the `up_loop_until` loop pass and in `up_once`), but the single-file + spec path (`reconcile_pass_specs` / `reconcile_pass_specs_with_sessions`, `src/run.rs`) emits + no spans yet. Same span shape applies; needs one pass over that call chain before it can join + the registry. +- **Remaining R04 resource attributes.** Only `service.name`, `service.version`, and `host.name` + are set today; `service.namespace`, `service.instance.id`, `sk.site`, `sk.role`, and + `deployment.environment.name` still need wiring (and a decision on which come from ambient + `OTEL_RESOURCE_ATTRIBUTES` versus st2-side detection). +- **PR3 log bridge approach.** Bridge existing diagnostic output through an OTel logs emitter + versus introducing a structured logging facade (`tracing` + opentelemetry layer) and migrating + emit sites. The facade is cleaner long-term but touches more call sites; decide when PR1's + trace plumbing is in tree and the real migration cost is visible. +- **Unit env mechanism.** `Environment=` lines vs `EnvironmentFile=` for `OTEL_*` propagation + ([specification](spec.md#systemd-unit-propagation)). Lines are simple and match the existing + PATH/PTY_ROOT pattern; a file scales to many variables and lets operators edit without + reinstalling the unit. Decide at PR1 implementation time based on how many variables survive + filtering. +- **Sampling.** Default is always-on given st2's low event volume. If supervisor-loop span volume + proves noisy in Grafana, revisit parent-based sampling ratios — not before there is data. diff --git a/docs/vrs/06-observability/requirements.md b/docs/vrs/06-observability/requirements.md new file mode 100644 index 00000000..c3b75e13 --- /dev/null +++ b/docs/vrs/06-observability/requirements.md @@ -0,0 +1,60 @@ +# Observability requirements + +st2 emits OpenTelemetry signals about its own supervision work. This tree defines what st2's +telemetry must do and how it is proven. It follows the root [vision](../vision.md) and refines the +supervision subjects of the root [requirements](../requirements.md). It does not define fleet-wide +naming, provenance, or pipeline semantics — those are owned centrally by the dotfiles context +`observability` tree (`01-conventions` for naming/provenance/span-label rules, `09-integration` +for producer obligations, `otel-stack.md` for the LGTMP pipeline), which this tree references. + +## Context + +Until PR1, st2 had zero telemetry: no `tracing`, logging, or opentelemetry dependencies in +`Cargo.toml`, and diagnostics were bare `println!`/`eprintln!`. PR1 introduces trace export +([specification](spec.md)); metrics (PR2) and the log bridge (PR3) are still open. Durable +records — events, the sent ledger, +harness-state ([05-harness-state](../05-harness-state/)) — capture *what happened* but not *how +long it took*, *how often*, or *in what order across processes*. A supervisor that wedges in a +reconcile pass or a hung provider-session probe is invisible until a human reads a log file. + +The fleet already runs an OTLP pipeline: producers ship OTLP/HTTP JSON to a per-host Alloy +forwarder at `127.0.0.1:4318`, which forwards to dev3 LGTMP and Grafana/gcx. st2 joins that +pipeline as one more producer; it does not invent its own. + +## Requirements + +- **O11Y-R01 Three signals:** st2 produces traces, metrics, and logs through OpenTelemetry. + Traces cover the supervision control flow (roots listed in the + [specification](spec.md)); metrics cover rates and durations of recurring passes; + logs replace ad-hoc diagnostics on the paths where correlation matters. All three is the target, + not traces alone. +- **O11Y-R02 No-op when unset:** Signals are emitted only when `OTEL_EXPORTER_OTLP_ENDPOINT` is + set. When unset, telemetry is a zero-overhead no-op: no exporter threads, no network calls, no + measurable cost on hot loops. Ambient configuration is honored automatically by exporter + resolution; st2 adds no proprietary configuration surface beyond standard `OTEL_*` variables. +- **O11Y-R03 CI-proven:** The done-condition is proven in CI, not asserted. Integration tests run + st2 against an `otelite` capture receiver and assert emitted spans/signals via its inspect + mode. A build whose telemetry regresses to silence fails CI. +- **O11Y-R04 Provenance:** Every exported signal carries the fleet resource-attribute set: + `service.name`, `service.namespace`, `service.instance.id`, `host.name`, `sk.site`, `sk.role`, + and `deployment.environment.name`. Registered names are defined st2-side (this tree), not + borrowed. `service.version` derives from the build stamp (`src/version.rs` reading + `CLI_BUILD_STAMP`), the same identity the fleet `cli-version` shape carries. +- **O11Y-R05 Service naming by process unit:** `service.name` names the st2 process unit, not the + repo — e.g. `st2-supervisor`, `st2-cli`, `st2-hook` — so a Grafana query groups a supervisor's + lifetime separately from one-shot CLI invocations, per the central `01-conventions` rules. +- **O11Y-R06 Unit environment propagation:** The systemd supervisor unit propagates the + operator's `OTEL_*` environment into the service: `src/service.rs` serializes the `OTEL_*` + variables present in the launching environment into `Environment=` lines alongside the + existing `PATH`/`PTY_ROOT` serialization, so `st2 up --install-unit` preserves ambient + telemetry configuration (R02) under systemd. +- **O11Y-R07 Sync process model:** Telemetry must not require an async runtime. st2's process + model is synchronous (no tokio reactor); the exporter path must work under blocking clients. +- **O11Y-R08 Conformance posture:** Fleet-integration obligations are met as far as the st2 side + allows: resource attributes (R04), naming (R05), OTLP endpoint via ambient env (R02). The + remaining central obligations — the `telemetry.contract.ts` registry entry, the Grafana + dashboard, and coverage-census subject registration — live in dotfiles' central observability + tree and are explicitly deferred as cross-repo follow-up work, not part of st2's delivery. + +The [specification](spec.md) owns the crate stack, exporter configuration, trace roots, and PR +stack. Open items are tracked in [open-questions](open-questions.md). diff --git a/docs/vrs/06-observability/spec.md b/docs/vrs/06-observability/spec.md new file mode 100644 index 00000000..a9c1d90a --- /dev/null +++ b/docs/vrs/06-observability/spec.md @@ -0,0 +1,133 @@ +# Observability specification + +This document owns the mechanism behind [requirements](requirements.md): the crate stack, exporter +configuration, span roots, unit propagation, test strategy, and delivery order. Naming, +provenance, and span-label rules are referenced from the dotfiles context `observability` tree +(`01-conventions`); the six producer obligations from its `09-integration` spec. st2-side +obligations land here; registry/dashboard/census obligations are deferred cross-repo +(O11Y-R08). + +## Crate stack + +``` +opentelemetry = "0.30" +opentelemetry_sdk = "0.30" +opentelemetry-otlp = { version = "0.30", default-features = false, + features = ["http-json", "reqwest-blocking-client", "internal-logs"] } +``` + +The SDK needs no runtime feature: the batch exporter is driven by the blocking reqwest client on +st2's own threads, so no `rt-tokio` (or any async-runtime) feature is enabled. + +The otlp feature set is load-bearing, not stylistic: + +- **No gRPC client.** The fleet pipeline is OTLP/HTTP JSON only (`otel-stack.md`); no gRPC clients + anywhere. +- **Blocking reqwest client only.** With both `reqwest-client` and `reqwest-blocking-client` + enabled (the defaults include blocking alongside async), the crate compiles but every runtime + client-selection cfg arm requires *not*-having the other feature, so export fails with + `NoHttpClient` at span-export time. Exactly one of the two must be enabled. +- **Blocking chosen over async** because st2 has no tokio reactor; the async batch exporter + panicked without one. See the prototype evidence + ([.experiments/2026-08-25-rust-to-otelite-capture.md](.experiments/2026-08-25-rust-to-otelite-capture.md)). +- **`internal-logs`** keeps exporter-internal errors observable instead of swallowed. + +## Exporter and provider setup + +One module, `src/telemetry.rs`, owns init and teardown via `Telemetry::init(unit)` / +`Telemetry::shutdown()`: + +- **Endpoint**: none configured in code. The exporter resolves `OTEL_EXPORTER_OTLP_ENDPOINT` and + related `OTEL_*` variables from the environment automatically. Unset → no provider is installed + at all (R02): the guard is checked once at init, before any SDK object exists, so the unset case + allocates nothing. +- **Protocol**: HTTP JSON (`http-json` + protobuf-free wire), batch exporter, targeting the local + Alloy forwarder at `127.0.0.1:4318` by convention. +- **Resource**: `service.name` = `st2-` selected per entrypoint (below; `src/main.rs` + passes `supervisor` or `cli`), `service.version` from `crate::version::machine_version`, and + `host.name` from the existing host detection. The remaining R04 fleet attributes + (`service.namespace`, + `service.instance.id`, `sk.site`, `sk.role`, `deployment.environment.name`) are not set yet — + tracked as an [open question](open-questions.md). +- **Flush/shutdown**: `force_flush` + global `shutdown` registered to run at process exit. The + batch exporter buffers; without explicit flush at exit, tail spans of short-lived CLI runs are + lost. This pairing is required for delivery, not optional. + +### service.name values (R05) + +| Process unit | `service.name` | +| --- | --- | +| Supervisor loop (`st2 up` daemon / systemd unit) | `st2-supervisor` | +| One-shot CLI invocations | `st2-cli` | +| Hook executions (`src/hooks.rs`) — not instrumented yet; planned value | `st2-hook` | + +## Trace roots + +Instrumented in PR1, one root span per unit of work: + +- **Supervisor loop pass** — each iteration of the `up_loop_until` loop (`src/run.rs`, + `up_loop_until`) wraps one reconcile pass in a span named `st2.reconcile_pass` with attribute + `st2.host`; after the pass it records `st2.crash_loops` and `st2.unparked` counts. +- **One-shot up** — `up_once` (`src/run.rs`, `up_once`) wraps its single pass in the same + `st2.reconcile_pass` shape with `st2.host`. + +Not yet instrumented (follow-ups, not PR1 scope): + +- Provider session lifecycles (claude / codex / opencode spawn, attach, teardown), exec sidecars + (`src/exec_backend.rs`), and hooks (`src/hooks.rs`). +- The `st2 up ` path (`reconcile_pass_specs` and friends) emits no spans yet — tracked as + an [open question](open-questions.md). + +Span names follow the central `01-conventions` rules (`span.label` discipline included). Names are +registered st2-side; this list plus PR2's metric set is that registry's seed. + +## Metrics (PR2) + +Exact metric set is open ([open-questions](open-questions.md)). Shape: counters for reconcile +passes, spawns, reaps, hook runs, and errors by kind; histograms for reconcile-pass duration and +provider-session start latency. Same resource attributes, same endpoint, batch exporter shared +with traces. + +## Log bridge (PR3) + +Approach open ([open-questions](open-questions.md)): candidate is bridging the existing +diagnostic output through an OpenTelemetry logs emitter so log records join the same resource and +trace context. Ad-hoc `println!`/`eprintln!` on correlated paths migrate onto it; pure UI output +does not. + +## Systemd unit propagation + +`src/service.rs` builds the supervisor unit and serializes the operator's `OTEL_*` environment +into `Environment=` lines alongside the existing `PATH`/`PTY_ROOT` serialization, so +`st2 up --install-unit` preserves R02 (ambient endpoint) under systemd. Unit tests in +`service.rs` extend the existing serialization assertions. + +## Testing strategy + +- **Integration tests** (`tests/otel_export.rs`, cargo integration tests): the receiver is a + prebuilt `otelite` binary passed by path via `ST2_OTELITE_BIN` (the effect-utils flake package + output; gate wiring supplies it, and a gate run hard-fails without it unless + `ST2_ALLOW_OTEL_SKIP=1` explicitly allows a local skip). Each test spawns + `otelite capture` on an ephemeral port (`--http-port 0`), points the binary under test at it + via `OTEL_EXPORTER_OTLP_ENDPOINT`, drives one command, then stops the receiver by closing its + stdin — EOF flushes the capture to disk — and asserts on the captured traces (span names, + resource attributes). Precedent: dotfiles op-proxy tests use `captureEnvTrace`; dotfiles + branchy checks consume `effect-utils.packages..otelite`. + - Caveat baked into harness design: `otelite capture` treats stdin EOF as termination, so the + harness closes stdin deliberately as the stop signal rather than leaking `/dev/null`. +- **No-op proof**: a test asserts that with `OTEL_EXPORTER_OTLP_ENDPOINT` unset, the command + completes normally with no export activity — guarding R02. +- **Flake check wiring**: the check pulls effect-utils' `otelite` package output, mirroring the + branchy-check pattern, so CI proves R03 end-to-end without network access to dev3. + +## Delivery: gh stack of three PRs + +1. **PR1 — traces.** SDK init, OTLP/HTTP-JSON exporter with the exact feature set above, resource + attributes, trace roots, unit `OTEL_*` propagation, otelite-based integration tests and flake + check wiring, plus this VRS tree. +2. **PR2 — metrics.** Metric set finalized per open questions; shares provider/exporter/resource + plumbing from PR1; otelite assertions extended to metrics. +3. **PR3 — log bridge.** Approach finalized per open questions; migrates correlated diagnostics; + otelite assertions extended to logs. + +Each PR lands CI-green independently; PR2/PR3 depend on PR1's plumbing only. diff --git a/flake.lock b/flake.lock index 00c0bb3f..01e935ae 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,26 @@ { "nodes": { + "effect-utils": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "tsgo": "tsgo" + }, + "locked": { + "lastModified": 1787589117, + "narHash": "sha256-HKkUPQHdwZuoBACWPVvcgrD4C0W+0xLU5QJ7MAKGCtg=", + "owner": "overengineeringstudio", + "repo": "effect-utils", + "rev": "911e2ce0f4ac39d2b54f9ebd6df035234982f721", + "type": "github" + }, + "original": { + "owner": "overengineeringstudio", + "ref": "main", + "repo": "effect-utils", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -18,7 +39,73 @@ "type": "github" } }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "nixpkgs": { + "locked": { + "lastModified": 1786201459, + "narHash": "sha256-CiOTEjmwAmG2AWnaIno9YaCJJmpca2FXPhMAsnrolCg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "8b8c811c7c2541c30382c5de7ed26be055569c60", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "release-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgsUnstable": { + "locked": { + "lastModified": 1772773019, + "narHash": "sha256-E1bxHxNKfDoQUuvriG71+f+s/NT0qWkImXsYZNFFfCs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "aca4d95fce4914b3892661bcb80b8087293536c6", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { "locked": { "lastModified": 1784796856, "narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", @@ -57,8 +144,9 @@ }, "root": { "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs", + "effect-utils": "effect-utils", + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs_3", "pty": "pty" } }, @@ -76,6 +164,76 @@ "repo": "default", "type": "github" } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "tsgo": { + "inputs": { + "nixpkgs": "nixpkgs_2", + "nixpkgsUnstable": "nixpkgsUnstable", + "typescript-go-src": "typescript-go-src", + "typescript-src": "typescript-src" + }, + "locked": { + "lastModified": 1781687493, + "narHash": "sha256-TJ7ZYQ9b9SzUqcd0iiw9+NKM7CS4z9QaGGwSCvof9dc=", + "owner": "Effect-TS", + "repo": "tsgo", + "rev": "8d34c0a2d603a4b963b85ffccd4322c0ef74f472", + "type": "github" + }, + "original": { + "owner": "Effect-TS", + "repo": "tsgo", + "type": "github" + } + }, + "typescript-go-src": { + "flake": false, + "locked": { + "lastModified": 1781656263, + "narHash": "sha256-PkMX++B5qsHAib/VHc8QS0WAo9d/jXatlF6sUPymBN4=", + "owner": "microsoft", + "repo": "typescript-go", + "rev": "2fb5d4ce13935aef1f3c2896fad76d0ab4d43604", + "type": "github" + }, + "original": { + "owner": "microsoft", + "repo": "typescript-go", + "rev": "2fb5d4ce13935aef1f3c2896fad76d0ab4d43604", + "type": "github" + } + }, + "typescript-src": { + "flake": false, + "locked": { + "lastModified": 1775154140, + "narHash": "sha256-3WkY0R9tt5jdI2cOemHm91KztsNJYP/uuOE0SYn0SvM=", + "owner": "microsoft", + "repo": "TypeScript", + "rev": "c3bd12d888b86f676718b16e64d7d2abcb423514", + "type": "github" + }, + "original": { + "owner": "microsoft", + "repo": "TypeScript", + "rev": "c3bd12d888b86f676718b16e64d7d2abcb423514", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 5c54c30d..9e35e19d 100644 --- a/flake.nix +++ b/flake.nix @@ -8,6 +8,11 @@ # fleet-observation guarantees required by st2 reconciliation. pty.url = "github:compoundingtech/pty/504ac7332895fe1fa3767b530dcd99f091f56cda"; pty.inputs.nixpkgs.follows = "nixpkgs"; + # Shared tooling packages from overengineering: provides the `otelite` + # OTLP collector binary that the OTel export integration gate + # (`tests/otel_export.rs`, exposed as `checks.otel-export`) drives to + # prove real span export end-to-end. + effect-utils.url = "github:overengineeringstudio/effect-utils/main"; }; outputs = @@ -16,6 +21,7 @@ nixpkgs, flake-utils, pty, + effect-utils, }: flake-utils.lib.eachDefaultSystem ( system: @@ -198,6 +204,29 @@ ]; }); + # OTLP export integration gate. `tests/otel_export.rs` is skipped + # unless `ST2_OTELITE_BIN` points at a real collector, so the package's + # own test boundary never exercises span export — this dedicated + # derivation is what makes the contract non-vacuous: it pins the exact + # `otelite` build from effect-utils and does NOT set + # `ST2_ALLOW_OTEL_SKIP`, so a broken export path fails the gate instead + # of silently skipping. + st2OtelExport = st2.overrideAttrs (old: { + pname = "st2-otel-export-check"; + # The test drives `st2 up --once`, whose reconcile pass shells out to + # `pty list --json` — the same real-producer requirement as + # st2ParkedRecovery, so the packaged pty must be on the check's PATH. + nativeCheckInputs = (old.nativeCheckInputs or [ ]) ++ [ + pty.packages.${system}.default + effect-utils.packages.${system}.otelite + ]; + ST2_OTELITE_BIN = "${effect-utils.packages.${system}.otelite}/bin/otelite"; + cargoTestFlags = [ + "--test" + "otel_export" + ]; + }); + hookSuccessorSource = pkgs.runCommand "st2-hook-successor-source" { } '' cp -R ${self} $out chmod -R u+w $out @@ -234,6 +263,7 @@ checks.catalog-bootstrap = st2CatalogBootstrap; checks.message-cli = st2MessageCli; checks.parked-recovery = st2ParkedRecovery; + checks.otel-export = st2OtelExport; # Real producer-consumer contract: st2 consumes `pty list --json` from the exact pty # revision that owns fleet observation. Fake CLI fixtures below still cover malformed @@ -448,7 +478,14 @@ pkgs.rust-analyzer pkgs.git pty.packages.${system}.default + # Local runs of the OTLP export integration gate + # (`cargo test --test otel_export`) need the same collector the + # Nix check pins; `ST2_OTELITE_BIN` points at it. + effect-utils.packages.${system}.otelite ]; + # Same collector the Nix gate pins, so a bare + # `cargo test --test otel_export` in this shell runs against it. + ST2_OTELITE_BIN = "${effect-utils.packages.${system}.otelite}/bin/otelite"; }; } ); diff --git a/src/lib.rs b/src/lib.rs index 7b369714..a1b0d51c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,7 @@ pub mod run; pub mod service; pub mod status; pub mod task_inventory; +pub mod telemetry; pub mod validate; pub mod version; mod watch; diff --git a/src/main.rs b/src/main.rs index 28026fbd..0a7ce97a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -991,8 +991,20 @@ fn main() -> Result<()> { catalog_path, command, } = Cli::parse(); - initialize_catalog_env(catalog_path.as_deref())?; + let mut telemetry = + st2::telemetry::Telemetry::init(if matches!(command, Command::Up { once: false, .. }) { + "supervisor" + } else { + "cli" + }); + let result = dispatch(command, catalog_path.as_deref()); + telemetry.shutdown(); + result +} + +fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result<()> { + initialize_catalog_env(catalog_path.map(|path| path.to_path_buf()).as_deref())?; match command { Command::Ls { root } => { let root = catalog_arg(root)?; diff --git a/src/run.rs b/src/run.rs index 7eac51db..c3de81c8 100644 --- a/src/run.rs +++ b/src/run.rs @@ -25,6 +25,8 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; use std::time::{Duration, Instant}; use anyhow::Context as _; +use opentelemetry::trace::Span as _; +use opentelemetry::trace::Tracer as _; use serde::{Deserialize, Serialize}; use crate::exec_backend::ExecBackend; @@ -1723,7 +1725,12 @@ fn gate_harness_launches_on_hooks<'a, V>( pub fn up_once(root: &Path, this_host: &str, runner: &dyn Runner) -> anyhow::Result { let task_context = TaskCompileContext::current(root.to_path_buf())?; let mut debounce = LivenessDebounce::new(DEBOUNCE_GRACE); - Ok(reconcile_pass( + let tracer = opentelemetry::global::tracer("st2"); + let mut span = tracer + .span_builder("st2.reconcile_pass") + .with_attributes(vec![opentelemetry::KeyValue::new("st2.host", this_host.to_string())]) + .start(&tracer); + let report = reconcile_pass( root, this_host, &task_context, @@ -1731,7 +1738,9 @@ pub fn up_once(root: &Path, this_host: &str, runner: &dyn Runner) -> anyhow::Res &mut FlappingCap::default(), &mut debounce, &mut PresentationPatchCursor::default(), - )) + ); + span.end(); + Ok(report) } /// Like [`reconcile_pass`] but over IN-MEMORY specs (a single-file st2 spec's team) rather than a @@ -2215,15 +2224,34 @@ fn up_loop_until( loop { let mut pre = UpReport::default(); park_channel.grant_requests(&mut cap, &mut pre); - let mut report = reconcile_pass( - root, - this_host, - &task_context, - runner, - &mut cap, - &mut debounce, - &mut presentation_cursor, - ); + let mut report = { + let tracer = opentelemetry::global::tracer("st2"); + let mut span = tracer + .span_builder("st2.reconcile_pass") + .with_attributes(vec![ + opentelemetry::KeyValue::new("st2.host", this_host.to_string()), + ]) + .start(&tracer); + let pass = reconcile_pass( + root, + this_host, + &task_context, + runner, + &mut cap, + &mut debounce, + &mut presentation_cursor, + ); + span.set_attribute(opentelemetry::KeyValue::new( + "st2.crash_loops", + i64::try_from(pass.crash_loops.len()).unwrap_or(i64::MAX), + )); + span.set_attribute(opentelemetry::KeyValue::new( + "st2.unparked", + i64::try_from(pass.unparked.len()).unwrap_or(i64::MAX), + )); + span.end(); + pass + }; pre.absorb(report); report = pre; if let Some(watcher) = &mut watcher { diff --git a/src/service.rs b/src/service.rs index 40b1ec58..6eeb1f5a 100644 --- a/src/service.rs +++ b/src/service.rs @@ -45,6 +45,9 @@ pub struct ServiceSpec { /// an explicit adoption can use an existing registry without syncing pid/socket state. pty_root: Option, memory_max_mb: u64, + /// Ambient `OTEL_*` environment captured at install time and re-serialized into the unit, so + /// the supervised supervisor reaches the same OTLP endpoint as an interactive `st2 up`. + otel_env: Vec<(String, String)>, } impl ServiceSpec { @@ -55,6 +58,7 @@ impl ServiceSpec { path: impl Into, pty_root: Option, memory_max_mb: u64, + otel_env: Vec<(String, String)>, ) -> Result { if memory_max_mb == 0 { bail!("--memory-max-mb must be greater than zero"); @@ -73,6 +77,7 @@ impl ServiceSpec { path, pty_root, memory_max_mb, + otel_env, }) } @@ -92,6 +97,14 @@ impl ServiceSpec { } } +/// Ambient `OTEL_*` variables worth carrying into a unit. Captured at install time because the +/// systemd user manager has no shell to expand them from. +pub(crate) fn collect_otel_env() -> Vec<(String, String)> { + std::env::vars() + .filter(|(key, _)| key.starts_with("OTEL_")) + .collect() +} + /// `st2 service install [--catalog ] [--host H] [--pty-root PATH] /// [--memory-max-mb N]`. pub fn install( @@ -116,7 +129,7 @@ pub fn install( .with_context(|| format!("pty root {} does not exist", root.display())) }) .transpose()?; - let spec = ServiceSpec::new(exe, &catalog, host, path, pty_root, memory_max_mb)?; + let spec = ServiceSpec::new(exe, &catalog, host, path, pty_root, memory_max_mb, collect_otel_env())?; install_systemd_user(&spec)?; @@ -263,6 +276,13 @@ pub fn render_systemd_user_unit(spec: &ServiceSpec) -> String { .map(|arg| systemd_quote_arg(arg)) .collect::>() .join(" "); + let otel_env = spec + .otel_env + .iter() + .map(|(key, value)| { + format!("Environment={}\n", systemd_quote_arg(&format!("{key}={value}"))) + }) + .collect::(); format!( "[Unit]\n\ Description=st2 supervisor (st2 up)\n\ @@ -272,6 +292,7 @@ After=network.target\n\ Type=simple\n\ Environment={}\n\ {}\ +{otel_env}\ ExecStart={exec_start}\n\ Restart=on-failure\n\ RestartSec=5s\n\ @@ -332,6 +353,7 @@ mod tests { "/home/user/.cargo/bin:/home/user/.local/bin:/usr/bin", None, DEFAULT_MEMORY_MAX_MB, + Vec::new(), )?; let unit = render_systemd_user_unit(&spec); @@ -363,6 +385,7 @@ mod tests { "/usr/local/bin:/usr/bin", Some(PathBuf::from("/srv/legacy-pty")), 512, + Vec::new(), )?; let unit = render_systemd_user_unit(&spec); @@ -384,6 +407,7 @@ mod tests { "/opt/st2 tools:/usr/bin", Some(PathBuf::from("/srv/pty 100%")), 256, + Vec::new(), )?; let unit = render_systemd_user_unit(&spec); @@ -397,13 +421,13 @@ mod tests { #[test] fn zero_memory_max_is_rejected() { - let err = ServiceSpec::new("/bin/st2", "/cat", None, "/bin", None, 0).unwrap_err(); + let err = ServiceSpec::new("/bin/st2", "/cat", None, "/bin", None, 0, Vec::new()).unwrap_err(); assert!(err.to_string().contains("greater than zero")); } #[test] fn empty_path_and_relative_pty_root_are_rejected() { - let err = ServiceSpec::new("/bin/st2", "/cat", None, "", None, 1).unwrap_err(); + let err = ServiceSpec::new("/bin/st2", "/cat", None, "", None, 1, Vec::new()).unwrap_err(); assert!(err.to_string().contains("PATH cannot be empty")); let err = ServiceSpec::new( @@ -413,8 +437,66 @@ mod tests { "/bin", Some(PathBuf::from("relative")), 1, + Vec::new(), ) .unwrap_err(); assert!(err.to_string().contains("absolute")); } + + #[test] + fn unit_emits_one_quoted_environment_line_per_captured_otel_var() -> Result<()> { + let spec = ServiceSpec::new( + "/usr/local/bin/st2", + "/srv/catalog", + None, + "/usr/local/bin:/usr/bin", + Some(PathBuf::from("/srv/pty")), + DEFAULT_MEMORY_MAX_MB, + vec![ + ( + "OTEL_EXPORTER_OTLP_ENDPOINT".to_string(), + "http://127.0.0.1:4317".to_string(), + ), + ( + "OTEL_SERVICE_NAME".to_string(), + "st2 supervisor 100%".to_string(), + ), + ], + )?; + + let unit = render_systemd_user_unit(&spec); + + // One `Environment=` line per captured var. All-safe-char values stay bare (quote rule), + // values with spaces/`%` are double-quoted via systemd_quote_arg. + assert!(unit.contains("Environment=OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317")); + assert!( + unit.contains("Environment=\"OTEL_SERVICE_NAME=st2 supervisor 100%%\""), + "value with space/percent must go through systemd_quote_arg" + ); + + // Ordering: after the PTY_ROOT line, before ExecStart. + let pty_root_at = unit.find("Environment=PTY_ROOT=/srv/pty").unwrap(); + let first_otel_at = unit.find("Environment=\"OTEL_").unwrap(); + let exec_start_at = unit.find("ExecStart=").unwrap(); + assert!(pty_root_at < first_otel_at && first_otel_at < exec_start_at); + Ok(()) + } + + #[test] + fn unit_without_otel_vars_emits_no_extra_environment_lines() -> Result<()> { + let spec = ServiceSpec::new( + "/usr/local/bin/st2", + "/srv/catalog", + None, + "/usr/local/bin:/usr/bin", + None, + DEFAULT_MEMORY_MAX_MB, + Vec::new(), + )?; + + let unit = render_systemd_user_unit(&spec); + + assert!(!unit.contains("OTEL_")); + Ok(()) + } } diff --git a/src/telemetry.rs b/src/telemetry.rs new file mode 100644 index 00000000..df9f4326 --- /dev/null +++ b/src/telemetry.rs @@ -0,0 +1,77 @@ +//! OpenTelemetry export for st2, per `docs/vrs/06-observability/`. +//! +//! Zero-overhead no-op unless `OTEL_EXPORTER_OTLP_ENDPOINT` is set; the exporter then ships +//! OTLP/HTTP JSON to the fleet's local Alloy forwarder (normally `127.0.0.1:4318`). The process +//! model is sync (no tokio), so the blocking reqwest client is mandatory — enabling both +//! reqwest client features of `opentelemetry-otlp` 0.30 compiles but fails at runtime with +//! `NoHttpClient` (all client cfg arms exclude each other). + +use opentelemetry::KeyValue; +use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::trace::SdkTracerProvider; + +/// Guard holding the tracer provider for a process lifetime. Dropping it flushes and shuts the +/// exporter down so short-lived CLI invocations still deliver their spans. +pub struct Telemetry { + provider: Option, +} + +impl Telemetry { + /// Initialize telemetry for one process unit (`supervisor`, `cli`, ...). The service name + /// follows the central observability contract's process-unit boundary: `st2-`. + pub fn init(unit: &str) -> Self { + if std::env::var_os("OTEL_EXPORTER_OTLP_ENDPOINT").is_none() { + return Self { provider: None }; + } + + let exporter = match opentelemetry_otlp::SpanExporter::builder() + .with_http() + .with_protocol(opentelemetry_otlp::Protocol::HttpJson) + .build() + { + Ok(exporter) => exporter, + // Export setup must never take the runner down: telemetry is best-effort. + Err(err) => { + eprintln!("st2: otel exporter unavailable, continuing without telemetry: {err}"); + return Self { provider: None }; + } + }; + + let resource = opentelemetry_sdk::Resource::builder() + .with_service_name(format!("st2-{unit}")) + .with_attribute(KeyValue::new( + "service.version", + crate::version::machine_version(), + )) + .with_attribute(KeyValue::new("host.name", crate::run::detect_host())) + .build(); + + let provider = SdkTracerProvider::builder() + .with_batch_exporter(exporter) + .with_resource(resource) + .build(); + opentelemetry::global::set_tracer_provider(provider.clone()); + Self { + provider: Some(provider), + } + } + + /// Whether export is active (endpoint configured and exporter initialized). + pub fn enabled(&self) -> bool { + self.provider.is_some() + } + + /// Flush pending spans and stop the exporter. Safe to call multiple times. + pub fn shutdown(&mut self) { + if let Some(provider) = self.provider.take() { + let _ = provider.force_flush(); + let _ = provider.shutdown(); + } + } +} + +impl Drop for Telemetry { + fn drop(&mut self) { + self.shutdown(); + } +} diff --git a/tests/otel_export.rs b/tests/otel_export.rs new file mode 100644 index 00000000..c1249281 --- /dev/null +++ b/tests/otel_export.rs @@ -0,0 +1,122 @@ +//! E2E proof that the real `st2` binary exports OTLP/HTTP JSON spans into an otelite receiver +//! when `OTEL_EXPORTER_OTLP_ENDPOINT` is set (and stays silent without one). The receiver is the +//! `otelite` binary from effect-utils (`packages.x86_64-linux.otelite`); the flake check wires it +//! in via `ST2_OTELITE_BIN`. +//! +//! Needs `ST2_OTELITE_BIN` on a gate run — HARD failure if absent unless `ST2_ALLOW_OTEL_SKIP` +//! is set (a gate must not silently skip). + +use std::io::Read as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Spawn `otelite capture`, wait for its endpoints banner. +/// Returns (child, its stdout — keep until after `wait()` so otelite's final +/// writes don't hit a broken pipe, http endpoint). +fn spawn_capture( + otelite: &Path, + out_dir: &Path, +) -> (std::process::Child, std::process::ChildStdout, String) { + let mut child = Command::new(otelite) + .args(["capture", "--out"]) + .arg(out_dir) + .arg("--http-port") + .arg("0") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("failed to spawn otelite capture"); + + // The endpoints banner is one JSON line: {"grpc":..., "http":..., "out":..., "schema":...}. + let mut stdout = child.stdout.take().unwrap(); + let mut banner = String::new(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + assert!( + Instant::now() < deadline, + "otelite capture did not print its endpoints banner" + ); + let mut chunk = [0u8; 512]; + let n = stdout.read(&mut chunk).expect("read otelite stdout"); + if n > 0 { + banner.push_str(&String::from_utf8_lossy(&chunk[..n])); + } + if let Some(line) = banner.lines().find(|l| l.contains("otelite.endpoints")) { + let v: serde_json::Value = serde_json::from_str(line).expect("endpoints banner JSON"); + let http = v["http"].as_str().expect("http endpoint").to_string(); + return (child, stdout, http); + } + if n == 0 { + panic!("otelite capture exited before serving: {banner}"); + } + } +} + +#[test] +fn st2_exports_spans_to_otelite_when_endpoint_is_set() { + let Some(otelite) = std::env::var_os("ST2_OTELITE_BIN").map(PathBuf::from) else { + assert!( + std::env::var_os("ST2_ALLOW_OTEL_SKIP").is_some(), + "`ST2_OTELITE_BIN` not set — can't prove OTLP export. Set ST2_ALLOW_OTEL_SKIP=1 to skip." + ); + eprintln!("SKIP st2_exports_spans_to_otelite: ST2_OTELITE_BIN not set"); + return; + }; + + let bin = env!("CARGO_BIN_EXE_st2"); + let bin_dir = Path::new(bin).parent().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let cap_dir = tmp.path().join("cap"); + let empty_catalog = tmp.path().join("catalog"); + std::fs::create_dir_all(&empty_catalog).unwrap(); + + let (mut capture, capture_stdout, endpoint) = spawn_capture(&otelite, &cap_dir); + + let path = format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + let out = Command::new(bin) + .args(["up", "--catalog", empty_catalog.to_str().unwrap(), "--once"]) + .env("PATH", path) + .env("OTEL_EXPORTER_OTLP_ENDPOINT", &endpoint) + .output() + .expect("run st2 up --once"); + assert!( + out.status.success(), + "st2 up --once failed.\n--- stderr ---\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + // stdin EOF (or SIGTERM) stops the receiver and flushes captured signals to disk. + let _ = capture.stdin.take(); + let _ = capture.wait(); + // Only now close otelite's stdout: its shutdown writes must not hit a broken pipe. + drop(capture_stdout); + + let traces = + std::fs::read_to_string(cap_dir.join("traces.ndjson")).expect("traces.ndjson written"); + assert!( + traces.contains("\"st2.reconcile_pass\""), + "reconcile pass span missing from capture:\n{traces}" + ); + assert!( + traces.contains("st2-cli"), + "service.name st2-cli missing from capture:\n{traces}" + ); +} + +#[test] +fn st2_without_endpoint_does_not_error() { + // No-op guarantee: an unset OTEL_EXPORTER_OTLP_ENDPOINT must keep every command working + // (here: a trivially valid CLI invocation) with no telemetry side effects. + let bin = env!("CARGO_BIN_EXE_st2"); + let out = Command::new(bin) + .arg("--version") + .env_remove("OTEL_EXPORTER_OTLP_ENDPOINT") + .output() + .unwrap(); + assert!(out.status.success()); +} From b9547468f46ae4ad47ea42b776c9b2362ee27411 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:26:53 +0200 Subject: [PATCH 2/9] docs(vrs/o11y): resolve PR2 metric set and PR3 log-bridge approach from interview Q5: RED-minimal metric set, bounded-enum labels only. Q6: tracing facade for logs (tracing-opentelemetry + appender). R04 attrs and unit env mechanism closed by evidence. 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@929dc21 --- docs/vrs/06-observability/open-questions.md | 40 +++++++++++---------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/docs/vrs/06-observability/open-questions.md b/docs/vrs/06-observability/open-questions.md index ced4a64a..c59d8a30 100644 --- a/docs/vrs/06-observability/open-questions.md +++ b/docs/vrs/06-observability/open-questions.md @@ -2,27 +2,29 @@ Kept minimal; each blocks exactly one delivery slice, not the tree. -- **PR2 metric set.** Which metrics exactly, and their labels/cardinality budget? The shape - (counters for passes/spawns/reaps/hooks/errors, histograms for reconcile duration and session - start latency) is proposed in the [specification](spec.md#metrics-pr2); concrete instrument - names and label sets need one pass over `src/run.rs` before PR2 opens. +- **PR2 metric set.** Resolved by interview (decision record Q5): the RED-minimal + set — counters `reconcile_passes_total{result}`, `task_launches_total{driver}`, + `task_reaps_total{driver}`, `hook_invocations_total{hook,event}`, + `message_deliveries_total{result}`, `crash_loops_total`; histograms + `reconcile_pass_duration_seconds`, `session_start_duration_seconds`. Labels only + from bounded enums (result/driver/hook); ids stay in span attributes. - **`st2 up ` span coverage.** The catalog reconcile paths are instrumented (`st2.reconcile_pass` at the `up_loop_until` loop pass and in `up_once`), but the single-file spec path (`reconcile_pass_specs` / `reconcile_pass_specs_with_sessions`, `src/run.rs`) emits - no spans yet. Same span shape applies; needs one pass over that call chain before it can join - the registry. -- **Remaining R04 resource attributes.** Only `service.name`, `service.version`, and `host.name` - are set today; `service.namespace`, `service.instance.id`, `sk.site`, `sk.role`, and - `deployment.environment.name` still need wiring (and a decision on which come from ambient - `OTEL_RESOURCE_ATTRIBUTES` versus st2-side detection). -- **PR3 log bridge approach.** Bridge existing diagnostic output through an OTel logs emitter - versus introducing a structured logging facade (`tracing` + opentelemetry layer) and migrating - emit sites. The facade is cleaner long-term but touches more call sites; decide when PR1's - trace plumbing is in tree and the real migration cost is visible. -- **Unit env mechanism.** `Environment=` lines vs `EnvironmentFile=` for `OTEL_*` propagation - ([specification](spec.md#systemd-unit-propagation)). Lines are simple and match the existing - PATH/PTY_ROOT pattern; a file scales to many variables and lets operators edit without - reinstalling the unit. Decide at PR1 implementation time based on how many variables survive - filtering. + no spans yet. Same span shape applies; folded into PR2, which needs a pass over that call + chain for metrics anyway. +- **Remaining R04 resource attributes.** Resolved by source read (dotfiles dev3 + `monitoring.nix` transform block): the platform edge stamps `service.namespace`, + `sk.site`, `sk.role`, `deployment.environment.name` where absent, and the central + contract forbids hand-stamping them producer-side. st2 keeps `service.name`, + `service.version`, `host.name`; nothing left to wire. +- **PR3 log bridge approach.** Resolved by interview (decision record Q6): adopt the + `tracing` facade (`tracing-opentelemetry` + `opentelemetry-appender-tracing`) and migrate + emit sites to tracing macros, unifying spans and logs on one subscriber. Larger diff accepted + for the long-term win; PR1's dual-path helper is not built. +- **Unit env mechanism.** Resolved at PR1 implementation time: `Environment=` lines, + captured at install time and unit-tested (`src/service.rs`); matches the existing + PATH/PTY_ROOT pattern and the expected handful of variables. Revisit + `EnvironmentFile=` only if the variable count grows. - **Sampling.** Default is always-on given st2's low event volume. If supervisor-loop span volume proves noisy in Grafana, revisit parent-based sampling ratios — not before there is data. From edb942dd51c146ed48149652039815fd061a83e5 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:43:48 +0200 Subject: [PATCH 3/9] ci(nix): raise check timeout 30m -> 90m nix flake check now also builds the st2-otel-export gate; the 30m budget was cancelled mid-build on contended shared runners. 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@929dc21 --- .github/workflows/nix.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 85fa13cb..c40a2df5 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -7,7 +7,9 @@ on: jobs: check: runs-on: ubuntu-latest - timeout-minutes: 30 + # `nix flake check` builds the package plus every checks.* on contended + # shared builders; 30m was cancelled mid-build with the otel-export check added. + timeout-minutes: 90 steps: - uses: actions/checkout@v4 - uses: DeterminateSystems/determinate-nix-action@v3 From f11c195f7908f93f0f38488bcd773f7bb12bbe45 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:57:08 +0200 Subject: [PATCH 4/9] fix(otel): skip span construction when telemetry disabled; enforce banner deadline in e2e - Add process-wide ENABLED gate + PassSpan guard in telemetry; all three reconcile-pass sites (up_once, supervisor loop, newly instrumented up_once_selected) now construct nothing when OTEL_EXPORTER_OTLP_ENDPOINT is unset instead of allocating a no-op tracer/attrs per pass. - tests/otel_export.rs: read otelite stdout on a reader thread feeding an mpsc channel so the 10s banner deadline is enforced with recv_timeout instead of a blocking read that can hang the CI workflow. 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@929dc21 --- src/run.rs | 30 ++++---------------- src/telemetry.rs | 57 ++++++++++++++++++++++++++++++++++++++ tests/otel_export.rs | 66 ++++++++++++++++++++++++++++---------------- 3 files changed, 105 insertions(+), 48 deletions(-) diff --git a/src/run.rs b/src/run.rs index c3de81c8..0e84f5b9 100644 --- a/src/run.rs +++ b/src/run.rs @@ -25,8 +25,6 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; use std::time::{Duration, Instant}; use anyhow::Context as _; -use opentelemetry::trace::Span as _; -use opentelemetry::trace::Tracer as _; use serde::{Deserialize, Serialize}; use crate::exec_backend::ExecBackend; @@ -1725,11 +1723,7 @@ fn gate_harness_launches_on_hooks<'a, V>( pub fn up_once(root: &Path, this_host: &str, runner: &dyn Runner) -> anyhow::Result { let task_context = TaskCompileContext::current(root.to_path_buf())?; let mut debounce = LivenessDebounce::new(DEBOUNCE_GRACE); - let tracer = opentelemetry::global::tracer("st2"); - let mut span = tracer - .span_builder("st2.reconcile_pass") - .with_attributes(vec![opentelemetry::KeyValue::new("st2.host", this_host.to_string())]) - .start(&tracer); + let pass_span = crate::telemetry::PassSpan::start(this_host); let report = reconcile_pass( root, this_host, @@ -1739,7 +1733,7 @@ pub fn up_once(root: &Path, this_host: &str, runner: &dyn Runner) -> anyhow::Res &mut debounce, &mut PresentationPatchCursor::default(), ); - span.end(); + pass_span.finish(report.crash_loops.len(), report.unparked.len()); Ok(report) } @@ -1868,6 +1862,7 @@ pub fn up_once_selected( this_host: &str, runner: &dyn Runner, ) -> anyhow::Result { + let pass_span = crate::telemetry::PassSpan::start(this_host); let _catalog_lock = crate::CatalogLock::shared(catalog_root) .context("acquire shared catalog-authoring lock for selected reconcile")?; let found = crate::discovery::discover(catalog_root); @@ -1914,6 +1909,7 @@ pub fn up_once_selected( || Ok(()), )?; report.absorb(execution); + pass_span.finish(report.crash_loops.len(), report.unparked.len()); Ok(report) } @@ -2225,13 +2221,7 @@ fn up_loop_until( let mut pre = UpReport::default(); park_channel.grant_requests(&mut cap, &mut pre); let mut report = { - let tracer = opentelemetry::global::tracer("st2"); - let mut span = tracer - .span_builder("st2.reconcile_pass") - .with_attributes(vec![ - opentelemetry::KeyValue::new("st2.host", this_host.to_string()), - ]) - .start(&tracer); + let pass_span = crate::telemetry::PassSpan::start(this_host); let pass = reconcile_pass( root, this_host, @@ -2241,15 +2231,7 @@ fn up_loop_until( &mut debounce, &mut presentation_cursor, ); - span.set_attribute(opentelemetry::KeyValue::new( - "st2.crash_loops", - i64::try_from(pass.crash_loops.len()).unwrap_or(i64::MAX), - )); - span.set_attribute(opentelemetry::KeyValue::new( - "st2.unparked", - i64::try_from(pass.unparked.len()).unwrap_or(i64::MAX), - )); - span.end(); + pass_span.finish(pass.crash_loops.len(), pass.unparked.len()); pass }; pre.absorb(report); diff --git a/src/telemetry.rs b/src/telemetry.rs index df9f4326..f0a9f976 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -6,10 +6,22 @@ //! reqwest client features of `opentelemetry-otlp` 0.30 compiles but fails at runtime with //! `NoHttpClient` (all client cfg arms exclude each other). +use std::sync::atomic::{AtomicBool, Ordering}; + +use opentelemetry::trace::{Span as _, Tracer as _}; use opentelemetry::KeyValue; use opentelemetry_otlp::WithExportConfig; use opentelemetry_sdk::trace::SdkTracerProvider; +static ENABLED: AtomicBool = AtomicBool::new(false); + +/// Process-wide export gate. Spans and other instrumented work check this before allocating +/// anything, so the unset-endpoint path stays allocation-free (the provider guard alone cannot +/// remove no-op span construction on the supervisor hot path). +pub fn enabled() -> bool { + ENABLED.load(Ordering::Relaxed) +} + /// Guard holding the tracer provider for a process lifetime. Dropping it flushes and shuts the /// exporter down so short-lived CLI invocations still deliver their spans. pub struct Telemetry { @@ -51,6 +63,7 @@ impl Telemetry { .with_resource(resource) .build(); opentelemetry::global::set_tracer_provider(provider.clone()); + ENABLED.store(true, Ordering::Relaxed); Self { provider: Some(provider), } @@ -67,6 +80,7 @@ impl Telemetry { let _ = provider.force_flush(); let _ = provider.shutdown(); } + ENABLED.store(false, Ordering::Relaxed); } } @@ -75,3 +89,46 @@ impl Drop for Telemetry { self.shutdown(); } } + +/// A root `st2.reconcile_pass` span for one bounded pass, or nothing when telemetry is +/// disabled (construction is skipped entirely — see [`enabled`]). Each pass gets its own +/// trace; the supervisor loop never holds an endless root open. +pub struct PassSpan(Option); + +impl PassSpan { + pub fn start(this_host: &str) -> Self { + if !enabled() { + return Self(None); + } + let tracer = opentelemetry::global::tracer("st2"); + let span = tracer + .span_builder("st2.reconcile_pass") + .with_attributes(vec![KeyValue::new("st2.host", this_host.to_string())]) + .start(&tracer); + Self(Some(span)) + } + + /// Record pass outcomes and end the span. Early-drop paths end it without attributes. + pub fn finish(mut self, crash_loops: usize, unparked: usize) { + if let Some(span) = self.0.as_mut() { + let to_i64 = |n: usize| i64::try_from(n).unwrap_or(i64::MAX); + span.set_attribute(KeyValue::new("st2.crash_loops", to_i64(crash_loops))); + span.set_attribute(KeyValue::new("st2.unparked", to_i64(unparked))); + } + self.end(); + } +} + +impl Drop for PassSpan { + fn drop(&mut self) { + self.end(); + } +} + +impl PassSpan { + fn end(&mut self) { + if let Some(mut span) = self.0.take() { + span.end(); + } + } +} diff --git a/tests/otel_export.rs b/tests/otel_export.rs index c1249281..cbe634aa 100644 --- a/tests/otel_export.rs +++ b/tests/otel_export.rs @@ -12,12 +12,10 @@ use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; /// Spawn `otelite capture`, wait for its endpoints banner. -/// Returns (child, its stdout — keep until after `wait()` so otelite's final -/// writes don't hit a broken pipe, http endpoint). -fn spawn_capture( - otelite: &Path, - out_dir: &Path, -) -> (std::process::Child, std::process::ChildStdout, String) { +/// Returns (child, http endpoint). The child's stdout pipe stays open until otelite exits: +/// a detached reader thread owns it until EOF, so otelite's shutdown writes never hit a +/// broken pipe even though this function returns before the child terminates. +fn spawn_capture(otelite: &Path, out_dir: &Path) -> (std::process::Child, String) { let mut child = Command::new(otelite) .args(["capture", "--out"]) .arg(out_dir) @@ -29,26 +27,46 @@ fn spawn_capture( .expect("failed to spawn otelite capture"); // The endpoints banner is one JSON line: {"grpc":..., "http":..., "out":..., "schema":...}. - let mut stdout = child.stdout.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (tx, rx) = std::sync::mpsc::channel::>(); + std::thread::spawn(move || { + // A plain blocking `stdout.read()` ignores any deadline — if otelite stalls mid-read + // the test would hang until the workflow timeout. Ship bytes over a channel instead + // so the main thread can enforce the timeout with recv_timeout and fail fast. + let mut stdout = stdout; + let mut chunk = [0u8; 512]; + loop { + match stdout.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => { + if tx.send(chunk[..n].to_vec()).is_err() { + break; + } + } + } + } + }); + + const BANNER_TIMEOUT: Duration = Duration::from_secs(10); + let started = Instant::now(); let mut banner = String::new(); - let deadline = Instant::now() + Duration::from_secs(10); loop { - assert!( - Instant::now() < deadline, - "otelite capture did not print its endpoints banner" - ); - let mut chunk = [0u8; 512]; - let n = stdout.read(&mut chunk).expect("read otelite stdout"); - if n > 0 { - banner.push_str(&String::from_utf8_lossy(&chunk[..n])); + let Some(remaining) = BANNER_TIMEOUT.checked_sub(started.elapsed()) else { + panic!("otelite capture did not print its endpoints banner within 10s: {banner}"); + }; + match rx.recv_timeout(remaining) { + Ok(bytes) => banner.push_str(&String::from_utf8_lossy(&bytes)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + panic!("otelite capture did not print its endpoints banner within 10s: {banner}"); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + panic!("otelite capture exited before serving: {banner}"); + } } if let Some(line) = banner.lines().find(|l| l.contains("otelite.endpoints")) { let v: serde_json::Value = serde_json::from_str(line).expect("endpoints banner JSON"); let http = v["http"].as_str().expect("http endpoint").to_string(); - return (child, stdout, http); - } - if n == 0 { - panic!("otelite capture exited before serving: {banner}"); + return (child, http); } } } @@ -71,7 +89,7 @@ fn st2_exports_spans_to_otelite_when_endpoint_is_set() { let empty_catalog = tmp.path().join("catalog"); std::fs::create_dir_all(&empty_catalog).unwrap(); - let (mut capture, capture_stdout, endpoint) = spawn_capture(&otelite, &cap_dir); + let (mut capture, endpoint) = spawn_capture(&otelite, &cap_dir); let path = format!( "{}:{}", @@ -90,11 +108,11 @@ fn st2_exports_spans_to_otelite_when_endpoint_is_set() { String::from_utf8_lossy(&out.stderr) ); - // stdin EOF (or SIGTERM) stops the receiver and flushes captured signals to disk. + // stdin EOF stops the receiver and flushes captured signals to disk. The detached reader + // thread keeps otelite's stdout pipe open until process exit, so its shutdown writes + // never hit a broken pipe. let _ = capture.stdin.take(); let _ = capture.wait(); - // Only now close otelite's stdout: its shutdown writes must not hit a broken pipe. - drop(capture_stdout); let traces = std::fs::read_to_string(cap_dir.join("traces.ndjson")).expect("traces.ndjson written"); From 858dec5203df29d92910f2c54c274ef94e842ce9 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:26:13 +0200 Subject: [PATCH 5/9] test(otel): strengthen no-op e2e to detect always-on-export regressions 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@929dc21 --- tests/otel_export.rs | 54 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/tests/otel_export.rs b/tests/otel_export.rs index cbe634aa..08833466 100644 --- a/tests/otel_export.rs +++ b/tests/otel_export.rs @@ -128,13 +128,59 @@ fn st2_exports_spans_to_otelite_when_endpoint_is_set() { #[test] fn st2_without_endpoint_does_not_error() { + let Some(otelite) = std::env::var_os("ST2_OTELITE_BIN").map(PathBuf::from) else { + assert!( + std::env::var_os("ST2_ALLOW_OTEL_SKIP").is_some(), + "`ST2_OTELITE_BIN` not set — can't prove OTLP silence. Set ST2_ALLOW_OTEL_SKIP=1 to skip." + ); + eprintln!("SKIP st2_without_endpoint_does_not_error: ST2_OTELITE_BIN not set"); + return; + }; + // No-op guarantee: an unset OTEL_EXPORTER_OTLP_ENDPOINT must keep every command working - // (here: a trivially valid CLI invocation) with no telemetry side effects. + // AND emit no telemetry at all. Run a real `st2 up --once` against a scratch EMPTY + // catalog with a live otelite receiver attached; if an always-on-export regression ever + // lands, the receiver flushes non-empty ndjson files here and the test fails — a bare + // `--version` smoke could never catch that. let bin = env!("CARGO_BIN_EXE_st2"); + let bin_dir = Path::new(bin).parent().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let cap_dir = tmp.path().join("cap"); + let empty_catalog = tmp.path().join("catalog"); + std::fs::create_dir_all(&empty_catalog).unwrap(); + + let (mut capture, _endpoint) = spawn_capture(&otelite, &cap_dir); + + let path = format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); let out = Command::new(bin) - .arg("--version") + .args(["up", "--catalog", empty_catalog.to_str().unwrap(), "--once"]) + .env("PATH", path) .env_remove("OTEL_EXPORTER_OTLP_ENDPOINT") .output() - .unwrap(); - assert!(out.status.success()); + .expect("run st2 up --once without endpoint"); + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "st2 up --once must succeed without an OTLP endpoint\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // stdin EOF stops the receiver and flushes captured signals to disk. The detached reader + // thread keeps otelite's stdout pipe open until process exit (see spawn_capture). + let _ = capture.stdin.take(); + let _ = capture.wait(); + + for name in ["traces.ndjson", "metrics.ndjson", "logs.ndjson"] { + let path = cap_dir.join(name); + let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + assert_eq!( + bytes, 0, + "{name} must be absent or empty when OTEL_EXPORTER_OTLP_ENDPOINT is unset, got {bytes} bytes" + ); + } } From 5939fd75e138f77679ed7d2de5d290d3a8148bda Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:26:13 +0200 Subject: [PATCH 6/9] fix(otel): pin effect-utils flake input to locked rev 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@929dc21 --- flake.lock | 2 +- flake.nix | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 01e935ae..82f37cad 100644 --- a/flake.lock +++ b/flake.lock @@ -16,8 +16,8 @@ }, "original": { "owner": "overengineeringstudio", - "ref": "main", "repo": "effect-utils", + "rev": "911e2ce0f4ac39d2b54f9ebd6df035234982f721", "type": "github" } }, diff --git a/flake.nix b/flake.nix index 9e35e19d..05cef273 100644 --- a/flake.nix +++ b/flake.nix @@ -11,8 +11,10 @@ # Shared tooling packages from overengineering: provides the `otelite` # OTLP collector binary that the OTel export integration gate # (`tests/otel_export.rs`, exposed as `checks.otel-export`) drives to - # prove real span export end-to-end. - effect-utils.url = "github:overengineeringstudio/effect-utils/main"; + # prove real span export end-to-end. Pinned to a full rev (like `pty`) + # so CI is reproducible; bump deliberately via `nix flake lock`. + effect-utils.url = + "github:overengineeringstudio/effect-utils/911e2ce0f4ac39d2b54f9ebd6df035234982f721"; }; outputs = From 58846a2b38bf1dc6f6ac8b27717aa8ec45cc8664 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:01:09 +0200 Subject: [PATCH 7/9] ci(nix): raise check timeout 90m -> 240m for degraded-runner periods 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@929dc21 --- .github/workflows/nix.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index c40a2df5..08309786 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -7,9 +7,10 @@ on: jobs: check: runs-on: ubuntu-latest - # `nix flake check` builds the package plus every checks.* on contended - # shared builders; 30m was cancelled mid-build with the otel-export check added. - timeout-minutes: 90 + # `nix flake check` builds the package plus every checks.*; on contended + # shared builders the budget is sized for degraded-runner periods (baseline + # ~35m healthy): fleet runner contention, not diff size, drives overruns. + timeout-minutes: 240 steps: - uses: actions/checkout@v4 - uses: DeterminateSystems/determinate-nix-action@v3 From fb2fb9e03f5ce71b7c20fa5c18564fb00eb8d774 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:12:33 +0200 Subject: [PATCH 8/9] fix(otel): make no-op e2e receiver shutdown deterministic in nix sandbox 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 --- tests/otel_export.rs | 140 ++++++++++++------------------------------- 1 file changed, 39 insertions(+), 101 deletions(-) diff --git a/tests/otel_export.rs b/tests/otel_export.rs index 08833466..a13d4e8b 100644 --- a/tests/otel_export.rs +++ b/tests/otel_export.rs @@ -6,69 +6,45 @@ //! Needs `ST2_OTELITE_BIN` on a gate run — HARD failure if absent unless `ST2_ALLOW_OTEL_SKIP` //! is set (a gate must not silently skip). -use std::io::Read as _; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::time::{Duration, Instant}; +use std::process::{Command, Output}; + +/// Run `st2 up --once` under otelite's managed receiver lifecycle. +/// +/// `otelite run` owns the receiver and shuts it down after the command exits, so the test does +/// not depend on the surrounding process's stdin or on detached pipe-reader threads. For the +/// no-export case, `env -u` removes the endpoint that otelite injects into its child while leaving +/// the receiver live to catch any unintended traffic. +fn run_with_capture( + otelite: &Path, + out_dir: &Path, + catalog: &Path, + export: bool, +) -> Output { + let bin = env!("CARGO_BIN_EXE_st2"); + let bin_dir = Path::new(bin).parent().unwrap(); + let path = format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); -/// Spawn `otelite capture`, wait for its endpoints banner. -/// Returns (child, http endpoint). The child's stdout pipe stays open until otelite exits: -/// a detached reader thread owns it until EOF, so otelite's shutdown writes never hit a -/// broken pipe even though this function returns before the child terminates. -fn spawn_capture(otelite: &Path, out_dir: &Path) -> (std::process::Child, String) { - let mut child = Command::new(otelite) - .args(["capture", "--out"]) + let mut command = Command::new(otelite); + command + .args(["run", "--out"]) .arg(out_dir) - .arg("--http-port") - .arg("0") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .expect("failed to spawn otelite capture"); - - // The endpoints banner is one JSON line: {"grpc":..., "http":..., "out":..., "schema":...}. - let stdout = child.stdout.take().unwrap(); - let (tx, rx) = std::sync::mpsc::channel::>(); - std::thread::spawn(move || { - // A plain blocking `stdout.read()` ignores any deadline — if otelite stalls mid-read - // the test would hang until the workflow timeout. Ship bytes over a channel instead - // so the main thread can enforce the timeout with recv_timeout and fail fast. - let mut stdout = stdout; - let mut chunk = [0u8; 512]; - loop { - match stdout.read(&mut chunk) { - Ok(0) | Err(_) => break, - Ok(n) => { - if tx.send(chunk[..n].to_vec()).is_err() { - break; - } - } - } - } - }); - - const BANNER_TIMEOUT: Duration = Duration::from_secs(10); - let started = Instant::now(); - let mut banner = String::new(); - loop { - let Some(remaining) = BANNER_TIMEOUT.checked_sub(started.elapsed()) else { - panic!("otelite capture did not print its endpoints banner within 10s: {banner}"); - }; - match rx.recv_timeout(remaining) { - Ok(bytes) => banner.push_str(&String::from_utf8_lossy(&bytes)), - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - panic!("otelite capture did not print its endpoints banner within 10s: {banner}"); - } - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - panic!("otelite capture exited before serving: {banner}"); - } - } - if let Some(line) = banner.lines().find(|l| l.contains("otelite.endpoints")) { - let v: serde_json::Value = serde_json::from_str(line).expect("endpoints banner JSON"); - let http = v["http"].as_str().expect("http endpoint").to_string(); - return (child, http); - } + .args(["--protocol", "http/json", "--"]); + if !export { + command.args(["env", "-u", "OTEL_EXPORTER_OTLP_ENDPOINT"]); } + command + .arg(bin) + .args(["up", "--catalog"]) + .arg(catalog) + .arg("--once") + .env("PATH", path) + .output() + .expect("run st2 up --once under otelite") } #[test] @@ -82,38 +58,19 @@ fn st2_exports_spans_to_otelite_when_endpoint_is_set() { return; }; - let bin = env!("CARGO_BIN_EXE_st2"); - let bin_dir = Path::new(bin).parent().unwrap(); let tmp = tempfile::tempdir().unwrap(); let cap_dir = tmp.path().join("cap"); let empty_catalog = tmp.path().join("catalog"); std::fs::create_dir_all(&empty_catalog).unwrap(); - let (mut capture, endpoint) = spawn_capture(&otelite, &cap_dir); - - let path = format!( - "{}:{}", - bin_dir.display(), - std::env::var("PATH").unwrap_or_default() - ); - let out = Command::new(bin) - .args(["up", "--catalog", empty_catalog.to_str().unwrap(), "--once"]) - .env("PATH", path) - .env("OTEL_EXPORTER_OTLP_ENDPOINT", &endpoint) - .output() - .expect("run st2 up --once"); + let out = run_with_capture(&otelite, &cap_dir, &empty_catalog, true); assert!( out.status.success(), - "st2 up --once failed.\n--- stderr ---\n{}", + "otelite run failed while exporting st2 telemetry.\n--- stdout ---\n{}\n--- stderr ---\n{}", + String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ); - // stdin EOF stops the receiver and flushes captured signals to disk. The detached reader - // thread keeps otelite's stdout pipe open until process exit, so its shutdown writes - // never hit a broken pipe. - let _ = capture.stdin.take(); - let _ = capture.wait(); - let traces = std::fs::read_to_string(cap_dir.join("traces.ndjson")).expect("traces.ndjson written"); assert!( @@ -142,26 +99,12 @@ fn st2_without_endpoint_does_not_error() { // catalog with a live otelite receiver attached; if an always-on-export regression ever // lands, the receiver flushes non-empty ndjson files here and the test fails — a bare // `--version` smoke could never catch that. - let bin = env!("CARGO_BIN_EXE_st2"); - let bin_dir = Path::new(bin).parent().unwrap(); let tmp = tempfile::tempdir().unwrap(); let cap_dir = tmp.path().join("cap"); let empty_catalog = tmp.path().join("catalog"); std::fs::create_dir_all(&empty_catalog).unwrap(); - let (mut capture, _endpoint) = spawn_capture(&otelite, &cap_dir); - - let path = format!( - "{}:{}", - bin_dir.display(), - std::env::var("PATH").unwrap_or_default() - ); - let out = Command::new(bin) - .args(["up", "--catalog", empty_catalog.to_str().unwrap(), "--once"]) - .env("PATH", path) - .env_remove("OTEL_EXPORTER_OTLP_ENDPOINT") - .output() - .expect("run st2 up --once without endpoint"); + let out = run_with_capture(&otelite, &cap_dir, &empty_catalog, false); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); @@ -170,11 +113,6 @@ fn st2_without_endpoint_does_not_error() { "st2 up --once must succeed without an OTLP endpoint\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - // stdin EOF stops the receiver and flushes captured signals to disk. The detached reader - // thread keeps otelite's stdout pipe open until process exit (see spawn_capture). - let _ = capture.stdin.take(); - let _ = capture.wait(); - for name in ["traces.ndjson", "metrics.ndjson", "logs.ndjson"] { let path = cap_dir.join(name); let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); From 539427b5edc47134fe60647b04ec66d2dea7ee39 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:53:31 +0200 Subject: [PATCH 9/9] ci(nix): restore bounded 90m check timeout after e2e hang fix 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 --- .github/workflows/nix.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 08309786..a40438e1 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -7,10 +7,9 @@ on: jobs: check: runs-on: ubuntu-latest - # `nix flake check` builds the package plus every checks.*; on contended - # shared builders the budget is sized for degraded-runner periods (baseline - # ~35m healthy): fleet runner contention, not diff size, drives overruns. - timeout-minutes: 240 + # A healthy full `nix flake check` baseline is ~35m; 90m provides + # headroom while still failing boundedly. + timeout-minutes: 90 steps: - uses: actions/checkout@v4 - uses: DeterminateSystems/determinate-nix-action@v3