diff --git a/README.md b/README.md index 3ffc713..6839c03 100644 --- a/README.md +++ b/README.md @@ -508,7 +508,7 @@ var NoopMetrics Metrics = &noopMetrics{} func NewPrometheusMetrics(namespace string) Metrics ``` -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. +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\). ## type RunOption diff --git a/metrics.go b/metrics.go index f218d21..a30a86f 100644 --- a/metrics.go +++ b/metrics.go @@ -1,12 +1,18 @@ package workers import ( + "sync" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) +var ( + promMetricsMu sync.RWMutex + promMetricsCache = map[string]*prometheusMetrics{} +) + // Metrics collects worker lifecycle metrics. // Implement this interface to provide custom metrics (e.g., Datadog, StatsD). // Use NoopMetrics to disable metrics, or NewPrometheusMetrics for the built-in @@ -50,9 +56,24 @@ type prometheusMetrics struct { // 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. +// 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). func NewPrometheusMetrics(namespace string) Metrics { - return &prometheusMetrics{ + promMetricsMu.RLock() + if m, ok := promMetricsCache[namespace]; ok { + promMetricsMu.RUnlock() + return m + } + promMetricsMu.RUnlock() + + promMetricsMu.Lock() + defer promMetricsMu.Unlock() + // Double-check after acquiring write lock. + if m, ok := promMetricsCache[namespace]; ok { + return m + } + m := &prometheusMetrics{ started: promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: namespace, Name: "worker_started_total", @@ -90,6 +111,8 @@ func NewPrometheusMetrics(namespace string) Metrics { Help: "Number of currently active workers.", }), } + promMetricsCache[namespace] = m + return m } func (p *prometheusMetrics) WorkerStarted(name string) { diff --git a/metrics_test.go b/metrics_test.go index b24f62f..dbb25c7 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -187,3 +187,31 @@ func TestMetrics_NoopDefault(t *testing.T) { Run(ctx, []*Worker{w}) // no WithMetrics — uses NoopMetrics } + +func TestNewPrometheusMetrics_CachesSameNamespace(t *testing.T) { + m1 := NewPrometheusMetrics("test_cache") + m2 := NewPrometheusMetrics("test_cache") + assert.Same(t, m1, m2, "same namespace should return same instance") +} + +func TestNewPrometheusMetrics_DifferentNamespace(t *testing.T) { + m1 := NewPrometheusMetrics("ns_a") + m2 := NewPrometheusMetrics("ns_b") + assert.NotSame(t, m1, m2, "different namespaces should return different instances") +} + +func TestNewPrometheusMetrics_ConcurrentSafe(t *testing.T) { + var wg sync.WaitGroup + results := make([]Metrics, 100) + for i := range results { + wg.Add(1) + go func(i int) { + defer wg.Done() + results[i] = NewPrometheusMetrics("concurrent_test") + }(i) + } + wg.Wait() + for _, m := range results { + assert.Same(t, results[0], m, "all concurrent calls should return same instance") + } +}