Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions label_namer.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"

Check failure on line 27 in label_namer.go

View workflow job for this annotation

GitHub Actions / lint

import 'sync/atomic' is not allowed from list 'main': Use go.uber.org/atomic instead of sync/atomic (depguard)
"time"
"unicode"
)

Expand All @@ -46,6 +49,13 @@
// specification https://github.com/open-telemetry/opentelemetry-specification/blob/v1.38.0/specification/compatibility/prometheus_and_openmetrics.md#otlp-metric-points-to-prometheus),
// but may be needed for compatibility with legacy systems that rely on the old behavior.
PreserveMultipleUnderscores bool
// CacheDisabled specifies whether to disable the transformation cache.
// Defaults to false (cache enabled). Set to true to disable caching.
CacheDisabled bool
// once ensures thread-safe lazy initialization of cache.
once sync.Once
// cache is lazily initialized when CacheDisabled is false.
cache *StringCache
}

// Build normalizes the specified label to follow Prometheus label names standard.
Expand Down Expand Up @@ -74,6 +84,61 @@
return label, nil
}

// Lazy init cache if enabled (thread-safe via sync.Once)
if !ln.CacheDisabled {
ln.once.Do(func() {
ln.cache = NewStringCache()
})
}

// Try cache first
if ln.cache != nil {
if v, ok := ln.cache.m.Load(label); ok {
e := v.(*cacheEntry)
ct := uint64(time.Now().Unix())
if e.lastAccessTime.Load()+10 < ct {
e.lastAccessTime.Store(ct)
}
return e.value, nil
}
}

// Cache miss - transform
result, err := ln.buildWithoutCache(label)
if err != nil {
return result, err
}

// Store in cache with memory safety
if ln.cache != nil {
label = strings.Clone(label)
if result == label {
result = label
}
e := &cacheEntry{
value: result,
}
e.lastAccessTime.Store(uint64(time.Now().Unix()))
ln.cache.m.Store(label, e)

// Lazy cleanup
ct := uint64(time.Now().Unix())
if needCleanup(&ln.cache.lastCleanupTime, ct) {
deadline := ct - uint64(ln.cache.expireDuration.Seconds())
ln.cache.m.Range(func(k, v any) bool {
e := v.(*cacheEntry)
if e.lastAccessTime.Load() < deadline {
ln.cache.m.Delete(k)
}
return true
})
}
}

return result, nil
}

func (ln *LabelNamer) buildWithoutCache(label string) (string, error) {
normalizedName := sanitizeLabelName(label, ln.PreserveMultipleUnderscores)

// If label starts with a number, prepend with "key_".
Expand All @@ -98,3 +163,33 @@
}
return true
}

// StringCache caches string transformations.
// It is safe for concurrent use.
type StringCache struct {
m sync.Map
lastCleanupTime atomic.Uint64
expireDuration time.Duration
}

type cacheEntry struct {
lastAccessTime atomic.Uint64
value string
}

// NewStringCache creates a new StringCache with default expiry duration.
func NewStringCache() *StringCache {
return &StringCache{
expireDuration: 6 * time.Minute,
}
}

// needCleanup returns true if cleanup should be performed.
// It is called lazily on Transform to avoid background goroutines.
func needCleanup(lastCleanupTime *atomic.Uint64, currentTime uint64) bool {
lct := lastCleanupTime.Load()
if lct+61 >= currentTime {
return false
}
return lastCleanupTime.CompareAndSwap(lct, currentTime)
}
19 changes: 19 additions & 0 deletions label_namer_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,22 @@ func BenchmarkNormalizeLabel(b *testing.B) {
})
}
}

func BenchmarkNormalizeLabelWithCache(b *testing.B) {
labelNamer := LabelNamer{CacheDisabled: false}
// Pre-populate cache
for _, input := range labelBenchmarkInputs {
//nolint:errcheck
labelNamer.Build(input.label)
}

b.ResetTimer()
for _, input := range labelBenchmarkInputs {
b.Run(input.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
//nolint:errcheck
labelNamer.Build(input.label)
}
})
}
}
77 changes: 77 additions & 0 deletions label_namer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,80 @@ func TestBuildLabel_UTF8Allowed(t *testing.T) {
})
}
}

func TestLabelNamerCacheHit(t *testing.T) {
namer := &LabelNamer{CacheDisabled: false}

result1, err := namer.Build("http.method")
if err != nil {
t.Fatal(err)
}
if result1 != "http_method" {
t.Errorf("expected http_method, got %s", result1)
}

// Same label should hit cache
result2, err := namer.Build("http.method")
if err != nil {
t.Fatal(err)
}
if result2 != "http_method" {
t.Errorf("expected http_method, got %s", result2)
}
}

func TestLabelNamerCacheDisabled(t *testing.T) {
namer := &LabelNamer{CacheDisabled: true}

result, err := namer.Build("http.method")
if err != nil {
t.Fatal(err)
}
if result != "http_method" {
t.Errorf("expected http_method, got %s", result)
}

result2, err := namer.Build("http.method")
if err != nil {
t.Fatal(err)
}
if result2 != "http_method" {
t.Errorf("expected http_method, got %s", result2)
}
}

func TestLabelNamerCacheMemorySafety(t *testing.T) {
// Create a label that doesn't need transformation
label := "already_valid_label"
namer := &LabelNamer{CacheDisabled: false}

result, err := namer.Build(label)
if err != nil {
t.Fatal(err)
}
if result != label {
t.Errorf("expected %s, got %s", label, result)
}
}

func TestLabelNamerCacheEnabledDefault(t *testing.T) {
// Default LabelNamer{} should have cache enabled
namer := &LabelNamer{}

result1, err := namer.Build("http.method")
if err != nil {
t.Fatal(err)
}
if result1 != "http_method" {
t.Errorf("expected http_method, got %s", result1)
}

// Second call should hit cache
result2, err := namer.Build("http.method")
if err != nil {
t.Fatal(err)
}
if result2 != "http_method" {
t.Errorf("expected http_method, got %s", result2)
}
}
Loading