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 (new source label; new reasons no_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 its namespace_id.
  • usagedata.Source closed enum, with canonical slices beside each enum feeding the limiter catalog and its pinning tests.
  • docs/dev/observability.md documents 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)

Edited by João Pereira

Merge request reports

Loading
Loading