Skip to content

encryption: add sidecar and KEK metrics (Stage 9C-2, §9.2) - #1223

Open
bootjp wants to merge 2 commits into
design/encryption-9c-metricsfrom
design/encryption-9c2-sidecar-kek-metrics
Open

encryption: add sidecar and KEK metrics (Stage 9C-2, §9.2)#1223
bootjp wants to merge 2 commits into
design/encryption-9c-metricsfrom
design/encryption-9c2-sidecar-kek-metrics

Conversation

@bootjp

@bootjp bootjp commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Stacked on #1221 (Stage 9C-1). Review that first; this branch targets design/encryption-9c-metrics, and I'll retarget to main once #1221 merges.

What

Stage 9C-2 of docs/design/2026_04_29_partial_data_at_rest_encryption.md — the three remaining §9.2 metrics that have live sources today.

Metric Type Source
elastickv_encryption_active_dek_id{purpose} gauge StateCache mirror of sidecar.Active.{Storage,Raft}
elastickv_encryption_sidecar_raft_index gauge StateCache mirror of sidecar.RaftAppliedIndex
elastickv_encryption_kek_unwrap_seconds histogram decorator on the loaded KEK source

After this, only last_proposed_index_per_raft_dek remains from §9.2 — it needs the §5.4 raft-DEK Wrap path, so it stays unregistered rather than always-zero.

Decisions worth reviewing

Both purpose series are published at 0 on construction. Without that, an alert on active_dek_id == 0 cannot distinguish "not bootstrapped" from "this node never reported" — very different incidents. Pinned by a revert-checked test.

The mirrors are refreshed by the existing RefreshFromSidecar, the same call that maintains the decision mirrors, so metrics state cannot drift from decision state. They are observability-only and explicitly documented as such — the raft envelope path resolves its own key id through the raft envelope runtime, not through this mirror.

KEK timing decorates once, at the single load site. Every unwrap path — startup hydration, bootstrap apply, rotation apply — is timed without each needing its own instrumentation.

Failed unwraps are timed. A KMS outage shows up as slow errors; excluding them would hide exactly the signal the histogram exists for.

The decorator wraps errors rather than carrying a //nolint. wrapcheck flagged the pass-through; errors.Wrapf preserves Is/As, so the startup guards that match ErrKEKMismatch still see through it — pinned by a test that asserts errors.Is survives the decorator.

A test that was lying

TestTimedKEKUnwrapperTimesAndPreservesFailures originally asserted CollectAndCount(kekUnwrapSecond) == 1. That counts series, and a histogram is one series whether or not anything was observed — so it passed with the observation removed. It now asserts the gathered histogram's SampleCount, and revert-check C below fails as it should.

Behavior change / risk

Observability only; no decision reads these values. The one non-metrics change is the KEK decorator sitting on the unwrap path: it adds two time.Now() calls and now wraps the returned error with the source name. errors.Is/As are preserved, verified by test. NewTimedKEKUnwrapper returns the inner source unchanged when metrics are absent, so an unmetered node keeps the exact object it had before.

Test evidence

  • go test . ./monitoring/ ./internal/encryption/... ./store/ -race -count=1 — all pass
  • golangci-lint run (full repo) — 0 issues, no //nolint added
  • Revert-checked (restore verified byte-exact with diff -q):
    • A. RefreshFromSidecar stops mirroring the raft slot → TestStateCacheMirrorsRaftSlotAndSidecarIndex FAILs
    • B. pre-bootstrap posture not published at construction → TestEncryptionMetricsPublishPreBootstrapPostureAtConstruction FAILs
    • C. failed unwrap no longer timed → TestTimedKEKUnwrapperTimesAndPreservesFailures FAILs (only after fixing the assertion above — the first version passed)

Self-review (five passes)

  1. Data loss — none possible; no write, apply, or storage path touched. The KEK decorator is on the unwrap path but returns the inner result unchanged on success.
  2. Concurrency / distributed failures — the mirrors are atomic.Uint32/Uint64 written by the same FSM-apply goroutine that already writes the decision mirrors; the observer only reads. Observer goroutine exits on ctx cancel. Race-clean across all touched packages.
  3. Performance — two time.Now() calls per KEK unwrap (rare: startup and encryption applies only). The gauge collector ticks at the existing raftMetricsObserveInterval and does no I/O — that is why the values are mirrored into the cache instead of read from the sidecar file.
  4. Data consistency — no MVCC/OCC/HLC interaction. The mirrors are refreshed atomically with the decision mirrors from the same Sidecar value, so a reader cannot observe a half-updated pair.
  5. Test coverage — 8 new monitoring tests, 2 StateCache tests, 3 revert-checks. Nil-receiver and nil-source paths covered.

https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE

Adds the three remaining §9.2 metrics that have live sources today:

  elastickv_encryption_active_dek_id{purpose}
  elastickv_encryption_sidecar_raft_index
  elastickv_encryption_kek_unwrap_seconds

