Chaos Observability Contracts: Adding a New Contract
On this page
Overview
A chaos observability contract asserts that production code emits a specific structured tracing::Event under fault-injected conditions. The contract is enforced by a test in crates/canopy-test-lib/tests/evil_proxy_test.rs that:
-
Spawns the production component in the test process via the
canopy_test_lib::chaosharness. -
Wraps the upstream dependency with
EvilLayer(or points at a real broker for protocolsevil_proxycannot wrap, such as AMQP). -
Asserts via
SpanCapturethat events with a specifictarget:fired.
Existing contracts (epic \&50):
| Issue | Target | Production code |
|---|---|---|
\#462 |
|
|
\#481 |
|
|
\#482 |
|
|
This runbook covers adding a new contract for production code that doesn’t yet have one.
See ADR-020 for the strategy decision (in-process fixtures vs OTEL vs log scraping) and the thread-local-subscriber constraint that motivated the harness primitive.
The thread-local subscriber lesson
SpanCapture::install_scoped uses tracing::subscriber::set_default, which is thread-local in the test process. Three implications worth internalising before writing a chaos test:
-
Tests using
SpanCaptureMUST use#[tokio::test(flavor = "current_thread")]. On a multi-threaded runtime, work-stealing spawns tasks onto threads that do not see the test thread’s default subscriber. The assertions silently fail under nextest. -
Production components running in devstack containers are unobservable. Events emitted by
JwksProviderrunning insidecanopy-web, orOutboxDrainerrunning insidecanopy-snap, fire on a different process than the test — no cross-process tracing capture today. -
The
chaosharness side-steps the cross-process problem by spawning the production component IN the test process pointed at a controlled endpoint. Same constructor, same code, same emit sites — just executed on the test’s runtime so the thread-local subscriber sees the spans.
Adding a new chaos contract — step by step
1. Add a target: to the production emit sites
In the production code module (e.g. crates/canopy-mq/src/inbox_drainer.rs), add target: "<name>" to every tracing::info! / warn! / error! that names an invariant the chaos test should assert on. Use a stable name that an operator would grep:
info!(
target: "inbox",
drainer_id = %cfg.drainer_id,
"inbox drainer started"
);
2. If the contract needs failure-path observability, ensure the failure path emits
? shortcut propagation is the most common source of un-emitted failures. The pattern that makes chaos tests work:
pub async fn refresh(&self) -> Result<(), anyhow::Error> {
let result: Result<JwkSet, anyhow::Error> = async {
let resp = self.client.get(&self.jwks_uri).send().await?;
Ok(resp.json().await?)
}.await;
match result {
Ok(jwks) => {
info!(target: "jwks", "JWKS refreshed");
*self.keys.write().await = Some(jwks);
Ok(())
}
Err(e) => {
warn!(target: "jwks", error = %e, "JWKS refresh failed");
Err(e)
}
}
}
This is the pattern \#481 introduced for JwksProvider::refresh().
3. If the production component is not yet harness-spawnable, add a helper
canopy_test_lib::chaos currently provides:
-
spawn_jwks_provider_for_chaos(EvilLayer) → ChaosJwksHandle -
spawn_outbox_drainer_for_chaos(pool, broker_url) → Result<ChaosOutboxHandle, anyhow::Error>
To add a new helper (e.g. spawn_inbox_drainer_for_chaos):
-
Verify the production constructor’s signature: is it test-friendly (constructor-injectable, clone-friendly, async)?
JwksProvider::from_discoveryandOutboxDrainer::spawnwere both ready out of the box. If your target component requires production-code changes to be testable, that’s a separate scope concern — discuss before adding. -
Model the helper on
chaos/jwks.rsorchaos/outbox.rs:-
Take an
EvilLayer(HTTP) orbroker_url(AMQP / other non-HTTP) parameter. -
For HTTP: spawn a static-document mock (modeled on
crate::mock::spawn_mock_jwks), wrap withevil_proxy, construct the production component pointed at the EvilLayer URL. -
For AMQP / other: take the URL directly; the chaos test passes a real devstack URL.
-
Return a
ChaosXHandlestruct holding the production component +MockHandle+EvilProxyHandleas_-prefixed fields so theirDropimpls run when the handle is dropped.
-
-
Re-export from
crates/canopy-test-lib/src/lib.rsnext to the existing harness re-exports. -
Document the thread-local-subscriber constraint in the helper’s module rustdoc.
4. Write the chaos test
In crates/canopy-test-lib/tests/evil_proxy_test.rs, add a #[tokio::test(flavor = "current_thread")] that:
-
Installs
SpanCapture::install_scoped(capture must come before the helper spawns anything). -
Drives a fail-then-recover cycle. The two phases serve different invariants:
-
Fail phase — exercises the helper’s error path and the production failure-path emit.
-
Recover phase — exercises the success-path emit.
-
-
Asserts
capture.assert_span_emitted("<your-target-name>").
If the test requires devstack (e.g., a real broker), gate it with #[ignore = "chaos: requires devstack + opt-in via cargo nextest run --run-ignored only"] and check infrastructure_available().await early. If the test is fully in-process (mock + EvilLayer + spawn in-process), drop the #[ignore] gate — let it run in the regular nextest sweep.
5. Verify determinism
Run the test 20 times in a tight loop:
for i in $(seq 1 20); do
cargo nextest run -p canopy-test-lib <test_name>
done
(Add --run-ignored only if the test is #[ignore]-gated.)
Any failure / flake means the test depends on something timing-, network-, or scheduler-sensitive. Investigate — do not retry-until-green (memory feedback_no_flake_dismissal). The four contracts in epic \&50 each verified 20/20 PASS at single-digit ms before merging.
6. CHANGELOG + commit
CHANGELOG.adoc entry under === Added. Include:
-
Which
target:was added and where (file:line for each emit site). -
Which fail-then-recover cycle the chaos test drives.
-
The deterministic-run count (20/20 PASS) + per-test wall-clock.
-
Which transient-failure paths are out of scope (e.g., AMQP-transparent EvilLayer requirement; see the \#482 entry for the canonical wording).
Example diff: \#481 JWKS chaos contract (merged 2026-05-18, MR \!334)
Production:
- info!(jwks_uri = %self.jwks_uri, key_count = jwks.keys.len(), "JWKS refreshed");
+ info!(target: "jwks", jwks_uri = %self.jwks_uri, key_count = jwks.keys.len(), "JWKS refreshed");
Plus the match-based refactor of refresh() so the failure path also emits with target: "jwks".
Test (consumes the \#480 harness):
#[tokio::test(flavor = "current_thread")]
async fn jwks_rotation() {
let (capture, _guard) = SpanCapture::install_scoped();
// Fail phase
let fail_handle = spawn_jwks_provider_for_chaos(
EvilLayer::new().with_failure_rate(1.0)
).await;
for _ in 0..3 {
let res = fail_handle.provider.refresh().await;
assert!(res.is_err());
}
drop(fail_handle);
// Recover phase
let ok_handle = spawn_jwks_provider_for_chaos(EvilLayer::new()).await;
for _ in 0..3 {
ok_handle.provider.refresh().await.expect("happy-path");
}
capture.assert_span_emitted("jwks");
}
20/20 PASS at 6-9 ms each. Fully in-process — no #[ignore] gate.
Common pitfalls
-
Asserting on a target that doesn’t exist yet. The chaos test fails with a diagnostic dump of all captured signals. Either add the
target:in the same MR, or file the production-code change first and gate the test against it. Don’t merge a test that’s structurally impossible to satisfy — that’s the trap epic \&50 was filed to close. -
Multi-threaded tokio runtime.
#[tokio::test]defaults to multi-thread.#[tokio::test(flavor = "current_thread")]is required forSpanCaptureto work. -
Drop order in helper handles. If a helper holds a
MockHandleAND anEvilProxyHandle, the proxy must drop before the mock (the proxy is forwarding to the mock; reversing the drop order produces a hangingDrop). Tokio’s default field-declaration drop order handles this — declare_evil_handleAFTER_mock_handleso the proxy drops first. -
?-shortcut hides the failure-path emit. Ifrefresh().awaitreturns Err but emits nothing, your chaos test’s failure-phase assertion will catch nothing. Pattern in step 2 above is the canonical fix. -
Asserting on devstack-container events. Production code running inside a devstack container is not observable by
SpanCapturein the test process — even when the test exercises the container via HTTP. The harness exists because of this. If your contract requires asserting on events from canopy-* services running in containers, you cannot useSpanCapturedirectly; the harness in-process spawn is the workaround. -
Transient-AMQP-failure injection.
evil_proxyis JSON-only; it cannot intercept AMQP. Chaos contracts that need transient-AMQP-failure paths (e.g., \#482’s "drainer recovers from publish failures") require an AMQP-transparent EvilLayer — currently unscoped. The basic "drainer firestarget: "outbox"on spawn" contract is sufficient and works through the standard harness.
References
-
Epic \&50 — Chaos observability contracts: cross-process capture + retry/JWKS/outbox.
-
crates/canopy-test-lib/src/chaos/mod.rs— module-level rustdoc with canonical use shape. -
crates/canopy-test-lib/tests/evil_proxy_test.rs— current contracts (inbox_dedup_at_100_percent_failure,jwks_rotation,outbox_catches_up,eligibility_circuit_breaker).