ADR-0026: A policy metric is created only for a policy that executes, and a breaker's state is published when it changes rather than when it is scraped
Status: Accepted Date: 2026-08-01 Deciders: Repository owner · Platform architecture Amends: 10 §9
Two features shipped in the same release and never met. The policy engine executes stage 4 (ADR-0025) and the telemetry seam publishes eleven instruments (12 §3) — and not one of them describes a policy. A circuit breaker that genuinely opened did so with no external evidence of any kind. This record is what closes that, and what it deliberately leaves open.
10 §9 specifies seven metric
rows. Its own warning box said none was emitted, and gave the reason: "FlowX ships no metrics
or logging infrastructure at all." That reason expired when
FlowXMetrics landed. What
remained was that nobody had connected the two, and the gap had a shape worth stating: the four
events an operator is actually paged about — a breaker opening, a retry attempting, a bulkhead
refusing, a timeout firing — are all decisions the engine makes silently and the flow reports
only as an Error on a failed step. An Error reaches the caller. It does not reach a
dashboard, and a breaker that opens for every caller of a dependency is not a property of any
one call.
FlowXMetrics's remarks are unambiguous about the two rows of §3 it declines to create:
"an instrument that exists and is never written to publishes an empty series, and an empty series is indistinguishable from a healthy one."
That is the whole difficulty with §9's table, because three of its seven rows describe
policies that do not execute. flowx_ratelimit_rejected_total,
flowx_cache_hits_total/_misses_total and flowx_idempotency_replays_total belong to stage
1, stage 5 and stage 3 — the stages ADR-0025 §2 skips, each for its own stated reason. Creating
them would publish four series that are permanently zero, and a permanently-zero
flowx_ratelimit_rejected_total reads as "nothing has ever been refused" when the truth is
"nothing ever refuses". That is a stronger lie than silence, because silence is visibly
missing and a zero is not.
flowx_circuit_state is specified as a gauge. The .NET idiom for a gauge is
CreateObservableGauge, whose callback is invoked per scrape — which requires the callback to
be able to find every live breaker at that moment. Breakers live in a
ConcurrentDictionary field on a FlowEngine instance, and an engine is an ordinary object a
host creates and drops. A static registry of live engines is the only way a scrape-time
callback could reach them, and it is a registry that either leaks every engine ever created or
needs weak references and a reaping policy — infrastructure with a failure mode
(a stale breaker publishing a stale state) that is worse than the gap it closes.
- Create all seven instruments and leave three empty. Rejected for §1.1's reason, which is
this repository's own and is applied to
flowx_stream_lag_recordstoday. - An observable gauge over a static registry of engines. Rejected for §1.2. It also breaks
B6 in a way the guard pattern cannot fix: an observable callback is invoked by the meter, so
it is not a call site that can check
Enabledfirst. - Emit a span event per policy decision instead of a metric. §9 asks for both — "policy decisions also appear as span events" — but a span is sampled and a page is not. An operator alerting on "this breaker is open" cannot do it from a signal that is dropped at 99 % sampling. Spans are the diagnosis; the metric is the alert. Only the metric is built here.
- Count a breaker refusal only, without the admissions. Rejected: a refusal count with no denominator cannot answer "what fraction of calls is this breaker refusing", and forty refusals means something different against forty-five calls than against forty-five thousand.
Four instruments are created — the §9 rows whose policy executes — and the three whose policy
does not are left unnamed. flowx_circuit_state and flowx_bulkhead_queue_depth are
synchronous Gauge<int>s recorded at the instant the value changes.
| Metric | Instrument | Recorded when |
|---|---|---|
flowx_policy_invocations_total |
Counter<long> |
every stage-4 policy application, refused and clean |
flowx_retry_attempts_total |
Counter<long> |
a dispatch beyond the first, after the deadline check |
flowx_circuit_state |
Gauge<int> |
a breaker transition, under the breaker's own lock |
flowx_bulkhead_queue_depth |
Gauge<int> |
a caller joins or leaves the queue |
PolicyMetrics is a separate type from FlowXMetrics because §9 and
12 §3 are separate
frozen tables; they share FlowXTelemetry.Meter, because one meter name is that type's entire
purpose, and nothing else.
This is the opposite of what was done for TriggerAdmittedTotal and StreamLagRecords, which
are named in TelemetryNames with no producer — and the difference is deliberate. Those two
describe a subject that exists and a seam that cannot reach it, so the name is a commitment
whose implementation is pending. A rate-limit rejection counter describes a decision no code
makes. There is nothing to freeze: the scope label §9 gives it presupposes a decision about
what a rate-limit scope is, and that decision belongs to whoever executes stage 1. Naming it
now would pre-commit a future author to a label shape chosen by someone who was not building
the thing.
A breaker publishes its state when it changes and at the first call that finds it closed. The
baseline matters and is not decoration: an alert of the form flowx_circuit_state == 2 needs a
0 to recover to, and without an initial reading a dashboard cannot tell a closed breaker from
a breaker nothing has ever asked. CircuitBreakerState therefore starts at a sentinel that is
neither open nor closed, and the first TryEnter publishes the real value.
Publication happens inside the breaker's existing lock. Two threads opening the same breaker at the same instant would otherwise both read a stale "last published" and emit the transition twice, and a state gauge that double-reports is one an operator cannot count edges on.
flowx_policy_invocations_total is incremented when a breaker admits as well as when it
refuses, when a bulkhead grants a permit as well as when it turns a caller away, and when a
timeout is armed and not reached as well as when it fires. The outcome label is what
separates them. This is the difference between a counter that can express "3 % of calls to this
dependency are being refused" and one that can only express "there have been 40 refusals".
flowx_retry_attempts_total is the exception and counts only attempts beyond the first,
because the first dispatch is not a retry — counting it would put every step that ever ran on a
retry dashboard.
Nothing in PolicyMetrics is reachable unless ExecutionPlan.HasStepPolicies is true and the
step's resolved StepPolicy.IsActive is true — the gate
ADR-0023 already placed in front of the whole
policy path. An ephemeral flow that declares no policy does not reach a guard in this file; it
does not reach this file. B2 is therefore a consequence of ADR-0023 rather than of care taken
here, which is the stronger of the two positions.
B6 is a property of the caller: every helper checks Instrument.Enabled before constructing
a single KeyValuePair, because a label that is not already a string boxes on its way in —
and two of these four take an int value.
TelemetryCostTests.APolicyReportingADecisionToNobodyAllocatesNothing measures the four helpers
with no listener attached and requires exactly zero bytes.
Positive:
- The four events an operator is paged about are now visible. A breaker opening, a retry
attempting, a bulkhead refusing and a timeout firing each produce a measurement, and
PolicyMetricsTestsasserts the measurement rather than the instrument — a test that only checked an instrument had been created would pass against exactly the silence being fixed. - §9's warning box is deleted rather than softened. It said "no metric in this table is emitted"; four now are, and the table says which three are not and why.
- The omission is checkable. A future author implementing stage 1 finds no
RateLimitRejectedTotalconstant to fill in, which is a compile error rather than a silently wrong series. - No new lifetime to manage. A synchronous gauge has no callback, so no engine is kept alive by the telemetry that describes it.
Negative / accepted trade-offs:
- A transition-recorded gauge is not a scrape-time truth. An exporter that starts after a
breaker opened sees nothing until the next transition, where an observable gauge would have
reported the current state on its first scrape. The mitigation is the baseline reading in
§2.3, and it is only a partial one: a process whose breaker opened before the exporter
attached publishes its
2only when it closes. This is the cost of not holding a registry of live engines, and it is accepted rather than solved. capabilityandkeyonflowx_circuit_statecarry the same value. 10 §6's compositeBreakerKeyhas four components and onlyCapabilityis expressible, so the two labels coincide. Emitting the duplicate is a bet that the key widens later; if it never does, every deployment pays one redundant label for ever.flowx_bulkhead_queue_depthhas no zero until a queue has formed. It is recorded only on the path that actually queues — the uncontendedWait(0)returns before reaching it — so a bulkhead nobody has ever waited for publishes no series at all. That is consistent with §1.1 (no empty series) and inconsistent with a dashboard that expects a flat zero.stageis a constant label today. Every kind that reaches the counter is stage 4, so the label distinguishes nothing and costs a string per measurement. It is emitted because §9 froze it and a series an operator already graphs must not change shape when stage 1 lands.- The span-event half of §9 is still unbuilt. "Policy decisions also appear as span events on the step span, so a trace shows why a call took 3.2 s" remains a specification. §9 is therefore half-implemented, and its text now has to say so rather than saying nothing is.
Revisit when: any of stage 1, stage 3 or stage 5 executes, at which point its §9 row gets a name and §2.2's argument for omitting it becomes history; or a second execution engine, or an out-of-process scraper, needs the breaker state — which a per-transition gauge cannot serve, and which is the case §1.2's rejected registry would have to be reconsidered for; or the span-event half of §9 is built, at which point the two halves should share one decision about what a "policy decision" is called.
See also: ADR-0023 · ADR-0024 · ADR-0025 · 10 §9 · 12 — Observability