StateCache gains observability-only mirrors of sidecar.Active.Raft and
sidecar.RaftAppliedIndex, refreshed by the same RefreshFromSidecar call
that maintains the decision mirrors so they cannot drift from them. A
polling observer publishes them, sampling immediately at startup rather
than making an operator wait a full interval after a restart.

Both purpose series are published at 0 on construction: without that,
an alert on active_dek_id == 0 cannot distinguish "not bootstrapped"
from "this node never reported", which are very different incidents.

KEK latency is captured by decorating the loaded KEK source once in
main.go, so every unwrap path (startup hydration, bootstrap apply,
rotation apply) is timed without per-path instrumentation. Failed
unwraps are timed too — a KMS outage surfaces as slow errors, and
excluding them would hide the signal the histogram exists to expose.

last_proposed_index_per_raft_dek still needs the §5.4 raft-DEK Wrap
path and stays unregistered rather than always-zero.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@bootjp

bootjp commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@bootjp

bootjp commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 66aa3afa-7146-437d-bb4a-91e6dab80107

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-07T13:23:50.021721Z b978c3d Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 2m 14s —— View job


Review: encryption: add sidecar and KEK metrics (Stage 9C-2, §9.2)

  • Gather context (PR description, changed files)
  • Fetch branch diff against design/encryption-9c-metrics
  • Review monitoring/encryption.go / monitoring/registry.go
  • Review internal/encryption/applier.go
  • Review tests
  • Review main.go wiring
  • Post final review feedback

Note: go build/go test/go vet were not runnable in this sandbox (Bash approval required and no interactive user to grant it), so this review is based on static reading of the diff plus the test evidence already in the PR description. If you'd like me to actually execute the test suite, allow the relevant Bash commands and re-trigger.

Summary

This is a clean, well-scoped observability-only change. I traced the two things most likely to hide a bug in a change like this and both check out:

  1. Mirror consistencyactiveRaftDEKID/sidecarRaftAppliedIndex (internal/encryption/applier.go:238-239) are written only inside RefreshFromSidecar (applier.go:256-264), and every call site that updates the decision mirrors (writeBootstrapSidecar at applier.go:902, applyEnableStorageEnvelope at applier.go:1188, applyEnableRaftEnvelope at applier.go:1354, writeRotationSidecar at applier.go:1439, plus the constructor prime at applier.go:521) goes through the same call, so there's no path where the metrics mirrors can drift from the decision state. Matches the PR's claim.
  2. Nil/typed-nil safety for the observerEncryptionStateObserver.Start (monitoring/encryption.go:243) checks source == nil on the interface, which would not catch a typed-nil *encryption.StateCache boxed in the interface. But that's fine here: ActiveStorageKeyID, ActiveRaftKeyID, and SidecarRaftAppliedIndex all have if c == nil guards, and encryptionWriteWiring.withDefaultedCache() (main_encryption_write_wiring.go:193) guarantees encWiring.cache is never actually nil at the startMonitoringCollectors call site anyway. No bug, just noting I checked the edge case.

One thing worth a second look, not a blocker:

  • main.go:634installEncryptionRotateOnStartup is wired with the raw kekWrapper, not the new kekUnwrapper decorator (only the buildShardGroupsWithEncryptionWiring call at main.go:511 got the decorated one). I traced this through: wrapFreshStartupDEK (main_encryption_rotate_on_startup.go:531-545) only calls .Wrap, never .Unwrap, and TimedKEKUnwrapper.Wrap is a pure passthrough anyway, so today there's no timing gap. It's a latent trap for later, though — if rotate-on-startup ever grows an Unwrap call (e.g. to verify the freshly-wrapped DEK round-trips), it would silently bypass kek_unwrap_seconds unless someone remembers to swap in kekUnwrapper there too. Might be worth a one-line comment at main.go:634 noting why the raw wrapper is intentionally used there, so it doesn't look like an oversight.

Everything else matches what the description claims:

  • Both active_dek_id purpose series are published at 0 on construction (monitoring/encryption.go:117-121), correctly distinguishing "not bootstrapped" from "never reported."
  • TimedKEKUnwrapper.Unwrap (monitoring/encryption.go:307-318) times both success and failure paths and wraps errors with errors.Wrapf, which preserves errors.Is/As — confirmed against internal/encryption's sentinel-error pattern.
  • NewTimedKEKUnwrapper (monitoring/encryption.go:298) returns the inner source unchanged when either inner or observer is nil, so an unmetered node's KEK wrapper identity doesn't change — good for anything doing pointer/type comparisons downstream.
  • Doc updates (docs/design/2026_04_29_partial_data_at_rest_encryption.md) accurately reflect the new metric list and correctly leave last_proposed_index_per_raft_dek unregistered pending §5.4.
  • Test coverage looks thorough: pre-bootstrap posture, tick-vs-immediate-sample behavior, nil-source/nil-receiver inertness, failed-unwrap timing, and the errors.Is passthrough are all pinned by dedicated tests. The "test that was lying" callout (fixing CollectAndCountSampleCount) is a good catch and the writeup explains it well.

