Skip to content

Cap the purpose meter tag to prevent unbounded cardinality - #113

Merged
vanrogu merged 1 commit into
developfrom
claude/gauge-cardinality-issue-07ywx4
Aug 3, 2026
Merged

Cap the purpose meter tag to prevent unbounded cardinality#113
vanrogu merged 1 commit into
developfrom
claude/gauge-cardinality-issue-07ywx4

Conversation

@vanrogu

@vanrogu vanrogu commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

Adds a configurable cap on the number of distinct purpose tag values that event store meters can report, preventing unbounded metric cardinality growth when purpose is used as an entity identifier (e.g., customer ID).

Problem

Every meter the store registers is tagged with context and purpose. When purpose is used as documented in examples — forContext("customer").withPurpose("123") — it takes one value per entity. Since Micrometer never evicts meters, the cost grows with every distinct purpose the process has ever seen:

  • ~5.5 KB of heap per distinct purpose
  • 15 meters per purpose (plus 2 more per additional event type)
  • 18 Prometheus series and ~2.4 KB of scrape body per purpose

At 100,000 customers this extrapolates to ~550 MB of heap, 1.8M series, and a 234 MB scrape — nothing fails, but the process gets heavier for as long as it runs.

Solution

Introduces MeterOptions to cap how many distinct purposes get their own meter tag value before the rest are pooled under _other:

  • Default cap: 1000 purposes per store (configurable via MeterOptions)
  • Admission is first-come-first-served and permanent — a purpose that gets a tag value keeps it for the store's lifetime, so dashboards built on those series remain stable
  • Purposes beyond the cap are pooled under _other with a one-time warning log
  • Events are still counted correctly — pooled purposes contribute to the _other series
  • The cap is applied at one point (purposeTagValueFor) so it bounds everything downstream: per-stream meters, the eventtype cross product, and the store's internal gauge state map

Key Changes

  • New MeterOptions class (sliceworkz-eventstore-api):

    • withMaxPurposeTagValues(int) — cap at a specific number
    • withoutPurposeBreakdown() — pool every purpose (cap = 0)
    • withUnlimitedPurposeTagValues() — no cap (for low-cardinality purposes by construction)
    • defaults() — returns cap of 1000
    • Validates that cap is non-negative
  • Updated EventStoreImpl (sliceworkz-eventstore-impl):

    • New three-argument constructor accepting MeterOptions
    • Two-argument constructor delegates to three-argument with MeterOptions.defaults()
    • purposeTagValueFor(String) method implements the capping logic with CAS-based admission
    • Tracks admitted purposes in a ConcurrentHashMap and count in AtomicInteger
    • One-time warning logged when cap is first exceeded
  • Updated EventStoreFactory (sliceworkz-eventstore-api):

    • New three-argument eventStore() method accepting MeterOptions
    • Default implementation delegates to two-argument for backward compatibility
  • Updated storage builders (in-memory, PostgreSQL):

    • Added meterOptions() builder method
    • Passed through to buildStore() which creates the EventStoreImpl
  • Comprehensive test suite (MeterPurposeCardinalityTest):

    • Verifies purposes below cap keep their own tag value
    • Verifies purposes beyond cap are pooled under _other
    • Confirms pooled purposes still count their events
    • Confirms admitted purposes are not demoted after cap is reached
    • Verifies internal gauge state map is also bounded
    • Tests withoutPurposeBreakdown() behavior
    • Tests default behavior for unconfigured stores
    • Tests concurrent first-use of distinct purposes holds the cap exactly
    • Tests wildcard and default purposes are treated as ordinary values
  • Documentation (CLAUDE.md):

    • Added "Metrics: what the stream meters cost, and the cap on purpose" section
    • Explains the cost per distinct purpose, why the cap exists, and how to configure it

Implementation Details

https://claude.ai/code/session_019wmQNmjGJvQXnoFFPDReQk

Every meter the store registers is tagged with the stream's context and
purpose. Purpose is documented as an entity id -- "e.g. customer ID, order
number" -- and half the examples in the repository are
forContext("customer").withPurpose("123"). A Micrometer registry never
evicts a meter, so used that way the meters grow with every entity the
process has ever seen and nothing reclaims them; dropping the stream handle,
which is the per-operation usage the docs recommend, releases nothing.

Measured per distinct purpose on an in-memory store with two event types:
15 meters (+2 per further event type), ~5.5KB of heap, 18 Prometheus series
and ~2.4KB of scrape body. At 10.000 purposes that is 150.000 meters, 53MB
and a 23MB scrape; 100.000 extrapolates to ~550MB and 1.8M series. Nothing
fails, which is why it went unnoticed -- the numbers stay correct and the
process just gets heavier for as long as it runs.

A store now tags the first MeterOptions.maxPurposeTagValues() distinct
purposes it sees (default 1000) and reports the rest as "_other", logging one
WARN naming the purpose that tripped it. Below the cap nothing changes, which
is the case where the breakdown is worth having; above it the meters stay
flat and the events are still counted, pooled. Re-measured at 10.000
purposes: 15.015 meters instead of 150.000, and a 2.3MB scrape instead of
23MB.

Admission is first-come-first-served and permanent, so a series a dashboard
is built on does not disappear when traffic widens. Rejected purposes are
deliberately not remembered -- memoising them would cost exactly the
cardinality being avoided -- and the slot is claimed with a CAS rather than a
size() check, which under concurrent first use of distinct purposes would
overshoot.

The cap is applied where the tag value is chosen, so it bounds the per-stream
meters, the eventtype cross product on query.event/append.event, and the
store's map of append.position gauge state in one place. That last one is why
this belongs in the library rather than in a MeterFilter the caller writes: a
filter runs at registration and the map is keyed on the tags the store asked
for, so with MeterFilter.denyNameStartsWith("sliceworkz") a registry holding
zero meters still left the store growing by ~730 bytes per purpose.

No API change for existing callers. The two-argument factory methods and
constructors apply MeterOptions.defaults(), and the new three-argument
factory method is a default method delegating to the old one, so a factory
implementation written before this still compiles. MeterOptions is also
reachable from the three storage builders' buildStore() via .meterOptions().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wmQNmjGJvQXnoFFPDReQk
@vanrogu
vanrogu merged commit a294fbd into develop Aug 3, 2026
1 check passed
@vanrogu
vanrogu deleted the claude/gauge-cardinality-issue-07ywx4 branch August 3, 2026 07:16
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.

2 participants