Skip to content

Commit efe3938

Browse files
authored
fix: cache PrometheusMetrics to prevent duplicate registration panic (#1)
* fix: cache PrometheusMetrics to prevent duplicate registration panic NewPrometheusMetrics now caches instances per namespace using sync.RWMutex with double-checked locking. Calling it multiple times with the same namespace returns the cached instance instead of panicking on duplicate Prometheus registration. * fix: add cache tests, document global cache behavior - Add tests: same namespace returns same instance, different namespaces return different instances, concurrent calls are safe - Document that cache is process-global and namespaces should be static
1 parent 9633736 commit efe3938

3 files changed

Lines changed: 54 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ var NoopMetrics Metrics = &noopMetrics{}
508508
func NewPrometheusMetrics(namespace string) Metrics
509509
```
510510

511-
NewPrometheusMetrics creates a Metrics implementation backed by Prometheus. The namespace is prepended to all metric names \(e.g., "myapp" → "myapp\_worker\_started\_total"\). Metrics are auto\-registered with the default Prometheus registry.
511+
NewPrometheusMetrics creates a Metrics implementation backed by Prometheus. The namespace is prepended to all metric names \(e.g., "myapp" → "myapp\_worker\_started\_total"\). Metrics are auto\-registered with the default Prometheus registry. Safe to call multiple times with the same namespace — returns the cached instance. The cache is process\-global; use a small number of static namespaces \(not per\-request/tenant values\).
512512

513513
<a name="RunOption"></a>
514514
## type RunOption

metrics.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
package workers
22

33
import (
4+
"sync"
45
"time"
56

67
"github.com/prometheus/client_golang/prometheus"
78
"github.com/prometheus/client_golang/prometheus/promauto"
89
)
910

11+
var (
12+
promMetricsMu sync.RWMutex
13+
promMetricsCache = map[string]*prometheusMetrics{}
14+
)
15+
1016
// Metrics collects worker lifecycle metrics.
1117
// Implement this interface to provide custom metrics (e.g., Datadog, StatsD).
1218
// Use NoopMetrics to disable metrics, or NewPrometheusMetrics for the built-in
@@ -50,9 +56,24 @@ type prometheusMetrics struct {
5056
// NewPrometheusMetrics creates a Metrics implementation backed by Prometheus.
5157
// The namespace is prepended to all metric names (e.g., "myapp" →
5258
// "myapp_worker_started_total"). Metrics are auto-registered with the
53-
// default Prometheus registry.
59+
// default Prometheus registry. Safe to call multiple times with the same
60+
// namespace — returns the cached instance. The cache is process-global;
61+
// use a small number of static namespaces (not per-request/tenant values).
5462
func NewPrometheusMetrics(namespace string) Metrics {
55-
return &prometheusMetrics{
63+
promMetricsMu.RLock()
64+
if m, ok := promMetricsCache[namespace]; ok {
65+
promMetricsMu.RUnlock()
66+
return m
67+
}
68+
promMetricsMu.RUnlock()
69+
70+
promMetricsMu.Lock()
71+
defer promMetricsMu.Unlock()
72+
// Double-check after acquiring write lock.
73+
if m, ok := promMetricsCache[namespace]; ok {
74+
return m
75+
}
76+
m := &prometheusMetrics{
5677
started: promauto.NewCounterVec(prometheus.CounterOpts{
5778
Namespace: namespace,
5879
Name: "worker_started_total",
@@ -90,6 +111,8 @@ func NewPrometheusMetrics(namespace string) Metrics {
90111
Help: "Number of currently active workers.",
91112
}),
92113
}
114+
promMetricsCache[namespace] = m
115+
return m
93116
}
94117

95118
func (p *prometheusMetrics) WorkerStarted(name string) {

metrics_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,31 @@ func TestMetrics_NoopDefault(t *testing.T) {
187187

188188
Run(ctx, []*Worker{w}) // no WithMetrics — uses NoopMetrics
189189
}
190+
191+
func TestNewPrometheusMetrics_CachesSameNamespace(t *testing.T) {
192+
m1 := NewPrometheusMetrics("test_cache")
193+
m2 := NewPrometheusMetrics("test_cache")
194+
assert.Same(t, m1, m2, "same namespace should return same instance")
195+
}
196+
197+
func TestNewPrometheusMetrics_DifferentNamespace(t *testing.T) {
198+
m1 := NewPrometheusMetrics("ns_a")
199+
m2 := NewPrometheusMetrics("ns_b")
200+
assert.NotSame(t, m1, m2, "different namespaces should return different instances")
201+
}
202+
203+
func TestNewPrometheusMetrics_ConcurrentSafe(t *testing.T) {
204+
var wg sync.WaitGroup
205+
results := make([]Metrics, 100)
206+
for i := range results {
207+
wg.Add(1)
208+
go func(i int) {
209+
defer wg.Done()
210+
results[i] = NewPrometheusMetrics("concurrent_test")
211+
}(i)
212+
}
213+
wg.Wait()
214+
for _, m := range results {
215+
assert.Same(t, results[0], m, "all concurrent calls should return same instance")
216+
}
217+
}

0 commit comments

Comments
 (0)