No correctness, data-loss, or concurrency issues found. Nice, tightly-scoped PR — good adherence to the design-doc-first workflow and the five-pass self-review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 16592c464f

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

c.activeStorageDEKID.Store(sc.Active.Storage)
c.storageEnvelopeActive.Store(sc.StorageEnvelopeActive)
c.activeRaftDEKID.Store(sc.Active.Raft)
c.sidecarRaftAppliedIndex.Store(sc.RaftAppliedIndex)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh the cache after no-op sidecar writes

When a stale or duplicate enable-storage-envelope/enable-raft-envelope entry is applied, the no-op branches in applyEnableStorageEnvelope and applyEnableRaftEnvelope advance and persist sc.RaftAppliedIndex but return without calling RefreshFromSidecar. Because the new sidecarRaftAppliedIndex mirror is the sole source for elastickv_encryption_sidecar_raft_index, the metric remains behind the actual persisted sidecar until another fresh encryption mutation or restart, potentially producing a false sidecar-divergence signal.

Useful? React with 👍 / 👎.

Comment thread main.go Outdated
// the single place the KEK source is loaded — means every unwrap
// path (startup hydration, bootstrap apply, rotation apply) is
// timed without each one needing its own instrumentation.
kekUnwrapper := monitoring.NewTimedKEKUnwrapper(kekWrapper, metricsRegistry.KEKUnwrapObserver())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decorate the KEK before startup unwrapping

On every successful startup with a configured KEK, loadKEKAfterPreNonceStartupGuards invokes both CheckStartupGuards and kek.VerifyWrapper using the raw wrapper before this decorator is constructed. Their KEK unwrap round trips—potentially slow remote-KMS calls—are therefore omitted from elastickv_encryption_kek_unwrap_seconds; on a fresh node the preflight unwrap can be the only startup unwrap, leaving the histogram empty despite a completed call. Construct or inject the timed wrapper before these startup checks so the metric covers the advertised unwrap paths.

Useful? React with 👍 / 👎.

Two P2s from review.

The no-op apply branches (stale DEKID, already-active cutover) advance
and persist sc.RaftAppliedIndex but returned without refreshing the
StateCache, so the mirror — and therefore
elastickv_encryption_sidecar_raft_index — sat behind the durable
sidecar until the next fresh mutation or a restart, which reads as a
false sidecar-divergence signal.

Fixed structurally rather than at the two named branches: all eight
sidecar-write sites now go through Applier.persistSidecar, which pairs
the write with the refresh. Patching only the reported branches would
have left the same latent defect in every future one.

The KEK decorator was constructed after loadKEKAfterPreNonceStartupGuards
had already run CheckStartupGuards and kek.VerifyWrapper against the
raw wrapper. Both perform real unwrap round trips, and on a fresh node
the preflight unwrap can be the only one that ever happens, so
elastickv_encryption_kek_unwrap_seconds could stay empty despite
completed KMS calls. The source is now decorated inside the loader,
before those guards run.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@bootjp

bootjp commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Both P2s fixed in b978c3dc. Both were correct.

No-op sidecar writes left the mirror behind. Confirmed: the stale-DEKID and already-active branches call advanceRaftAppliedIndex + WriteSidecar and return, so sidecarRaftAppliedIndex — the sole source for elastickv_encryption_sidecar_raft_index — lagged the durable sidecar until the next fresh mutation or restart. That is exactly a false sidecar-divergence signal.

Fixed structurally, not at the two named branches. I audited all WriteSidecar(a.sidecarPath, …) sites: 8 writes, 5 refreshes, so the reported pair was not the whole set. All eight now go through Applier.persistSidecar, which pairs the write with the refresh, so a future no-op branch cannot reintroduce the gap. Patching only the two named sites is the failure mode I keep hitting on these reviews, so I went for the invariant instead.

KEK decorated too late. Confirmed: loadKEKAndRunStartupGuards runs CheckStartupGuards and kek.VerifyWrapper against the raw wrapper, and on a fresh node that preflight unwrap can be the only unwrap that ever happens — the histogram would stay empty despite a completed KMS call. The source is now decorated inside the loader, before those guards run, and run() uses the returned decorated wrapper throughout.

Revert-checked (restore byte-exact): reverting the two no-op branches to bare WriteSidecar fails TestApplierRefreshesTheCacheOnNoOpSidecarWrites with "the cache must never lag the durable sidecar after a no-op write". TestTimedKEKUnwrapperCoversStartupPreflightUnwraps pins the decoration order.

go test . ./monitoring/ ./internal/encryption/... -race: pass. Full-repo golangci-lint: 0 issues.

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: b978c3dcd5

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant