feat(usagedata): own the anchored emission gate on the tracker
Context
S23 moved usage-data emission onto request-rate paths, and the shared emission gate kept three admin-rate assumptions (#377 (closed)). This MR lands Step 1 of the hardening plan: the tracker becomes the owner of the emission gate, without touching any caller.
What this adds
(*usagedata.Tracker).TrackAnchored(ctx, Source, AnchoredEvent), the tracker-owned gate. Nil and inert trackers return before the gate, so a disabled tracker is silent by construction.- Gate skips land on
gitlab_artifact_registry_usagedata_dropped_events_total(newsourcelabel; new reasonsno_anchor,anchor_not_integer,kind_unmapped,format_unmapped) instead of being log-only. - The per-skip log becomes a WARN sampled to one line per
(source, event, reason)per minute; the window's first skip logs and carries itsnamespace_id. usagedata.Sourceclosed enum, with canonical slices beside each enum feeding the limiter catalog and its pinning tests.docs/dev/observability.mddocuments the relabel; values without a producer on this branch are marked not yet emitted.
The old free-function gate stays byte-identical beside the new one; callers migrate in the next step. The plan's Scope also asks for the coexistence to be "documented as pending deletion", which conflicts with byte-identical (and a plan-step reference in code would break the repo's comment rules); the byte-identical reading won.
Adding source makes any ad-hoc query against the old {event, reason} counter shape stale. Nothing in-repo or in dashboards reads it today.
Spec coverage
Spec: none. This is issue-driven hardening, so the source of truth is
the plan's Step 1 (Scope, Acceptance, Tests) plus the Approach's design
pins in docs/plans/2026-08-03-usage-data-emission-hardening.md.
Step 1 acceptance clauses
| # | Clause | Tests |
|---|---|---|
| A-1 | Two gate implementations coexist | anchor_test.go (unchanged, covers the free function) and the new tracker_test.go rows compile and run in one package |
| A-2 | No caller changed | Not a test property. Verified by the diff: internal/usagedata/anchor.go, anchor_test.go, and all four consumer packages are untouched |
| A-3 | The relabeled counter registers | TestTracker_DroppedCounter_HelpAndLabels |
| A-4 | Every Source and every reason value has at least one positive hit |
TestTracker_TrackAnchored_UnattributableAnchor_CountsAndWarns (5 rows: maven/npm/oci/management, both anchor reasons), TestTracker_TrackAnchored_EnqueueDropsCarryTheCallerSource |
| A-5 | The three inertness contracts | TestTracker_TrackAnchored_Inertness (nil receiver, zero-value tracker, usage_data disabled), each paired with a live-tracker positive control |
| A-6 | Limiter window incl. the terminal unknown slot | TestLimiter_Allow_OneWinnerPerKeyPerWindow, TestLimiter_Allow_EveryCatalogKeyHasItsOwnSlot, TestLimiter_Allow_UnknownKeysShareOneTerminalSlot |
Approach design pins
| # | Pin | Tests |
|---|---|---|
| P-1 | Source feeds the log line, the counter label, the limiter key |
assertSkipWarn (log), droppedCount (label), TestTracker_TrackAnchored_LimiterKeyIsSourceEventAndReason/source (key) |
| P-2 | Enqueue drops take the caller's Source; tracker is the fallback |
TestTracker_TrackAnchored_EnqueueDropsCarryTheCallerSource, TestTracker_Track_DropsDefaultToTheTrackerSource |
| P-3 | Limiter keyed (source, event, reason), one line per minute |
TestTracker_TrackAnchored_LimiterKeyIsSourceEventAndReason (all three), TestTracker_TrackAnchored_WarnsOncePerKeyPerWindow, TestLimiter_Allow_OneWinnerPerKeyPerWindow |
| P-4 | Construction-time slot map over the closed catalog | TestLimiter_Allow_EveryCatalogKeyHasItsOwnSlot (primes the terminal slot first, so a missing key fails) |
| P-5 | One terminal unknown slot, not a per-key fallback table | TestLimiter_Allow_UnknownKeysShareOneTerminalSlot |
| P-6 | CAS winner logs its own namespace_id; losers silent |
TestTracker_TrackAnchored_WarnNamesTheWindowsFirstNamespace. One winner per key per window is pinned by the sequential window tests (TestLimiter_Allow_OneWinnerPerKeyPerWindow, TestTracker_TrackAnchored_WarnsOncePerKeyPerWindow). CAS over read-then-store under true contention is a stated design pin, not an asserted one: TestLimiter_Allow_ConcurrentWindow_ExactlyOneWinner exercises the contended claim and holds the read-only slot map under -race, but it does not reliably detect a read-then-store regression |
| P-7 | The counter counts every skip; only the log is sampled | TestTracker_TrackAnchored_WarnsOncePerKeyPerWindow, TestTracker_TrackAnchored_WarnNamesTheWindowsFirstNamespace |
| P-8 | Nil contract stated once (t == nil || t.sp == nil first) |
TestTracker_TrackAnchored_Inertness/nil_receiver, /zero-value_tracker |
| P-9 | Skip line is WARN, not ERROR | assertSkipWarn asserts "level":"WARN" on every gate row |
| P-10 | Help text exactly as pinned | TestTracker_DroppedCounter_HelpAndLabels |
| P-11 | Happy path still projects every dimension | TestTracker_TrackAnchored_EmitsAttrsProjection |
| P-12 | Reasons kind_unmapped / format_unmapped declared |
TestLimiter_Allow_EveryCatalogKeyHasItsOwnSlot (as catalog keys). No producer test: their mapping producers land with the seam flip, per the plan's staging argument |
| P-13 | docs/dev/observability.md matches the new shape |
Docs, not tested. The help text and label set are pinned by TestTracker_DroppedCounter_HelpAndLabels |
| P-14 | Slot map is read-only after build | Not directly asserted; no behavioral handle. Covered indirectly by TestLimiter_Allow_ConcurrentWindow_ExactlyOneWinner under -race |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | enabled: false means nothing leaves the process |
TestTracker_TrackAnchored_Inertness/tracker_built_with_usage_data_disabled, TestNew_Disabled_Noop (unchanged) |
| S-2 | No namespace_id metric label |
TestTracker_DroppedCounter_HelpAndLabels (label set is exactly event/reason/source), TestTracker_TrackAnchored_WarnNamesTheWindowsFirstNamespace (the line, not the label, carries it) |
| S-3 | Bounded source label cardinality |
Compile-time via the closed usagedata.Source type; TestTracker_Track_DropsDefaultToTheTrackerSource pins that no series carries an empty value |
| S-4 | Bounded limiter state on the drop path | TestLimiter_Allow_UnknownKeysShareOneTerminalSlot |
| S-5 | Props allow-list still gates before enqueue on the new seam | TestTracker_TrackAnchored_EnqueueDropsCarryTheCallerSource/validation_drop, TestTracker_Track_UnknownPropKey_DropsEvent (unchanged) |
Derived surface not fixed by the plan, for the implementation to keep or
change with the tests: (*limiter).allow(limiterKey, time.Time) takes
the instant as a parameter, which is what makes the window roll-forward
assertable without a real one-minute wait; the sampled line's field
names (source, reason, entity_type, entity_id) mirror the
superseded gate's shape. The line's message wording is deliberately not
asserted.
E2E scenarios
No catalog change: the e2e catalogs assert emitted events; skip logs and counter shape are not part of any scenario.
Related to #377 (closed)