Skip to content

Commit 5304066

Browse files
authored
add WithLabelsFromContext prometheus interceptor option (#758)
* add WithLabelsFromContext prometheus interceptor option * run lint * remove unused field * update example line numbers * go mod tidy * remove go toolchain and go patch version change in go mod file * Revert "remove go toolchain and go patch version change in go mod file" This reverts commit 6bfa439. * remove go toolchain and go patch version change in go mod file * remove toolchain directive
1 parent 26edc26 commit 5304066

File tree

9 files changed

+395
-37
lines changed

9 files changed

+395
-37
lines changed

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,23 @@ This repository offers ready-to-use middlewares that implements gRPC interceptor
1616
1717
Additional great feature of interceptors is the fact we can chain those. For example below you can find example server side chain of interceptors with full observabiliy correlation, auth and panic recovery:
1818

19-
```go mdox-exec="sed -n '122,136p' examples/server/main.go"
19+
```go mdox-exec="sed -n '143,163p' examples/server/main.go"
2020
grpcSrv := grpc.NewServer(
2121
grpc.StatsHandler(otelgrpc.NewServerHandler()),
2222
grpc.ChainUnaryInterceptor(
23-
srvMetrics.UnaryServerInterceptor(grpcprom.WithExemplarFromContext(exemplarFromContext)),
23+
srvMetrics.UnaryServerInterceptor(
24+
grpcprom.WithExemplarFromContext(exemplarFromContext),
25+
grpcprom.WithLabelsFromContext(labelsFromContext),
26+
),
2427
logging.UnaryServerInterceptor(interceptorLogger(rpcLogger), logging.WithFieldsFromContext(logTraceID)),
2528
selector.UnaryServerInterceptor(auth.UnaryServerInterceptor(authFn), selector.MatchFunc(allButHealthZ)),
2629
recovery.UnaryServerInterceptor(recovery.WithRecoveryHandler(grpcPanicRecoveryHandler)),
2730
),
2831
grpc.ChainStreamInterceptor(
29-
srvMetrics.StreamServerInterceptor(grpcprom.WithExemplarFromContext(exemplarFromContext)),
32+
srvMetrics.StreamServerInterceptor(
33+
grpcprom.WithExemplarFromContext(exemplarFromContext),
34+
grpcprom.WithLabelsFromContext(labelsFromContext),
35+
),
3036
logging.StreamServerInterceptor(interceptorLogger(rpcLogger), logging.WithFieldsFromContext(logTraceID)),
3137
selector.StreamServerInterceptor(auth.StreamServerInterceptor(authFn), selector.MatchFunc(allButHealthZ)),
3238
recovery.StreamServerInterceptor(recovery.WithRecoveryHandler(grpcPanicRecoveryHandler)),

examples/go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/grpc-ecosystem/go-grpc-middleware/examples/v2
22

3-
go 1.23
3+
go 1.23.0
44

55
require (
66
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.0
@@ -33,9 +33,9 @@ require (
3333
go.opentelemetry.io/otel/metric v1.31.0 // indirect
3434
golang.org/x/net v0.35.0 // indirect
3535
golang.org/x/sys v0.30.0 // indirect
36-
golang.org/x/text v0.22.0 // indirect
36+
golang.org/x/text v0.23.0 // indirect
3737
google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect
38-
google.golang.org/protobuf v1.36.4 // indirect
38+
google.golang.org/protobuf v1.36.6 // indirect
3939
gopkg.in/yaml.v3 v3.0.1 // indirect
4040
)
4141

examples/go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,14 @@ golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
6060
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
6161
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
6262
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
63-
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
64-
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
63+
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
64+
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
6565
google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc=
6666
google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
6767
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
6868
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
69-
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
70-
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
69+
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
70+
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
7171
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
7272
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
7373
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

examples/server/main.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
1919
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery"
2020
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/selector"
21+
"github.com/grpc-ecosystem/go-grpc-middleware/v2/metadata"
2122
"github.com/grpc-ecosystem/go-grpc-middleware/v2/testing/testpb"
2223
"github.com/oklog/run"
2324
"github.com/prometheus/client_golang/prometheus"
@@ -65,6 +66,11 @@ func main() {
6566
grpcprom.WithServerHandlingTimeHistogram(
6667
grpcprom.WithHistogramBuckets([]float64{0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 9, 20, 30, 60, 90, 120}),
6768
),
69+
// Add tenant_name as a context label. This server option is necessary
70+
// to initialize the metrics with the labels that will be provided
71+
// dynamically from the context. This should be used in tandem with
72+
// WithLabelsFromContext in the interceptor options.
73+
grpcprom.WithContextLabels("tenant_name"),
6874
)
6975
reg := prometheus.NewRegistry()
7076
reg.MustRegister(srvMetrics)
@@ -75,6 +81,21 @@ func main() {
7581
return nil
7682
}
7783

84+
// Extract the tenant name value from gRPC metadata
85+
// and use it as a label on our metrics.
86+
labelsFromContext := func(ctx context.Context) prometheus.Labels {
87+
labels := prometheus.Labels{}
88+
89+
md := metadata.ExtractIncoming(ctx)
90+
tenantName := md.Get("tenant-name")
91+
if tenantName == "" {
92+
tenantName = "unknown"
93+
}
94+
labels["tenant_name"] = tenantName
95+
96+
return labels
97+
}
98+
7899
// Set up OTLP tracing (stdout for debug).
79100
exporter, err := stdout.New(stdout.WithPrettyPrint())
80101
if err != nil {
@@ -122,13 +143,19 @@ func main() {
122143
grpcSrv := grpc.NewServer(
123144
grpc.StatsHandler(otelgrpc.NewServerHandler()),
124145
grpc.ChainUnaryInterceptor(
125-
srvMetrics.UnaryServerInterceptor(grpcprom.WithExemplarFromContext(exemplarFromContext)),
146+
srvMetrics.UnaryServerInterceptor(
147+
grpcprom.WithExemplarFromContext(exemplarFromContext),
148+
grpcprom.WithLabelsFromContext(labelsFromContext),
149+
),
126150
logging.UnaryServerInterceptor(interceptorLogger(rpcLogger), logging.WithFieldsFromContext(logTraceID)),
127151
selector.UnaryServerInterceptor(auth.UnaryServerInterceptor(authFn), selector.MatchFunc(allButHealthZ)),
128152
recovery.UnaryServerInterceptor(recovery.WithRecoveryHandler(grpcPanicRecoveryHandler)),
129153
),
130154
grpc.ChainStreamInterceptor(
131-
srvMetrics.StreamServerInterceptor(grpcprom.WithExemplarFromContext(exemplarFromContext)),
155+
srvMetrics.StreamServerInterceptor(
156+
grpcprom.WithExemplarFromContext(exemplarFromContext),
157+
grpcprom.WithLabelsFromContext(labelsFromContext),
158+
),
132159
logging.StreamServerInterceptor(interceptorLogger(rpcLogger), logging.WithFieldsFromContext(logTraceID)),
133160
selector.StreamServerInterceptor(auth.StreamServerInterceptor(authFn), selector.MatchFunc(allButHealthZ)),
134161
recovery.StreamServerInterceptor(recovery.WithRecoveryHandler(grpcPanicRecoveryHandler)),
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright (c) The go-grpc-middleware Authors.
2+
// Licensed under the Apache License 2.0.
3+
4+
package prometheus
5+
6+
import (
7+
"context"
8+
"testing"
9+
"time"
10+
11+
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors"
12+
"github.com/prometheus/client_golang/prometheus"
13+
dto "github.com/prometheus/client_model/go"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
"google.golang.org/grpc/codes"
17+
)
18+
19+
func TestContextLabels(t *testing.T) {
20+
// Create server metrics with context labels
21+
serverMetrics := NewServerMetrics(
22+
WithContextLabels("user_id", "tenant_id"),
23+
)
24+
25+
// Create a custom registry to isolate this test
26+
reg := prometheus.NewRegistry()
27+
reg.MustRegister(serverMetrics)
28+
29+
// Create a mock context with label values
30+
ctx := context.Background()
31+
32+
// Create the labels extraction function
33+
labelsFromCtx := func(ctx context.Context) prometheus.Labels {
34+
return prometheus.Labels{
35+
"user_id": "user123",
36+
"tenant_id": "tenant456",
37+
}
38+
}
39+
40+
// Create a reporter with the labels function
41+
rep := &reportable{
42+
serverMetrics: serverMetrics,
43+
opts: []Option{WithLabelsFromContext(labelsFromCtx)},
44+
}
45+
46+
// Simulate a server call
47+
meta := interceptors.CallMeta{
48+
Typ: interceptors.Unary,
49+
Service: "testpb.PingService",
50+
Method: "Ping",
51+
}
52+
53+
reporter, _ := rep.ServerReporter(ctx, meta)
54+
55+
// Simulate call completion
56+
reporter.PostCall(nil, time.Millisecond*100)
57+
58+
// Collect metrics
59+
metricFamilies, err := reg.Gather()
60+
require.NoError(t, err)
61+
62+
// Find the handled counter metric
63+
var handledCounter *dto.MetricFamily
64+
for _, mf := range metricFamilies {
65+
if *mf.Name == "grpc_server_handled_total" {
66+
handledCounter = mf
67+
break
68+
}
69+
}
70+
71+
require.NotNil(t, handledCounter, "Should find grpc_server_handled_total metric")
72+
require.Len(t, handledCounter.Metric, 1, "Should have one metric sample")
73+
74+
// Verify the metric has all expected labels
75+
metric := handledCounter.Metric[0]
76+
labelMap := make(map[string]string)
77+
for _, label := range metric.Label {
78+
labelMap[*label.Name] = *label.Value
79+
}
80+
81+
// Check standard labels
82+
assert.Equal(t, "unary", labelMap["grpc_type"])
83+
assert.Equal(t, "testpb.PingService", labelMap["grpc_service"])
84+
assert.Equal(t, "Ping", labelMap["grpc_method"])
85+
assert.Equal(t, codes.OK.String(), labelMap["grpc_code"])
86+
87+
// Check context labels
88+
assert.Equal(t, "user123", labelMap["user_id"])
89+
assert.Equal(t, "tenant456", labelMap["tenant_id"])
90+
91+
// Verify metric value
92+
assert.Equal(t, float64(1), *metric.Counter.Value)
93+
}
94+
95+
func TestContextLabelsWithMissingValues(t *testing.T) {
96+
// Create server metrics with context labels
97+
serverMetrics := NewServerMetrics(
98+
WithContextLabels("user_id", "missing_label"),
99+
)
100+
101+
// Create a custom registry to isolate this test
102+
reg := prometheus.NewRegistry()
103+
reg.MustRegister(serverMetrics)
104+
105+
// Create a mock context with only partial label values
106+
ctx := context.Background()
107+
108+
// Create the labels extraction function that only returns one label
109+
labelsFromCtx := func(ctx context.Context) prometheus.Labels {
110+
return prometheus.Labels{
111+
"user_id": "user123",
112+
// missing_label is not provided
113+
}
114+
}
115+
116+
// Create a reporter with the labels function
117+
rep := &reportable{
118+
serverMetrics: serverMetrics,
119+
opts: []Option{WithLabelsFromContext(labelsFromCtx)},
120+
}
121+
122+
// Simulate a server call
123+
meta := interceptors.CallMeta{
124+
Typ: interceptors.Unary,
125+
Service: "testpb.PingService",
126+
Method: "Ping",
127+
}
128+
129+
reporter, _ := rep.ServerReporter(ctx, meta)
130+
131+
// Simulate call completion
132+
reporter.PostCall(nil, time.Millisecond*100)
133+
134+
// Collect metrics
135+
metricFamilies, err := reg.Gather()
136+
require.NoError(t, err)
137+
138+
// Find the handled counter metric
139+
var handledCounter *dto.MetricFamily
140+
for _, mf := range metricFamilies {
141+
if *mf.Name == "grpc_server_handled_total" {
142+
handledCounter = mf
143+
break
144+
}
145+
}
146+
147+
require.NotNil(t, handledCounter, "Should find grpc_server_handled_total metric")
148+
require.Len(t, handledCounter.Metric, 1, "Should have one metric sample")
149+
150+
// Verify the metric has all expected labels
151+
metric := handledCounter.Metric[0]
152+
labelMap := make(map[string]string)
153+
for _, label := range metric.Label {
154+
labelMap[*label.Name] = *label.Value
155+
}
156+
157+
// Check standard labels
158+
assert.Equal(t, "unary", labelMap["grpc_type"])
159+
assert.Equal(t, "testpb.PingService", labelMap["grpc_service"])
160+
assert.Equal(t, "Ping", labelMap["grpc_method"])
161+
assert.Equal(t, codes.OK.String(), labelMap["grpc_code"])
162+
163+
// Check context labels - user_id should be present, missing_label should be empty
164+
assert.Equal(t, "user123", labelMap["user_id"])
165+
assert.Equal(t, "", labelMap["missing_label"])
166+
167+
// Verify metric value
168+
assert.Equal(t, float64(1), *metric.Counter.Value)
169+
}
170+
171+
func TestContextLabelsWithHistogram(t *testing.T) {
172+
// Create server metrics with context labels and histogram enabled
173+
serverMetrics := NewServerMetrics(
174+
WithContextLabels("user_id"),
175+
WithServerHandlingTimeHistogram(),
176+
)
177+
178+
// Create a custom registry to isolate this test
179+
reg := prometheus.NewRegistry()
180+
reg.MustRegister(serverMetrics)
181+
182+
// Create a mock context with label values
183+
ctx := context.Background()
184+
185+
// Create the labels extraction function
186+
labelsFromCtx := func(ctx context.Context) prometheus.Labels {
187+
return prometheus.Labels{
188+
"user_id": "user123",
189+
}
190+
}
191+
192+
// Create a reporter with the labels function
193+
rep := &reportable{
194+
serverMetrics: serverMetrics,
195+
opts: []Option{WithLabelsFromContext(labelsFromCtx)},
196+
}
197+
198+
// Simulate a server call
199+
meta := interceptors.CallMeta{
200+
Typ: interceptors.Unary,
201+
Service: "testpb.PingService",
202+
Method: "Ping",
203+
}
204+
205+
reporter, _ := rep.ServerReporter(ctx, meta)
206+
207+
// Simulate call completion
208+
reporter.PostCall(nil, time.Millisecond*100)
209+
210+
// Collect metrics
211+
metricFamilies, err := reg.Gather()
212+
require.NoError(t, err)
213+
214+
// Find the histogram metric
215+
var histogram *dto.MetricFamily
216+
for _, mf := range metricFamilies {
217+
if *mf.Name == "grpc_server_handling_seconds" {
218+
histogram = mf
219+
break
220+
}
221+
}
222+
223+
require.NotNil(t, histogram, "Should find grpc_server_handling_seconds metric")
224+
require.Len(t, histogram.Metric, 1, "Should have one metric sample")
225+
226+
// Verify the histogram has all expected labels
227+
metric := histogram.Metric[0]
228+
labelMap := make(map[string]string)
229+
for _, label := range metric.Label {
230+
labelMap[*label.Name] = *label.Value
231+
}
232+
233+
// Check standard labels
234+
assert.Equal(t, "unary", labelMap["grpc_type"])
235+
assert.Equal(t, "testpb.PingService", labelMap["grpc_service"])
236+
assert.Equal(t, "Ping", labelMap["grpc_method"])
237+
238+
// Check context labels
239+
assert.Equal(t, "user123", labelMap["user_id"])
240+
241+
// Verify histogram has recorded a sample
242+
assert.Equal(t, uint64(1), *metric.Histogram.SampleCount)
243+
}

providers/prometheus/options.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ type Option func(*config)
127127

128128
type config struct {
129129
exemplarFn exemplarFromCtxFn
130+
labelsFn labelsFromCtxFn
130131
}
131132

132133
func (c *config) apply(opts []Option) {
@@ -141,3 +142,11 @@ func WithExemplarFromContext(exemplarFn exemplarFromCtxFn) Option {
141142
o.exemplarFn = exemplarFn
142143
}
143144
}
145+
146+
// WithLabelsFromContext sets function that will be used to extract labels from context for metrics.
147+
// This should be used in conjunction with WithContextLabels to define which labels to extract.
148+
func WithLabelsFromContext(labelsFn labelsFromCtxFn) Option {
149+
return func(o *config) {
150+
o.labelsFn = labelsFn
151+
}
152+
}

0 commit comments

Comments
 (0)