Files
tasks/common/metrics/tally_metrics_handler_test.go
Yaniv KaulandGitHub 625342fb4f perf: cache tally metrics handler scopes and WithTags handlers to reduce allocations (#9620)
## Summary

- Cache `WithTags()` child handlers via `sync.Map` to eliminate repeated
`tagsToMap()`, `scope.Tagged()`, and handler struct allocations on the
hot path
- Cache `scope.Tagged()` results per unique inline tag combination in
`cachedTaggedScope()`, bounded to 1024 entries with graceful degradation
- Normalize excluded tags before cache key computation so
high-cardinality excluded values (e.g. `activityType`) share a single
cache entry, preventing unbounded cache growth

## Design

Two complementary caching layers in `tallyMetricsHandler`:

1. **`childCache`** (`sync.Map`): caches entire handler subtrees
returned by `WithTags()`. On cache hit: zero allocations.
2. **`scopeCache`** (`sync.Map` + `atomic.Int64` size bound): caches
`tally.Scope` objects returned by `scope.Tagged()` for inline tags
passed to `Counter`/`Gauge`/`Timer`/`Histogram` `Record()` calls.
Bounded to 1024 entries; beyond that, scopes are created but not cached.

Both caches use `LoadOrStore` for safe concurrent access. Tag
normalization via `normalizeTagsForCaching()` ensures excluded tag
variants collapse to the same cache key. The normalization has a
zero-alloc fast path when no tags need substitution.

## Allocation Reduction (pprof alloc_space, 5min ScyllaDB workload)

### Commit 1: WithTags handler cache
| Metric | Before | After | Reduction |
|--------|--------|-------|-----------|
| WithTags cumulative | 1,930 MB | 316 MB | -83.6% |
| Total server allocs | 18,030 MB | 16,481 MB | -8.6% |

### Commit 2: Scope cache for inline tags
| Metric | Before | After | Reduction |
|--------|--------|-------|-----------|
| tagsToMap.func1 | 1,101 MB | 0 MB | -100% |
| tally Subscope | 1,012 MB | 0 MB | -100% |
| Total server allocs | 18,465 MB | 16,511 MB | -10.6% |

## Benchmark (omes throughput_stress, mc150, 5 min)

Host networking, i7-1270P 4 cores/component, inter-run data resets:

| Database | Baseline | After commit 1 | After commit 2 |
|----------|----------|----------------|----------------|
| Cassandra | 280 | 294 (+5.0%) | 270 (-3.6%) |
| ScyllaDB | 290 | 296 (+2.1%) | 298 (+2.8%) |

Note: Throughput variance at mc150 is ~5-10%. The allocation reduction
is confirmed by pprof but throughput gains are within noise at this
concurrency level.

## Testing

- Unit tests for all 4 metric types (Counter, Gauge, Timer, Histogram)
with inline tags
- Concurrency tests with race detector (32 goroutines × 100 iterations)
- Cache bound enforcement test
- Exclude-tag normalization tests (merge, allowed values, zero-alloc
fast path)
- Independent per-handler scope cache verification
- All existing tests continue to pass

---

## v2 — addressing review feedback

All 6 review comments addressed. Rebased on `origin/main`, squashed into
a single commit.

### Changes

1. **`tagsCacheKey`: Use `strings.Builder` with `Grow` pre-allocation**
— replaced manual `[]byte` construction with `strings.Builder`. A sizing
pass pre-computes the exact capacity via `Grow()` to avoid internal
reallocation (1 alloc/op).

2. **`tagsCacheKey`: Remove single-tag special case, uniform `\x00`
separator** — removed the `len(tags) == 1` branch. Every tag pair now
unconditionally appends `\x00` after both key and value, making the
format uniform and the code simpler.

3. **`normalizeTagsForCaching`: Use `slices.Clone`** — replaced
`make([]Tag, len(tags))` + `copy(tags[:i])` with `slices.Clone(tags)`,
which copies the entire slice upfront. This eliminates the `if
normalized != nil { normalized[i] = t }` guard for unchanged tags after
the clone point.

4. **Extract shared `normalizeTag` function** — the exclude-tag check
was duplicated between `normalizeTagsForCaching` and the `convert`
closure in `tagsToMap`. Extracted `normalizeTag(tag Tag, excl
excludeTags) (Tag, bool)` used by both, removing the duplication.

5. **Bound `childCache` to `scopeCacheMaxSize`** — `childCache` (used by
`WithTags`) was previously unbounded. Applied the same bounding strategy
as `scopeCache`: atomic counter + stop caching beyond 1024 entries.
Added `childCacheSize atomic.Int64` field and
`TestWithTags_BoundedChildCacheSize` test.

### Micro-benchmark results (cached vs uncached)

```
goos: linux, goarch: amd64, cpu: 12th Gen Intel(R) Core(TM) i7-1270P

                                    ns/op     B/op   allocs/op
CounterRecord_Uncached              344       592    3
CounterRecord_CachedScope            93        48    2           ← 3.7x faster, 12x less memory
WithTags_Uncached                   325       592    3
WithTags_CacheHit                    50        16    1           ← 6.5x faster, 37x less memory
TagsCacheKey_SingleTag               29        24    1
TagsCacheKey_ThreeTags               49        64    1
```

### Not changed (deliberate)

- **Cache key is order-sensitive / does not deduplicate keys** — Tally
internally canonicalizes tag maps (sorted keys, rightmost precedence),
so the cache key could theoretically miss on reordered-but-equivalent
tag sets. Verified across 100+ call sites: tag ordering is fully
consistent and duplicate keys never appear in the codebase. Adding
sort+dedup to the hot path would add cost without real-world benefit.
2026-05-19 13:53:03 -07:00

630 lines
20 KiB
Go

package metrics
import (
"math"
"strconv"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/uber-go/tally/v4"
)
var defaultConfig = ClientConfig{
Tags: nil,
ExcludeTags: map[string][]string{
"taskqueue": {"__sticky__"},
"activityType": {},
"workflowType": {},
},
Prefix: "",
PerUnitHistogramBoundaries: map[string][]float64{
Dimensionless: {0, 10, 100},
Bytes: {1024, 2048},
Milliseconds: {10, 500, 1000, 5000, 10000},
Seconds: {0.01, 0.5, 1, 5, 10},
},
}
func TestTallyScope(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
mp := NewTallyMetricsHandler(defaultConfig, scope)
recordTallyMetrics(mp)
snap := scope.Snapshot()
counters, gauges, timers, histograms := snap.Counters(), snap.Gauges(), snap.Timers(), snap.Histograms()
require.EqualValues(t, 8, counters["test.hits+"].Value())
require.Equal(t, map[string]string{}, counters["test.hits+"].Tags())
require.EqualValues(t, 11, counters["test.hits-tagged+taskqueue=__sticky__"].Value())
require.Equal(t, map[string]string{"taskqueue": "__sticky__"}, counters["test.hits-tagged+taskqueue=__sticky__"].Tags())
require.EqualValues(t, 14, counters["test.hits-tagged-excluded+taskqueue="+tagExcludedValue].Value())
require.Equal(t, map[string]string{"taskqueue": tagExcludedValue}, counters["test.hits-tagged-excluded+taskqueue="+tagExcludedValue].Tags())
require.InDelta(t, float64(-100), gauges["test.temp+location=Mare Imbrium"].Value(), 0.01)
require.Equal(t, map[string]string{
"location": "Mare Imbrium",
}, gauges["test.temp+location=Mare Imbrium"].Tags())
require.Equal(t, []time.Duration{
1248 * time.Millisecond,
5255 * time.Millisecond,
}, timers["test.latency+"].Values())
require.Equal(t, map[string]string{}, timers["test.latency+"].Tags())
require.Equal(t, map[float64]int64{
1024: 0,
2048: 0,
math.MaxFloat64: 1,
}, histograms["test.transmission+"].Values())
require.Equal(t, map[time.Duration]int64(nil), histograms["test.transmission+"].Durations())
require.Equal(t, map[string]string{}, histograms["test.transmission+"].Tags())
newTaggedHandler := mp.WithTags(NamespaceTag(uuid.NewString()))
recordTallyMetrics(newTaggedHandler)
snap = scope.Snapshot()
counters = snap.Counters()
require.EqualValues(t, 11, counters["test.hits-tagged+taskqueue=__sticky__"].Value())
require.Equal(t, map[string]string{"taskqueue": "__sticky__"}, counters["test.hits-tagged+taskqueue=__sticky__"].Tags())
}
func recordTallyMetrics(h Handler) {
hitsCounter := h.Counter("hits")
gauge := h.Gauge("temp")
timer := h.Timer("latency")
histogram := h.Histogram("transmission", Bytes)
hitsTaggedCounter := h.Counter("hits-tagged")
hitsTaggedExcludedCounter := h.Counter("hits-tagged-excluded")
hitsCounter.Record(8)
gauge.Record(-100, StringTag("location", "Mare Imbrium"))
timer.Record(1248 * time.Millisecond)
timer.Record(5255 * time.Millisecond)
histogram.Record(1234567)
hitsTaggedCounter.Record(11, UnsafeTaskQueueTag("__sticky__"))
hitsTaggedExcludedCounter.Record(14, UnsafeTaskQueueTag("filtered"))
}
func TestWithTags_EmptyTagsReturnsSelf(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
got := h.WithTags()
require.Same(t, h, got, "WithTags() with no args should return the same handler")
}
func TestWithTags_CacheHitReturnsSamePointer(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
h1 := h.WithTags(OperationTag("op1"))
h2 := h.WithTags(OperationTag("op1"))
require.Same(t, h1, h2, "repeated WithTags with identical args should return the same handler")
}
func TestWithTags_DifferentTagsReturnDifferentHandlers(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
h1 := h.WithTags(OperationTag("op1"))
h2 := h.WithTags(OperationTag("op2"))
require.NotSame(t, h1, h2, "WithTags with different values must produce different handlers")
// Different keys with same value.
h3 := h.WithTags(StringTag("key_a", "val"))
h4 := h.WithTags(StringTag("key_b", "val"))
require.NotSame(t, h3, h4, "WithTags with different keys must produce different handlers")
}
func TestWithTags_CachedHandlerRecordsMetricsCorrectly(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
tagged := h.WithTags(StringTag("env", "prod"))
// Record via first call.
tagged.Counter("requests").Record(5)
// Record via second (cached) call.
cached := h.WithTags(StringTag("env", "prod"))
cached.Counter("requests").Record(3)
snap := scope.Snapshot()
c := snap.Counters()["test.requests+env=prod"]
require.NotNil(t, c)
require.EqualValues(t, 8, c.Value())
require.Equal(t, map[string]string{"env": "prod"}, c.Tags())
}
func TestWithTags_MultipleTagsCacheCorrectly(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
tags := []Tag{OperationTag("op1"), StringTag("env", "staging")}
h1 := h.WithTags(tags...)
h2 := h.WithTags(tags...)
require.Same(t, h1, h2, "multi-tag WithTags should be cached")
h1.Counter("hits").Record(1)
h2.Counter("hits").Record(2)
snap := scope.Snapshot()
c := snap.Counters()["test.hits+env=staging,operation=op1"]
require.NotNil(t, c)
require.EqualValues(t, 3, c.Value())
}
func TestWithTags_TagOrderMatters(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
// Different ordering of the same two tags should be separate cache
// entries (the tally scope will merge them, but the cache keys differ).
h1 := h.WithTags(StringTag("a", "1"), StringTag("b", "2"))
h2 := h.WithTags(StringTag("b", "2"), StringTag("a", "1"))
// They must both work — record via each.
h1.Counter("c").Record(1)
h2.Counter("c").Record(1)
snap := scope.Snapshot()
c := snap.Counters()["test.c+a=1,b=2"]
require.NotNil(t, c)
require.EqualValues(t, 2, c.Value())
}
func TestWithTags_ChildCachesAreIndependent(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
child := h.WithTags(OperationTag("parent_op"))
grandchild := child.WithTags(StringTag("env", "dev"))
// Grandchild should be cached on child, not on root.
grandchild2 := child.WithTags(StringTag("env", "dev"))
require.Same(t, grandchild, grandchild2)
// Root should not have the grandchild cached.
fromRoot := h.WithTags(StringTag("env", "dev"))
require.NotSame(t, grandchild, fromRoot, "child and root caches should be independent")
}
func TestWithTags_ExcludeTagsStillApply(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
// "activityType" is in excludeTags with empty allow-list, so any value
// should be replaced with tagExcludedValue.
tagged := h.WithTags(ActivityTypeTag("MyActivity"))
tagged.Counter("hits").Record(1)
snap := scope.Snapshot()
c := snap.Counters()["test.hits+activityType="+tagExcludedValue]
require.NotNil(t, c, "excluded tag value should be sanitized")
require.EqualValues(t, 1, c.Value())
}
func TestWithTags_ExcludedTagsShareChildHandler(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
// Different excluded-tag values should produce the same cached child handler,
// preventing unbounded childCache growth from high-cardinality excluded tags.
h1 := h.WithTags(ActivityTypeTag("TypeA"))
h2 := h.WithTags(ActivityTypeTag("TypeB"))
h3 := h.WithTags(ActivityTypeTag("TypeC"))
require.Same(t, h1, h2, "excluded tag variants should share the same child handler")
require.Same(t, h2, h3, "excluded tag variants should share the same child handler")
// Verify the child handler still records metrics correctly.
h1.Counter("hits").Record(1)
h2.Counter("hits").Record(2)
h3.Counter("hits").Record(4)
snap := scope.Snapshot()
c := snap.Counters()["test.hits+activityType="+tagExcludedValue]
require.NotNil(t, c)
require.EqualValues(t, 7, c.Value())
}
func TestWithTags_ConcurrentAccess(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
const goroutines = 32
const iterations = 100
var wg sync.WaitGroup
handlers := make([]Handler, goroutines)
wg.Add(goroutines)
for i := range goroutines {
go func(idx int) {
defer wg.Done()
var last Handler
for range iterations {
last = h.WithTags(OperationTag("concurrent_op"))
last.Counter("concurrent_count").Record(1)
}
handlers[idx] = last
}(i)
}
wg.Wait()
// All goroutines should have received the same cached handler.
for i := 1; i < goroutines; i++ {
require.Same(t, handlers[0], handlers[i],
"all goroutines should get the same cached handler")
}
snap := scope.Snapshot()
c := snap.Counters()["test.concurrent_count+operation=concurrent_op"]
require.NotNil(t, c)
require.Equal(t, int64(goroutines*iterations), c.Value())
}
func TestTagsCacheKey(t *testing.T) {
tests := []struct {
name string
a, b []Tag
same bool
}{
{
name: "identical single tags",
a: []Tag{{Key: "op", Value: "foo"}},
b: []Tag{{Key: "op", Value: "foo"}},
same: true,
},
{
name: "different values",
a: []Tag{{Key: "op", Value: "foo"}},
b: []Tag{{Key: "op", Value: "bar"}},
same: false,
},
{
name: "different keys",
a: []Tag{{Key: "op", Value: "x"}},
b: []Tag{{Key: "ns", Value: "x"}},
same: false,
},
{
name: "identical multi tags",
a: []Tag{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}},
b: []Tag{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}},
same: true,
},
{
name: "different ordering",
a: []Tag{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}},
b: []Tag{{Key: "b", Value: "2"}, {Key: "a", Value: "1"}},
same: false,
},
{
name: "single vs multi",
a: []Tag{{Key: "a", Value: "1"}},
b: []Tag{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}},
same: false,
},
{
name: "key boundary ambiguity",
a: []Tag{{Key: "ab", Value: "c"}},
b: []Tag{{Key: "a", Value: "bc"}},
same: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ka := tagsCacheKey(tt.a)
kb := tagsCacheKey(tt.b)
if tt.same {
require.Equal(t, ka, kb)
} else {
require.NotEqual(t, ka, kb)
}
})
}
}
func TestScopeCache_CounterWithInlineTags(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
c := h.Counter("requests")
c.Record(1, StringTag("status", "ok"))
c.Record(2, StringTag("status", "ok"))
c.Record(5, StringTag("status", "err"))
snap := scope.Snapshot()
ok := snap.Counters()["test.requests+status=ok"]
require.NotNil(t, ok)
require.EqualValues(t, 3, ok.Value())
errC := snap.Counters()["test.requests+status=err"]
require.NotNil(t, errC)
require.EqualValues(t, 5, errC.Value())
}
func TestScopeCache_GaugeWithInlineTags(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
g := h.Gauge("temp")
g.Record(42.0, StringTag("location", "cpu"))
g.Record(99.0, StringTag("location", "cpu"))
snap := scope.Snapshot()
gauge := snap.Gauges()["test.temp+location=cpu"]
require.NotNil(t, gauge)
require.InDelta(t, 99.0, gauge.Value(), 0.01)
}
func TestScopeCache_TimerWithInlineTags(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
ti := h.Timer("latency")
ti.Record(100*time.Millisecond, StringTag("op", "read"))
ti.Record(200*time.Millisecond, StringTag("op", "read"))
snap := scope.Snapshot()
timer := snap.Timers()["test.latency+op=read"]
require.NotNil(t, timer)
require.Equal(t, []time.Duration{100 * time.Millisecond, 200 * time.Millisecond}, timer.Values())
}
func TestScopeCache_HistogramWithInlineTags(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
hist := h.Histogram("size", Bytes)
hist.Record(512, StringTag("type", "payload"))
hist.Record(4096, StringTag("type", "payload"))
snap := scope.Snapshot()
histo := snap.Histograms()["test.size+type=payload"]
require.NotNil(t, histo)
require.Equal(t, map[float64]int64{
1024: 1,
2048: 0,
math.MaxFloat64: 1,
}, histo.Values())
}
func TestScopeCache_NoTagsUsesBaseScope(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
h.Counter("hits").Record(7)
snap := scope.Snapshot()
c := snap.Counters()["test.hits+"]
require.NotNil(t, c)
require.EqualValues(t, 7, c.Value())
}
func TestScopeCache_ExcludeTagsApply(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
h.Counter("hits").Record(1, ActivityTypeTag("MyActivity"))
snap := scope.Snapshot()
c := snap.Counters()["test.hits+activityType="+tagExcludedValue]
require.NotNil(t, c, "excluded tag value should be sanitized via scope cache")
require.EqualValues(t, 1, c.Value())
}
func TestScopeCache_IndependentPerHandler(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
child1 := h.WithTags(OperationTag("op1"))
child2 := h.WithTags(OperationTag("op2"))
child1.Counter("hits").Record(1, StringTag("env", "prod"))
child2.Counter("hits").Record(2, StringTag("env", "prod"))
snap := scope.Snapshot()
c1 := snap.Counters()["test.hits+env=prod,operation=op1"]
require.NotNil(t, c1)
require.EqualValues(t, 1, c1.Value())
c2 := snap.Counters()["test.hits+env=prod,operation=op2"]
require.NotNil(t, c2)
require.EqualValues(t, 2, c2.Value())
}
func TestScopeCache_ConcurrentRecordWithTags(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
const goroutines = 32
const iterations = 100
var wg sync.WaitGroup
wg.Add(goroutines)
for range goroutines {
go func() {
defer wg.Done()
c := h.Counter("concurrent")
for range iterations {
c.Record(1, StringTag("shard", "0"))
}
}()
}
wg.Wait()
snap := scope.Snapshot()
c := snap.Counters()["test.concurrent+shard=0"]
require.NotNil(t, c)
require.Equal(t, int64(goroutines*iterations), c.Value())
}
func TestScopeCache_ExcludedTagsMergeInCache(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
// "activityType" has empty allow-list, so all values are excluded.
// Different raw values should normalize to the same cache entry.
h.Counter("hits").Record(1, ActivityTypeTag("TypeA"))
h.Counter("hits").Record(2, ActivityTypeTag("TypeB"))
h.Counter("hits").Record(4, ActivityTypeTag("TypeC"))
snap := scope.Snapshot()
c := snap.Counters()["test.hits+activityType="+tagExcludedValue]
require.NotNil(t, c)
require.EqualValues(t, 7, c.Value(), "all excluded tag values should map to the same counter")
// Verify only one cache entry was created, not three.
h.cache.mu.Lock()
scopeCount := len(h.cache.scopes)
h.cache.mu.Unlock()
require.Equal(t, 1, scopeCount,
"excluded tags with different raw values should share a single cache entry")
}
func TestScopeCache_BoundedSize(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
cfg := defaultConfig
cfg.TagsCacheMaxSize = 100
h := NewTallyMetricsHandler(cfg, scope)
// Fill the cache to the limit.
for i := range 100 {
h.Counter("c").Record(1, StringTag("id", strconv.Itoa(i)))
}
h.cache.mu.Lock()
require.Len(t, h.cache.scopes, 100)
h.cache.mu.Unlock()
// Beyond the limit, cache is cleared and new entry is stored.
h.Counter("c").Record(1, StringTag("id", "overflow"))
h.cache.mu.Lock()
require.LessOrEqual(t, len(h.cache.scopes), 2,
"scope cache should have been cleared on overflow")
h.cache.mu.Unlock()
snap := scope.Snapshot()
c := snap.Counters()["test.c+id=overflow"]
require.NotNil(t, c, "metrics should work even after cache clear")
require.EqualValues(t, 1, c.Value())
// Entries are re-cached after clear.
h.Counter("c").Record(1, StringTag("id", "0"))
snap = scope.Snapshot()
c0 := snap.Counters()["test.c+id=0"]
require.NotNil(t, c0)
require.EqualValues(t, 2, c0.Value())
}
func TestWithTags_BoundedChildCacheSize(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
cfg := defaultConfig
cfg.TagsCacheMaxSize = 100
h := NewTallyMetricsHandler(cfg, scope)
// Fill the handler cache to the limit.
for i := range 100 {
h.WithTags(StringTag("id", strconv.Itoa(i)))
}
h.cache.mu.Lock()
require.Len(t, h.cache.handlers, 100)
h.cache.mu.Unlock()
// Beyond the limit, WithTags still works; cache is cleared.
overflow := h.WithTags(StringTag("id", "overflow"))
h.cache.mu.Lock()
require.LessOrEqual(t, len(h.cache.handlers), 2,
"handler cache should have been cleared on overflow")
h.cache.mu.Unlock()
// The handler still works correctly.
overflow.Counter("hits").Record(1)
snap := scope.Snapshot()
c := snap.Counters()["test.hits+id=overflow"]
require.NotNil(t, c)
require.EqualValues(t, 1, c.Value())
// Cached entries are re-cached on next access.
cached := h.WithTags(StringTag("id", "0"))
cached2 := h.WithTags(StringTag("id", "0"))
require.Same(t, cached, cached2, "re-cached entries should hit")
}
func TestNormalizeTagsForCaching(t *testing.T) {
excl := excludeTags{
"activityType": {}, // empty allow-list: exclude all
"taskqueue": {"__sticky__": struct{}{}}, // allow only __sticky__
}
t.Run("no excluded tags returns original slice", func(t *testing.T) {
tags := []Tag{{Key: "env", Value: "prod"}}
result := normalizeTagsForCaching(tags, excl)
require.Same(t, &tags[0], &result[0], "should return the same slice when no normalization needed")
})
t.Run("excluded tag gets normalized", func(t *testing.T) {
tags := []Tag{{Key: "activityType", Value: "MyActivity"}}
result := normalizeTagsForCaching(tags, excl)
require.Equal(t, tagExcludedValue, result[0].Value)
})
t.Run("allowed tag value is not normalized", func(t *testing.T) {
tags := []Tag{{Key: "taskqueue", Value: "__sticky__"}}
result := normalizeTagsForCaching(tags, excl)
require.Same(t, &tags[0], &result[0], "allowed value should not trigger normalization")
})
t.Run("mixed tags normalize only excluded ones", func(t *testing.T) {
tags := []Tag{
{Key: "env", Value: "prod"},
{Key: "activityType", Value: "DoSomething"},
{Key: "taskqueue", Value: "non-sticky"},
}
result := normalizeTagsForCaching(tags, excl)
require.Equal(t, "prod", result[0].Value)
require.Equal(t, tagExcludedValue, result[1].Value)
require.Equal(t, tagExcludedValue, result[2].Value)
})
t.Run("empty excludeTags returns original", func(t *testing.T) {
tags := []Tag{{Key: "anything", Value: "val"}}
result := normalizeTagsForCaching(tags, nil)
require.Same(t, &tags[0], &result[0])
})
}
func TestTagsCacheKey_NoCollisionsForEmbeddedNulls(t *testing.T) {
tagsA := []Tag{{Key: "a", Value: "\x00b"}}
tagsB := []Tag{{Key: "a\x00", Value: "b"}}
require.NotEqual(t, tagsCacheKey(tagsA), tagsCacheKey(tagsB))
}
func TestWithTags_DistinguishesEmbeddedNullTags(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
h1 := h.WithTags(StringTag("a", "\x00b"))
h2 := h.WithTags(StringTag("a\x00", "b"))
require.NotSame(t, h1, h2)
}
func TestHistogram_CacheKeyDistinguishesNameAndUnit(t *testing.T) {
scope := tally.NewTestScope("test", map[string]string{})
h := NewTallyMetricsHandler(defaultConfig, scope)
h.Histogram("ab", MetricUnit("c"))
h.Histogram("ab\x00c", MetricUnit(""))
count := 0
h.histograms.Range(func(_, _ any) bool {
count++
return true
})
require.Equal(t, 2, count)
}