Skip to content

Commit 79722e0

Browse files
asimclaude
andauthored
feat: add prometheus monitoring wrapper (#2894)
Reintroduces the Prometheus metrics wrapper previously available in the plugins repository, updated for go-micro v5. Exposes request count and latency histograms for handlers, subscribers, and outgoing client calls via NewHandlerWrapper, NewSubscriberWrapper, NewCallWrapper and NewClientWrapper, labelled with service/endpoint/status. Options cover namespace, subsystem, const labels, histogram buckets and a custom registerer; duplicate collectors (e.g. from multiple wrappers sharing the same config) are reused transparently via a cached metrics bundle. Fixes #2893 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49070af commit 79722e0

7 files changed

Lines changed: 635 additions & 0 deletions

File tree

go.mod

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ require (
5353
require (
5454
filippo.io/edwards25519 v1.1.0 // indirect
5555
github.com/armon/go-metrics v0.4.1 // indirect
56+
github.com/beorn7/perks v1.0.1 // indirect
5657
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
5758
github.com/cespare/xxhash/v2 v2.3.0 // indirect
5859
github.com/coreos/go-semver v0.3.0 // indirect
@@ -89,10 +90,15 @@ require (
8990
github.com/minio/highwayhash v1.0.3 // indirect
9091
github.com/mitchellh/go-homedir v1.1.0 // indirect
9192
github.com/mitchellh/mapstructure v1.5.0 // indirect
93+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
9294
github.com/nats-io/jwt/v2 v2.7.4 // indirect
9395
github.com/nats-io/nkeys v0.4.11 // indirect
9496
github.com/nats-io/nuid v1.0.1 // indirect
9597
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
98+
github.com/prometheus/client_golang v1.21.1 // indirect
99+
github.com/prometheus/client_model v0.6.1 // indirect
100+
github.com/prometheus/common v0.63.0 // indirect
101+
github.com/prometheus/procfs v0.16.0 // indirect
96102
github.com/rogpeppe/go-internal v1.13.1 // indirect
97103
github.com/russross/blackfriday/v2 v2.1.0 // indirect
98104
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect

go.sum

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj
1919
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
2020
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
2121
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
22+
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2223
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
2324
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
2425
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
@@ -261,6 +262,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
261262
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
262263
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
263264
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
265+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
266+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
264267
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
265268
github.com/nats-io/jwt/v2 v2.7.4 h1:jXFuDDxs/GQjGDZGhNgH4tXzSUK6WQi2rsj4xmsNOtI=
266269
github.com/nats-io/jwt/v2 v2.7.4/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA=
@@ -298,14 +301,22 @@ github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSg
298301
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
299302
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
300303
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
304+
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
305+
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
301306
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
302307
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
303308
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
309+
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
310+
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
304311
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
305312
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
313+
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
314+
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
306315
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
307316
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
308317
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
318+
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
319+
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
309320
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
310321
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
311322
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Prometheus Wrapper
2+
3+
The `prometheus` wrapper package exposes standard request metrics (request
4+
count, latency, errors) for go-micro services and clients, so they can be
5+
scraped by a Prometheus server with zero extra boilerplate.
6+
7+
Resolves [micro/go-micro#2893](https://github.com/micro/go-micro/issues/2893).
8+
9+
## Installation
10+
11+
```go
12+
import prom "go-micro.dev/v5/wrapper/monitoring/prometheus"
13+
```
14+
15+
## Exported Metrics
16+
17+
All metrics are labelled with `service`, `endpoint` and `status`
18+
(`"success"` or `"fail"`). Labels are kept small on purpose to avoid
19+
blowing up Prometheus memory.
20+
21+
| Metric | Type | Description |
22+
|---------------------------------|-----------|---------------------------------------------|
23+
| `micro_request_total` | Counter | Total number of requests handled. |
24+
| `micro_request_duration_seconds`| Histogram | Request latency distribution (seconds). |
25+
26+
The `micro` prefix can be overridden with `prom.ServiceName("myapp")`.
27+
28+
## Basic Usage
29+
30+
```go
31+
import (
32+
"go-micro.dev/v5"
33+
prom "go-micro.dev/v5/wrapper/monitoring/prometheus"
34+
)
35+
36+
func main() {
37+
service := micro.NewService(
38+
micro.Name("example.service"),
39+
micro.WrapHandler(prom.NewHandlerWrapper()),
40+
micro.WrapClient(prom.NewClientWrapper()),
41+
micro.WrapSubscriber(prom.NewSubscriberWrapper()),
42+
)
43+
44+
service.Init()
45+
46+
if err := service.Run(); err != nil {
47+
panic(err)
48+
}
49+
}
50+
```
51+
52+
To expose the metrics to Prometheus, serve the default `promhttp` handler
53+
on a side HTTP endpoint:
54+
55+
```go
56+
import (
57+
"net/http"
58+
59+
"github.com/prometheus/client_golang/prometheus/promhttp"
60+
)
61+
62+
go func() {
63+
http.Handle("/metrics", promhttp.Handler())
64+
_ = http.ListenAndServe(":9100", nil)
65+
}()
66+
```
67+
68+
Then point Prometheus at it:
69+
70+
```yaml
71+
scrape_configs:
72+
- job_name: 'example.service'
73+
static_configs:
74+
- targets: ['localhost:9100']
75+
```
76+
77+
## Wrappers
78+
79+
| Constructor | Wraps | Notes |
80+
|---------------------------|-------------------------|--------------------------------------------|
81+
| `NewHandlerWrapper` | `server.HandlerWrapper` | Incoming RPC handlers. |
82+
| `NewSubscriberWrapper` | `server.SubscriberWrapper` | Event subscribers (uses topic as endpoint). |
83+
| `NewCallWrapper` | `client.CallWrapper` | Outgoing unary RPC calls only. |
84+
| `NewClientWrapper` | `client.Wrapper` | Outgoing `Call` **and** `Publish`. |
85+
86+
`NewClientWrapper` is the right choice when you want metrics for both
87+
`Call` and `Publish`; use `NewCallWrapper` if you only care about unary
88+
calls and want lower overhead.
89+
90+
## Configuration
91+
92+
All constructors accept functional options:
93+
94+
```go
95+
prom.NewHandlerWrapper(
96+
prom.ServiceName("myapp"), // metric name prefix
97+
prom.Namespace("prod"), // Prometheus namespace
98+
prom.Subsystem("api"), // Prometheus subsystem
99+
prom.ConstLabels(prometheus.Labels{"dc": "eu-1"}), // labels on every metric
100+
prom.Buckets([]float64{0.005, 0.05, 0.5, 1, 5}), // latency buckets
101+
prom.Registerer(myRegistry), // custom registerer
102+
)
103+
```
104+
105+
Defaults:
106+
107+
- `ServiceName`: `"micro"`
108+
- `Buckets`: `prometheus.DefBuckets`
109+
- `Registerer`: `prometheus.DefaultRegisterer`
110+
111+
## Reusing Collectors
112+
113+
Creating multiple wrappers with the same options (e.g. `NewHandlerWrapper`
114+
and `NewClientWrapper` together) is safe: the collectors are cached per
115+
`(name, namespace, subsystem)` triple and `AlreadyRegisteredError` from
116+
Prometheus is handled transparently, so the existing collector is reused.
117+
118+
## Testing
119+
120+
The package ships with unit tests that use a fresh `prometheus.Registry`
121+
per test to keep assertions isolated:
122+
123+
```bash
124+
go test ./wrapper/monitoring/prometheus/...
125+
```
126+
127+
## License
128+
129+
Apache 2.0
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package prometheus
2+
3+
import (
4+
"sync"
5+
6+
"github.com/prometheus/client_golang/prometheus"
7+
)
8+
9+
// metrics bundles the counters/histograms used by the wrappers.
10+
// A metrics value is keyed by (name + namespace + subsystem) so that
11+
// multiple wrappers created with the same options share the same
12+
// collectors instead of failing with AlreadyRegisteredError.
13+
type metrics struct {
14+
requestTotal *prometheus.CounterVec
15+
requestDuration *prometheus.HistogramVec
16+
}
17+
18+
// metricLabels are the labels we use on every metric. They intentionally
19+
// stay on a small, low-cardinality set: high-cardinality labels (e.g. the
20+
// full request body) must not end up in Prometheus.
21+
var metricLabels = []string{"service", "endpoint", "status"}
22+
23+
var (
24+
metricsMu sync.Mutex
25+
metricsCache = map[string]*metrics{}
26+
)
27+
28+
// getMetrics returns a cached metrics bundle for the given options, creating
29+
// and registering it on first use. Collectors that were already registered
30+
// on the underlying Registerer (e.g. because a user constructed two wrappers
31+
// with identical options) are reused transparently.
32+
func getMetrics(opts Options) *metrics {
33+
key := opts.Name + "\x00" + opts.Namespace + "\x00" + opts.Subsystem
34+
35+
metricsMu.Lock()
36+
defer metricsMu.Unlock()
37+
38+
if m, ok := metricsCache[key]; ok {
39+
return m
40+
}
41+
42+
counter := prometheus.NewCounterVec(
43+
prometheus.CounterOpts{
44+
Namespace: opts.Namespace,
45+
Subsystem: opts.Subsystem,
46+
Name: opts.Name + "_request_total",
47+
Help: "How many go-micro requests processed, partitioned by service, endpoint and status.",
48+
ConstLabels: opts.ConstLabels,
49+
},
50+
metricLabels,
51+
)
52+
53+
histogram := prometheus.NewHistogramVec(
54+
prometheus.HistogramOpts{
55+
Namespace: opts.Namespace,
56+
Subsystem: opts.Subsystem,
57+
Name: opts.Name + "_request_duration_seconds",
58+
Help: "Histogram of go-micro request latencies in seconds, partitioned by service, endpoint and status.",
59+
ConstLabels: opts.ConstLabels,
60+
Buckets: opts.Buckets,
61+
},
62+
metricLabels,
63+
)
64+
65+
m := &metrics{
66+
requestTotal: register(opts.Registerer, counter).(*prometheus.CounterVec),
67+
requestDuration: register(opts.Registerer, histogram).(*prometheus.HistogramVec),
68+
}
69+
metricsCache[key] = m
70+
return m
71+
}
72+
73+
// register registers c on r. If an identical collector is already registered
74+
// (AlreadyRegisteredError), the existing collector is returned so that the
75+
// wrapper can be constructed more than once without panicking.
76+
func register(r prometheus.Registerer, c prometheus.Collector) prometheus.Collector {
77+
if err := r.Register(c); err != nil {
78+
if are, ok := err.(prometheus.AlreadyRegisteredError); ok {
79+
return are.ExistingCollector
80+
}
81+
// Any other registration error is a programming mistake (e.g.
82+
// inconsistent label dimensions) and should surface loudly.
83+
panic(err)
84+
}
85+
return c
86+
}
87+
88+
// status returns "success" or "fail" depending on whether err is nil.
89+
// Using a fixed, low-cardinality set keeps Prometheus memory bounded.
90+
func status(err error) string {
91+
if err != nil {
92+
return "fail"
93+
}
94+
return "success"
95+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Package prometheus provides a go-micro wrapper that exposes standard
2+
// request/response metrics (request count, latency, errors) to Prometheus.
3+
package prometheus
4+
5+
import (
6+
"github.com/prometheus/client_golang/prometheus"
7+
)
8+
9+
// Options holds configuration for the Prometheus wrapper.
10+
type Options struct {
11+
// Name is the metric name prefix (Prometheus "name").
12+
// Default: "micro".
13+
Name string
14+
15+
// Namespace for the Prometheus metrics.
16+
// Default: "" (empty).
17+
Namespace string
18+
19+
// Subsystem for the Prometheus metrics.
20+
// Default: "" (empty).
21+
Subsystem string
22+
23+
// ConstLabels are labels applied to every metric.
24+
ConstLabels prometheus.Labels
25+
26+
// Buckets defines the histogram buckets (seconds) for latency.
27+
// When nil, prometheus.DefBuckets is used.
28+
Buckets []float64
29+
30+
// Registerer is used to register the metrics.
31+
// When nil, prometheus.DefaultRegisterer is used.
32+
Registerer prometheus.Registerer
33+
}
34+
35+
// Option applies a single configuration value.
36+
type Option func(*Options)
37+
38+
// ServiceName sets the metric name prefix (Prometheus "name").
39+
func ServiceName(name string) Option {
40+
return func(o *Options) {
41+
o.Name = name
42+
}
43+
}
44+
45+
// Namespace sets the Prometheus namespace for metrics.
46+
func Namespace(namespace string) Option {
47+
return func(o *Options) {
48+
o.Namespace = namespace
49+
}
50+
}
51+
52+
// Subsystem sets the Prometheus subsystem for metrics.
53+
func Subsystem(subsystem string) Option {
54+
return func(o *Options) {
55+
o.Subsystem = subsystem
56+
}
57+
}
58+
59+
// ConstLabels sets labels applied to every metric.
60+
func ConstLabels(labels prometheus.Labels) Option {
61+
return func(o *Options) {
62+
o.ConstLabels = labels
63+
}
64+
}
65+
66+
// Buckets sets the histogram buckets (in seconds) for latency metrics.
67+
func Buckets(buckets []float64) Option {
68+
return func(o *Options) {
69+
o.Buckets = buckets
70+
}
71+
}
72+
73+
// Registerer sets the Prometheus registerer used to register metrics.
74+
// When unset, prometheus.DefaultRegisterer is used.
75+
func Registerer(r prometheus.Registerer) Option {
76+
return func(o *Options) {
77+
o.Registerer = r
78+
}
79+
}
80+
81+
// newOptions builds Options from the provided Option functions, applying
82+
// sensible defaults.
83+
func newOptions(opts ...Option) Options {
84+
options := Options{
85+
Name: "micro",
86+
Buckets: prometheus.DefBuckets,
87+
Registerer: prometheus.DefaultRegisterer,
88+
}
89+
for _, o := range opts {
90+
o(&options)
91+
}
92+
if options.Registerer == nil {
93+
options.Registerer = prometheus.DefaultRegisterer
94+
}
95+
if len(options.Buckets) == 0 {
96+
options.Buckets = prometheus.DefBuckets
97+
}
98+
return options
99+
}

0 commit comments

Comments
 (0)