feat(egress): count DNS resolution and nftables update failures - #1410
Conversation
The sidecar had no metric for either of its two ways of failing, so both were
visible only in logs.
DNS: serveDNS recorded the same latency sample whether forward succeeded or
returned an error, and nothing else. A sandbox that cannot resolve anything
because the resolver chain is down was indistinguishable from a healthy one, and
easy to confuse with egress.policy.denied_total, which counts the opposite
situation - the policy working as designed. forward now returns the bounded
reason it failed with (it already tracked the distinction internally and threw it
away) and serveDNS turns that into egress.dns.query.failed_total{reason}.
nftables: RecordNftablesUpdate was only ever called on success. The sharp case is
AddResolvedIPs, which adds the IPs behind an allowed domain to the dynamic allow
set: when it fails the chain drops traffic the policy permits, which looks like a
denial from inside the sandbox while no counter moves at all. Now counted as
egress.nftables.updates.failed_total{operation}, alongside static_apply and
remove.
Both attributes come from closed sets, so cardinality is fixed and neither
queried names nor error strings can reach a label. New counters rather than an
outcome attribute on the existing ones, whose descriptions already promise
successes only.
Fixes opensandbox-group#1406
Fixes opensandbox-group#1407
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7b1a8c9694
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
The new counters were documented only in components/egress/docs, which nothing in docs/ links to, so the published site would not have shown them or their alerting guidance. Per AGENTS.md operations-visible content belongs in docs/. docs/components/egress.md gains the denied-vs-failed distinction, which is the part that actually matters to an operator: the two counters look similar and mean opposite things, and reading one for the other inverts the diagnosis. Its Observability section previously named no metrics at all, and now points at the component page for the full inventory.
|
Correct — fixed in afd932f.
What I put in Same fix applied to my other two open metric PRs (#1405, #1411). |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: afd932f743
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
The new static_apply counter was recorded and then thrown away. setupNft calls log.Fatalf on an ApplyStatic error, Fatalf calls os.Exit(1), and that skips main's deferred otelShutdown — so with metrics leaving through a periodic reader, the one sample explaining why the sidecar died was never exported. Counting it was worse than not counting it: the failure looked covered and was not. Add ForceFlush to the shared telemetry package (no-op when metrics are disabled) and call it on that path before exiting. Runtime policy updates are unaffected; they keep exporting on the normal interval.
|
Sharp catch, and correct — fixed in 1f04758. You're right that this made the counter worse than useless: Added I chose flushing over returning the error and letting Documented in the component page, since "this counter is only useful because that path flushes" is exactly the kind of thing that gets silently broken later. |
|
Please resolve conflicts |
…-counters # Conflicts: # components/egress/docs/opentelemetry.md # components/egress/pkg/telemetry/metrics_test.go # docs/components/egress.md
|
Done — conflicts resolved in 4b70b47. They came from #1405 landing, which I had flagged in the description: both PRs touch the instrument registration block and the same docs sections. Nothing semantic was in dispute — each side had added a different section at the same anchor — so everything from both is kept:
One thing the merge surfaced that I fixed rather than just resolved: Verified on the merged tree: I also merged And thanks for the #1405 review and merge. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1569f3f253
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Fixes #1406. Fixes #1407.
The egress had no metric for either of its two ways of failing. Both were visible only in logs.
DNS —
egress.dns.query.failed_total{reason}serveDNSrecorded the same latency sample whetherforwardsucceeded or returned an error, and nothing else. So a sandbox that cannot resolve anything because its resolver chain is down looked identical to a healthy one.Worse, the nearest-looking metric means the opposite.
egress.policy.denied_totalcounts the policy working as designed; there was nothing counting the sidecar failing to do its job. The two situations invite opposite responses and were indistinguishable.forwardalready tracked why each upstream failed and discarded it on the way out. It now returns a bounded reason:reasonno_upstreamsupstream_errorExchangefailed (network, timeout)empty_responsercodeSERVFAILnftables —
egress.nftables.updates.failed_total{operation}RecordNftablesUpdatewas only ever reached on success (if err == nilinAddResolvedIPs; after theruninApplyStatic; nowhere inRemoveEnforcement).dynamic_addis the case worth alerting on: it adds the IPs behind an allowed domain to the dynamic allow set, so when it fails the chain drops traffic the policy permits. Inside the sandbox that is indistinguishable from a denial, and outside it nothing moves at all — notpolicy.denied_total(it was not a policy decision) and notnftables.updates.count(there was no success). A silent fail-closed outage.Design notes
outcomeattribute on the existing ones: both their descriptions already promise successes, so adding a failure dimension would silently change what existing series mean.sandbox_id.egressMetricOptWithcopies the shared attribute slice rather than appending to it. That slice comes from async.OnceValueand can have spare capacity, soappendwould write into the shared backing array and leak one call's reason into another's. There is a test for exactly this.Testing
go test ./...green across the egress module,go vetandgofmtclean, builds forlinux/amd64.Two things worth flagging about the tests, because the first version of them was wrong:
SO_MARK, which returnsEPERMwithoutCAP_NET_ADMIN. My "every upstream unreachable" case therefore passed for the wrong reason —upstream_errorfrom the mark, not from the unreachable port. Both subtests now exempt loopback (EnvNameserverExempt), the same way the existingTestForwardAddsEDNS0BufferSizedoes, so they exercise the path they claim to.NotifyStartedFuncinstead of trusting the goroutine scheduler, so it cannot flake intoupstream_error.Note on overlap
Touches
pkg/telemetry/metrics.go, as do my #1405 and the follow-up for #1409. They are independent in substance; whichever merges first leaves the others needing a trivial rebase in the instrument-registration block.