Repository navigation
feat: observation completeness on measured payloads (ADR 0013) - #39
Merged
Merged
Conversation
A zero was ambiguous. ThrottledPeriods and the PSI stall counters were plain counters with a zero default, so a container that was never throttled and a container whose node's /metrics/cadvisor scrape failed all window produced byte-identical records. The cluster-wide signal set existed only in a log line and could not resolve the question for any single record — a scrape fails per node and per path, not per cluster. From outside the cluster the difference is unrecoverable after the fact. Measured payloads now say what the agent saw: - Per record: CPU.ThrottlingSamples, CPU.PSISamples and Memory.PSISamples count the observations that carried each signal, so a zero total with a zero sample count reads as "never observed" rather than "zero". CPU.CoveredNanoseconds is the summed duration of the intervals attributed to the record, split across windows exactly as the counters are; against WindowSeconds it says how much of the window was observed. All four add under merge — the merge-property tests are unchanged and still pass. - Per payload: an observation block with the poll cadence, cumulative kubelet requests attempted and failed, and the exposed signal set. Both kubelet paths count as separate requests because they fail independently; which one failed is resolved per record by the sample counts. Provenance completes: usage payloads declare source=measured, oom_kill journal, go_inventory structural. The gap ADR 0012 recorded is closed, in one golden migration rather than field by field later. Fixed alongside, because it shares the identity question: throttling kept its counter baselines under the pod name the cAdvisor exposition labels by, while CPU and memory kept theirs under the pod UID. A StatefulSet recreates a pod under the same name with a new UID, so the replacement's counters were compared against a dead container's baseline — dropped as a phantom counter reset, or stretched into one continuous run when the new counters happened to exceed the old. LookupPodByName now returns the UID and all three signals key on it. Deliberately not done: no "expected sample count" (it would require the agent to model when a container should have existed — a judgment; CoveredNanoseconds carries the same information without it) and no reason string for non-coverage (the agent reports counters, the backend renders the cause). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kfeofantov
added a commit
that referenced
this pull request
Aug 20, 2026
…(ADR 0015) (#41) docs/security.md §10.2 promises customers that node-level samples from processes whose module path *or namespace* is not allow-listed are discarded on the node, before transport. For the eBPF profiler that was true. For the Go-binary scanner it was not: it walked every PID, dropped only infra module paths, and shipped every remaining binary's module path, dependencies, pod UID and container ID to the controller, where the namespace filters were applied for the first time — at the join. A customer filtering the agent to two namespaces still had every Go process on every node leave the node. The node cannot fix this alone: zero API access by construction (ADR 0009), and a cgroup yields a pod UID, never a namespace. So it asks. A new /v1/node-scope endpoint answers the querying node with the UIDs of the pods on it that passed the filters — PodWatcher's admitted index itself, so the controller cannot name an excluded pod, it is answering from the filtered set rather than filtering at answer time. Three properties, each tested: - Fail closed. No endpoint, or an unreachable controller, means the node scans nothing. Scanner.Scan now requires a Scope and the zero Scope admits nothing, so no call site can scan unscoped by omission. A lost pass costs nothing; the next re-scans (loss-harmless, ADR 0003). - The cgroup is read before the executable. An out-of-scope process has its binary never opened and its module path never extracted — not collected, as opposed to collected and dropped (invariant 4). Only an aggregate filtered_scope counter moves (invariant 6). - A process in no pod — kubelet, a systemd unit — is never in scope, because no namespace filter can permit what is in no namespace. Those were previously reported with an empty pod UID and dropped at the join. Not a control channel (invariant 1): the external backend is uninvolved, the reply is identifiers derived from the operator's own Helm-values filters, and it can only narrow what the node does, never widen it. The node e2e could no longer assert "the scanner finds this Go workload" without a controller, so it became the fail-closed e2e — the security property proven against the real image and manifest, with a real Go workload running and still not reported. The positive path is TestGoInventoryEndToEnd, which deploys a controller and asserts on the payload. Also fixes a compile break the e2e package had carried since #39: the tagged tests are never built by `go test ./...`, so a changed signature rotted there unnoticed. `make test` and CI now run `go vet -tags e2e ./...`. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
A zero was ambiguous.
ThrottledPeriods,TotalPeriodsand both PSI stall counters were plain counters with a zero default, so a container that was never throttled and a container whose node's/metrics/cadvisorscrape failed for the whole window produced byte-identical records. The cluster-wide signal set existed, but only in a log line, and could not resolve the question for any single record — a scrape fails per node and per path, not per cluster.A partial window looked like a quiet one, too:
Samplescounted rate observations but nothing said how much of the window they spanned. A container that started fifty minutes into an hour and one whose node was unreachable for fifty minutes were indistinguishable.Only the agent can know any of this. From outside the cluster a failed scrape is unrecoverable after the fact, so it belongs in the payload rather than in a log the backend never sees.
What ships
Per record
cpu.throttling_samples,cpu.psi_samples,memory.psi_samples— how many observations carried each signal. A zero total with a zero sample count now reads as never observed; with a non-zero count it reads as observed and quiet.cpu.covered_nanoseconds— the summed duration of the intervals attributed to the record, split across windows exactly as the counters are. Againstwindow_secondsit says how much of the window was seen.All four add under merge; the generated merge-property tests (commutativity, associativity, partitioned-equals-whole) cover them by construction and pass unchanged.
Per payload
observationblock: poll cadence, cumulative kubelet requests attempted and failed, and the exposed signal set. Both kubelet paths count as separate requests because they fail independently — which one failed is resolved per record by the sample counts, not here.Provenance completes.
usage_snapshot/usage_window→measured,oom_kill→journal,go_inventory→structural. The gap ADR 0012 recorded is closed in one golden migration rather than field by field later.Bug fixed alongside — it is the same question
Throttling kept its counter baselines under the pod name the cAdvisor exposition labels by, while CPU and memory kept theirs under the pod UID. A name is not an identity: a StatefulSet recreates a pod under the same name with a new UID, and the replacement's counters were then compared against a dead container's baseline — dropped as a phantom counter reset, or, when the new counters happened to exceed the old, stretched into one continuous run.
LookupPodByNamenow returns the UID and all three kubelet signals key on it.TestThrottlingDoesNotCrossPodRecreationis the guard: with name keying it reports 400/1000 (the replacement's own periods silently lost), with UID keying 403/1010 (each container attributed from its own start).Golden bytes changed
Four goldens: both usage payloads (source, observation, four new record fields),
oom-killandgo-inventory(source). This is a deliberate protocol change, taken all at once. The regeneratedusage-snapshot.golden.jsonis itself the demonstration:indexshowsthrottled_periods: 0withthrottling_samples: 0,webshowsthrottled_periods: 40withthrottling_samples: 1.ADR 0013 — written from the implementation
Records where completeness lives (per-record for per-record questions, per-payload for cluster-wide ones), why the counters are cumulative, and the identity fix. Also records what was deliberately not done: no "expected sample count" — it would require the agent to model when a container should have existed, which is a judgment, and
covered_nanosecondscarries the same information without it; and no reason string for non-coverage — the agent reports counters, the backend renders the cause.Invariant claims
Docs
docs/security.md— new subsection on what the agent reports about its own collection, and why (honesty about gaps), stating that nothing in it identifies anything in the cluster.docs/backend-requirements.md— theobservationcontract, an explicit MUST that a zero total with a zero sample count is not zero, and the signal set moved from self-info to the payload it qualifies.Tests
internal/rollup— coverage splits with the interval across a window boundary; a partially observed window covers less than its length; observed-and-quiet versus never-observed produce different records with identical totals.internal/collector— throttling does not cross pod recreation (the regression guard); throttling sample count separates quiet from unobserved; observation counts every kubelet request and its failures across three failure combinations (driven through a real REST client against anhttptestkubelet); the signal set reports a cluster without PSI as such.internal/sink— goldens.make test(go test -race ./...) andmake lintgreen.🤖 Generated with Claude Code