observability: restart-resilient counters + proposal-parent labels - #2861
observability: restart-resilient counters + proposal-parent labels#2861iurii-ssv wants to merge 5 commits into
Conversation
Phase 1 of ssvlabs/ssv-node-board#1069. Splits observability.Initialize into InitializeLogger (early) and Initialize (late) so operator_id can be baked into OTel resource attributes after operatorDataStore is ready — every metric and trace then carries ssv.operator_id automatically. New WithResourceAttributes option carries the late-known attribute set. New operators not yet registered on-chain are emitted without the label rather than with operator_id=0; they pick it up after a restart following registration. Adds observability/metrics.RegisterSparseCounter + EmitBaselines so PromQL increase()/rate() return correct values after process restart. Applied to 26 sparse counters across 10 packages (proposal_parent and attestation_data sets in beacon/goclient plus 16 vulnerable counters from a broader audit: network/streams oversized payloads, network/peers connection events, network/discovery skipped peers, message/validation ignored/rejected, eth/executionclient client lifecycle + RPC errors, eth/eventhandler failures, operator/validator removals/errors/dropped, protocol/v2/ssv/runner submission failures, protocol/v2/qbft round changes). Limitation: only baselines the unlabeled series — counters that emit with per-call attributes still produce one un-baselined series per attribute set on first labeled increment. Per-attribute-set baselines are future work. beacon/goclient/proposer.go: - slot==0 guard in verifyProposalParent (prevents uint64 underflow) - demote mismatch log from Warn to Info: observability-only with no corrective action, and the metric commonly fires during normal fork resolution (cache-vs-BN drift) rather than true staleness — Warn was alert noise - thread bestClient through getProposalParallel and label all four proposal_parent counter Add() sites with ssv.beacon.client Incidental cleanups: - observability/metrics and observability/traces package loggers default to zap.NewNop() so they don't panic on a nil pointer if InitLogger hasn't been called yet (latent bug) - operator/validator validators.removed description corrected from "total number of validator errors" copy-paste Deferred to follow-ups (noted in #1069): - beacon_client label for the 8 attestation_data counters (simpleAttestationData uses gc.multiClient which doesn't expose which underlying client returned the data) - ssvsigner sparse counters (separate Go module — needs a small duplicate of the baseline mechanism there) - per-attribute-set baselines for fully-labeled increase()/rate() to work after restart
Codecov Report✅ All modified and coverable lines are covered by tests. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Greptile SummaryThis PR introduces three coordinated observability improvements: restart-resilient sparse counters via a new
Confidence Score: 4/5Safe to merge; no changes to the duty hot path, all observability-only. The core logic is sound: bestClient is always set alongside bestProposal in getProposalParallel, the slot-0 underflow guard is correct, and the OTel proxy pattern means instruments created at package init time properly delegate to the real provider after otel.SetMeterProvider is called. Two counters (messageValidationsIgnored/Rejected, connected/disconnected) are registered as sparse when they likely fire frequently in production — functionally harmless but worth a follow-up on the next audit pass. message/validation/observability.go and network/peers/connections/observability.go carry counters that may not be truly sparse; cli/operator/node.go has a longer startup window before metrics land (acceptable trade-off, but worth watching on the staging deploy checklist). Important Files Changed
Sequence DiagramsequenceDiagram
participant N as node.go (StartNodeCmd)
participant OL as observability.InitializeLogger
participant ODS as operatorDataStore
participant OI as observability.Initialize
participant OTel as OTel MeterProvider
participant EB as metrics.EmitBaselines
N->>OL: InitializeLogger(level, format, ...)
OL-->>N: global zap logger set, metrics/traces loggers propagated
Note over N: Setup: network, beacon client, storage, signing config...
Note over N: (Metrics emitted here to no-op provider, silently dropped)
N->>ODS: setupOperatorDataStore(logger, nodeStorage, pubKey)
ODS-->>N: operatorDataStore (with OperatorID if registered on-chain)
alt OperatorIDReady()
N->>N: append WithResourceAttributes(OperatorIDAttribute(id))
end
N->>OI: Initialize(ctx, appName, appVersion, options...)
OI->>OTel: SetMeterProvider(prometheusProvider)
OI-->>N: shutdownFn
N->>EB: EmitBaselines(ctx)
EB->>OTel: counter.Add(ctx, 0) x26 sparse counters
EB-->>N: baseline zeros written, Prometheus has reset anchor
Note over N: Node continues startup (network, duties, API...)
|
Extends EmitBaselines so packages can pre-emit baselines for the specific attribute combinations they'll use at runtime — not just the unlabeled series. New RegisterLabeledBaseline takes a callback that the package implements to iterate its known label combinations and emit Add(ctx, 0, WithAttributes(...)) for each. Applied to the proposal_parent counters in beacon/goclient: after goclient.New populates the configured beacon clients, we register a baseline emitter that pre-creates the (counter, ssv.beacon.client) time series for each beacon. PromQL increase()/rate() now return correct values for per-beacon queries even after process restart. The unlabeled RegisterSparseCounter path still exists for counters whose labels aren't known at startup or that don't carry per-call attributes. Addresses feedback on ssvlabs/ssv-node-board#1069: restart-resilience must pre-register all expected label combinations at startup since the issue is missing baseline samples, not counter resets per se.
…actor) Tests - observability/metrics/baseline_test.go: covers RegisterSparseCounter pass-through, EmitBaselines unlabeled-zero emission via an OTel manual reader, labeled-baseline fn invocation, the defensive-copy contract for re-entrant registration, and repeat-call idempotency. Tests snapshot the process-global registries so they isolate cleanly. - beacon/goclient/proposer_verify_test.go: covers verifyProposalParent slot==0 short-circuit, cache-miss/match silence, and that the mismatch log fires at Info with the new beacon_client field. cli/operator/node.go - Drop the early observabilityShutdown var and nil-check defer; declare it at the Initialize call site and register the shutdown defer only after a successful Initialize (Fatal earlier in startup os.Exits and has nothing to shut down). - Switch observabilityOptions to the idiomatic var declaration form. - Log an Info confirming observability init success, with metrics_enabled / traces_enabled / operator_id_label fields so misconfiguration is visible in startup logs. - Comment why OperatorIDReady() is intentionally false in exporter mode so future readers don't "fix" the missing label. observability/configurator.go - Drop misleading `_ =` on initLogger; expand InitializeLogger docstring with the symmetric note that Initialize does not re-propagate the logger. - Document that zap.L() inside Initialize returns the no-op global if InitializeLogger was never called. observability/metrics/baseline.go - Document that the registries are process-global and append-only, with the test-isolation caveat. - Document EmitBaselines as harmless-but-wasteful on repeat calls. beacon/goclient/proposer.go - Move the long trailing comment on beaconClient into a preceding block. - Expand the slot==0 guard comment to clarify production never reaches this branch; it is uint64-underflow defense for synthetic-slot tests. eth/executionclient/observability.go - Drop the "Sparse-ish" hedge on multiClientMethodErrorsCounter.
CI lint failures - observability/option.go: drop trailing blank line at EOF (gofmt). - observability/resources.go: preallocate baseAttrs slice (prealloc). - observability/attributes.go: drop redundant uint64() cast on spectypes.OperatorID (which is `type OperatorID = uint64`) — matches the existing pattern in ValidatorProposerAttribute. Tests - observability/configurator_test.go: lock the InitializeLogger / Initialize contracts: InitializeLogger propagation into metrics; Initialize works without InitializeLogger (no-op fallback); Initialize does NOT replace metrics.logger (verified via sentinel + RecordUint64Value error trigger). The third test guards the "intentionally does not re-propagate" comment from silent regressions. - beacon/goclient/proposer_verify_metric_test.go: lock the metric-label contract — verifyProposalParent's three branches each fire the expected counter carrying ssv.beacon.client. Subtests share one OTel meter provider because the global delegating-meter only re-binds package-level instruments on the first SetMeterProvider call after package init; per-test providers don't compose. Code / comment edits - beacon/goclient/observability.go: document the invariant that the `clients` slice passed to registerProposalParentBaselines must be final at call time (the closure captures the slice header). - beacon/goclient/proposer.go: route the mismatch log's beacon_client field through the new fields.BeaconClient helper. - observability/log/fields/fields.go: add fields.BeaconClient helper and FieldBeaconClient constant, mirroring the ssv.beacon.client metric attribute (dotted OTel form vs snake_case log convention). - cli/operator/node.go: rename metrics_enabled / traces_enabled log fields to _configured (they reflect config, not runtime state); gate EmitBaselines on cfg.MetricsAPIPort > 0 to skip no-op iteration when metrics are disabled. - observability/metrics/baseline.go: explain the locking asymmetry in EmitBaselines — sparse-counters loop holds the lock during Add because Int64Counter.Add is a leaf and cannot reenter the registry; labeled-baselines path snapshots first because user-provided fns may. - network/peers/connections/observability.go, message/validation/observability.go: replace ambiguous "Sparse" / "Possibly sparse" comments with TODO(audit) markers — re-evaluate these classifications against production rates. Mis-classifying is harmless (Add(0) on a dense counter is a no-op for PromQL) but the registry is meant to document intent.
observability/metrics, observability/traces - Add Logger() *zap.Logger getter symmetric with InitLogger. Primary use is letting tests capture-and-restore the package logger around mutations rather than blindly resetting to no-op. observability/configurator_test.go - installSentinelMetricsLogger now captures the original metrics.Logger() and restores it on cleanup (was: reset to zap.NewNop, which lost any pre-existing state). - New restoreMetricsLogger helper for tests that call InitializeLogger (which mutates metrics.logger as a side effect) but don't install a sentinel themselves. - TestInitializeLogger_PropagatesToMetricsPackage no longer duplicates the metrics-logger cleanup — installSentinelMetricsLogger handles it. beacon/goclient/proposer_test.go - createProposalResponseSafe now SSZ-deep-clones the spec-testing fixture (TestingBlockContentsElectra / TestingBlindedBeaconBlockElectra) before mutating Slot / FeeRecipient. Those package-level singletons were being mutated in place on every call, leaking state across every test in the binary that read from the same fixture. The fix is preventive — no test has been observed to be affected today. beacon/goclient/proposer_verify_test.go, beacon/goclient/proposer_verify_metric_test.go - Drop `proposal.Electra.Block.Slot = 100` mutations from all subtests. Tests now use whatever default Slot the fixture provides (ForkEpochPraterElectra) — they only need slot > 0 and a consistent parent root, both of which the default satisfies. - Add explanatory comment block documenting why mutation is avoided.
| // switch to the snapshot-then-invoke pattern used for labeled baselines below. | ||
| sparseCountersMu.Lock() | ||
| for _, c := range sparseCounters { | ||
| c.Add(ctx, 0) |
There was a problem hiding this comment.
Might be worth a closer look here: EmitBaselines emits Add(ctx, 0) with no attributes, but it seems most of the counters registered as sparse are only ever incremented with a per-call attribute set — connected/disconnected/filtered carry direction, events_failed the event name, rounds_changed role/round/reason, submissions.failed the role, router.messages.dropped the reason, etc.
For those, the zero baseline lands on a phantom {} series that production never touches, so the real labeled series still start cold after a restart — the exact increase()/rate() gap this is meant to close. The proposal_parent counters handle this correctly via RegisterLabeledBaseline.
Maybe we either extend that pattern to the labeled counters, or explicitly de-scope them and tighten the docs so the mechanism's current reach is clear? The genuinely-unlabeled ones (multi_client.method_errors, validator errors, attestation_data.*) do work as-is.
| // Metric and trace provider initialization is deferred until later in startup | ||
| // (after operatorDataStore is set up) so that operator_id can be baked into the | ||
| // OTel resource attributes — every emitted metric/trace is then automatically | ||
| // labeled with the operator. Metrics emitted before that point are dropped, which |
There was a problem hiding this comment.
Worth considering a short note guarding this trade-off: the dropped-metrics window looks sound today (the only counter I found emitting before Initialize is beaconNodeStatusGauge, a re-emitted gauge, so the first dropped sample is harmless).
But it rests on an unasserted invariant — that nothing one-shot fires before Initialize. A one-liner here (or near goclient.New) warning future refactors not to emit one-shot counters before observability is initialized would keep this from silently breaking.
| shared := spectestingutils.TestingBlindedBeaconBlockV(spec.DataVersionElectra).ElectraBlinded | ||
| sszBytes, _ := shared.MarshalSSZ() | ||
| block := new(apiv1electra.BlindedBeaconBlock) | ||
| _ = block.UnmarshalSSZ(sszBytes) |
There was a problem hiding this comment.
Might be worth require.NoError on the marshal/unmarshal here.
The deep-clone nicely fixes the shared-singleton mutation, but if UnmarshalSSZ ever errored we'd silently get a zero-valued block and a confusing downstream failure — a guard would localize any future fixture breakage.
Summary
Phase 1 of ssvlabs/ssv-node-board#1069.
observability/metrics.RegisterSparseCounter+EmitBaselinesso PromQLincrease()/rate()return correct values after process restart. Applied to 26 sparse counters across 10 packages (theproposal_parentandattestation_datasets inbeacon/goclientplus 16 vulnerable counters from a broader audit). SingleEmitBaselinescall innode.gostartup.RegisterLabeledBaseline: lets packages pre-emit baselines for the specific label combinations they'll use at runtime, so per-label PromQL queries (e.g.rate({beacon_client="..."})) also work after restart. Applied to the fourproposal_parentcounters for each configured beacon address.operator_idas OTel resource attribute: splitobservability.InitializeintoInitializeLogger(early) andInitialize(late). NewWithResourceAttributesoption carries late-known facts likeoperator_idfromoperatorDataStore. Every metric and trace is now automatically labeled withssv.operator_id(when the operator is on-chain).proposal_parentper-call labels:verifyProposalParentnow receivesbestClientfromgetProposalParalleland labels all four counter sites withssv.beacon.client. Plus two quick wins:slot == 0underflow guard andWarn → Infofor the mismatch log (which was alert noise during normal fork resolution — see Phase-0 evidence below).Phase-0 evidence behind the design
From 30 days of stage data analyzed in #1069 — 28,975 verifications, 16 mismatches across 16 unique operators (uniform distribution, not concentrated).
The metric is a noisy proxy, not a true-staleness signal. Detailed analysis of the 2026-05-24 fork event showed that the operator whose BN was actually on the stale fork did not fire the mismatch warning — its cache agreed with its (stale) BN. The three operators on the winning fork fired the warning, because their caches still held the earlier head root while their BNs had already updated. So the metric systematically flags operators with the fresher BN view, not the staler one. Acting on this signal directly (tier-1/2/3 refetch logic, SIP-83-style leader selection) would harm fork resolution rather than help it.
Other observations from the same window:
This PR ships only the observability + cache-improvement subset; no behavior changes to the duty hot path.
Limitations / known follow-ups
beacon_clientlabel not yet applied to the 8attestation_datacounters — thesimpleAttestationDatapath usesgc.multiClientwhich doesn't expose which underlying client returned the data. Needs separate design.ssvsignersparse counters (3 of them) not covered —ssvsigneris a separate Go module and would need a small duplicate of the baseline mechanism.proposal_parent— other counters labeled at runtime (e.g. by validator role) still need each package to register its own labeled baseline viaRegisterLabeledBaseline. The infrastructure is in place; just hasn't been wired up for those counters yet.Incidental cleanups
observability/metricsandobservability/tracespackage loggers default tozap.NewNop()so they don't panic on a nil pointer ifInitLoggerhasn't been called yet (latent bug — instruments are created during package init, beforemain()).operator/validatorvalidators.removeddescription corrected from "total number of validator errors" copy-paste.Test plan
go build ./...— cleango vet ./...— cleango teston all packages touched (observability, beacon/goclient, network/streams, network/peers/connections, network/discovery, message/validation, eth/executionclient, eth/eventhandler, operator/validator, protocol/v2/ssv/runner, protocol/v2/qbft/instance, cli/operator) — all greengo test ./operator/... ./protocol/... ./api/... ./exporter/...— all greenssv_cl_proposal_parent_*_totalseries includessv.operator_idandssv.beacon.clientlabelsincrease(ssv_cl_proposal_parent_verify_total{cluster="stage"}[30d])returns non-zero (proves unlabeled baseline emission works)increase(ssv_cl_proposal_parent_verify_total{beacon_client="..."}[30d])returns non-zero for each configured beacon (proves per-label baseline emission works)infolevel, notwarnoperatorDataStoreis ready are silently dropped (no errors in startup logs)