diff --git a/CHANGELOG.md b/CHANGELOG.md index 346e9fa6580..4a73d579042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add support for configuring `ClientCertificate` and `ClientKey` field for OTLP exporters in `go.opentelemetry.io/contrib/config`. (#6378) - Add `WithAttributeBuilder`, `AttributeBuilder`, `DefaultAttributeBuilder`, `DynamoDBAttributeBuilder`, `SNSAttributeBuilder` to support adding attributes based on SDK input and output in `go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws`. (#6543) - Support for the OTEL_HTTP_CLIENT_COMPATIBILITY_MODE=http/dup environment variable in `go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux` to emit attributes for both the v1.20.0 and v1.26.0 semantic conventions. (#6652) +- Added the `WithMeterProvider` option to allow passing a custom meter provider to `go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux`. (#6648) +- Added the `WithMetricAttributesFn` option to allow setting dynamic, per-request metric attributes in `go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux`. (#6648) +- Added metrics support, and emit all stable metrics from the [Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-metrics.md) in `go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux`. (#6648) ### Changed diff --git a/instrumentation/github.com/gorilla/mux/otelmux/config.go b/instrumentation/github.com/gorilla/mux/otelmux/config.go index 0f9b24242a0..c5e59d64e50 100644 --- a/instrumentation/github.com/gorilla/mux/otelmux/config.go +++ b/instrumentation/github.com/gorilla/mux/otelmux/config.go @@ -6,18 +6,22 @@ package otelmux // import "go.opentelemetry.io/contrib/instrumentation/github.co import ( "net/http" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/propagation" oteltrace "go.opentelemetry.io/otel/trace" ) // config is used to configure the mux middleware. type config struct { - TracerProvider oteltrace.TracerProvider - Propagators propagation.TextMapPropagator - spanNameFormatter func(string, *http.Request) string - PublicEndpoint bool - PublicEndpointFn func(*http.Request) bool - Filters []Filter + TracerProvider oteltrace.TracerProvider + Propagators propagation.TextMapPropagator + spanNameFormatter func(string, *http.Request) string + PublicEndpoint bool + PublicEndpointFn func(*http.Request) bool + Filters []Filter + MeterProvider metric.MeterProvider + MetricAttributesFn func(*http.Request) []attribute.KeyValue } // Option specifies instrumentation configuration options. @@ -97,3 +101,21 @@ func WithFilter(f Filter) Option { c.Filters = append(c.Filters, f) }) } + +// WithMeterProvider specifies a meter provider to use for creating a metric. +// If none is specified, the global provider is used. +func WithMeterProvider(provider metric.MeterProvider) Option { + return optionFunc(func(cfg *config) { + if provider != nil { + cfg.MeterProvider = provider + } + }) +} + +// WithMetricAttributesFn returns an Option to set a function that maps an HTTP request to a slice of attribute.KeyValue. +// These attributes will be included in metrics for every request. +func WithMetricAttributesFn(metricAttributesFn func(r *http.Request) []attribute.KeyValue) Option { + return optionFunc(func(c *config) { + c.MetricAttributesFn = metricAttributesFn + }) +} diff --git a/instrumentation/github.com/gorilla/mux/otelmux/mux.go b/instrumentation/github.com/gorilla/mux/otelmux/mux.go index a0cb38b2327..239fa3be28d 100644 --- a/instrumentation/github.com/gorilla/mux/otelmux/mux.go +++ b/instrumentation/github.com/gorilla/mux/otelmux/mux.go @@ -6,6 +6,7 @@ package otelmux // import "go.opentelemetry.io/contrib/instrumentation/github.co import ( "fmt" "net/http" + "time" "github.com/felixge/httpsnoop" "github.com/gorilla/mux" @@ -14,7 +15,8 @@ import ( "go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux/internal/semconv" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/trace" ) @@ -45,31 +47,42 @@ func Middleware(service string, opts ...Option) mux.MiddlewareFunc { if cfg.spanNameFormatter == nil { cfg.spanNameFormatter = defaultSpanNameFunc } + if cfg.MeterProvider == nil { + cfg.MeterProvider = otel.GetMeterProvider() + } + meter := cfg.MeterProvider.Meter( + ScopeName, + metric.WithInstrumentationVersion(Version()), + ) return func(handler http.Handler) http.Handler { return traceware{ - service: service, - tracer: tracer, - propagators: cfg.Propagators, - handler: handler, - spanNameFormatter: cfg.spanNameFormatter, - publicEndpoint: cfg.PublicEndpoint, - publicEndpointFn: cfg.PublicEndpointFn, - filters: cfg.Filters, - semconv: semconv.NewHTTPServer(noop.Meter{}), + service: service, + tracer: tracer, + propagators: cfg.Propagators, + handler: handler, + spanNameFormatter: cfg.spanNameFormatter, + publicEndpoint: cfg.PublicEndpoint, + publicEndpointFn: cfg.PublicEndpointFn, + filters: cfg.Filters, + meter: meter, + semconv: semconv.NewHTTPServer(meter), + metricAttributesFn: cfg.MetricAttributesFn, } } } type traceware struct { - service string - tracer trace.Tracer - propagators propagation.TextMapPropagator - handler http.Handler - spanNameFormatter func(string, *http.Request) string - publicEndpoint bool - publicEndpointFn func(*http.Request) bool - filters []Filter - semconv semconv.HTTPServer + service string + tracer trace.Tracer + propagators propagation.TextMapPropagator + handler http.Handler + spanNameFormatter func(string, *http.Request) string + publicEndpoint bool + publicEndpointFn func(*http.Request) bool + filters []Filter + meter metric.Meter + semconv semconv.HTTPServer + metricAttributesFn func(*http.Request) []attribute.KeyValue } // defaultSpanNameFunc just reuses the route name as the span name. @@ -78,6 +91,7 @@ func defaultSpanNameFunc(routeName string, _ *http.Request) string { return rout // ServeHTTP implements the http.Handler interface. It does the actual // tracing of the request. func (tw traceware) ServeHTTP(w http.ResponseWriter, r *http.Request) { + requestStartTime := time.Now() for _, f := range tw.filters { if !f(r) { // Simply pass through to the handler if a filter rejects the request @@ -158,4 +172,31 @@ func (tw traceware) ServeHTTP(w http.ResponseWriter, r *http.Request) { WriteBytes: rww.BytesWritten(), WriteError: rww.Error(), })...) + + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedTime := float64(time.Since(requestStartTime)) / float64(time.Millisecond) + + metricAttributes := semconv.MetricAttributes{ + Req: r, + StatusCode: statusCode, + AdditionalAttributes: tw.metricAttributesFromRequest(r), + } + + tw.semconv.RecordMetrics(ctx, semconv.ServerMetricData{ + ServerName: tw.service, + ResponseSize: rww.BytesWritten(), + MetricAttributes: metricAttributes, + MetricData: semconv.MetricData{ + RequestSize: bw.BytesRead(), + ElapsedTime: elapsedTime, + }, + }) +} + +func (tw traceware) metricAttributesFromRequest(r *http.Request) []attribute.KeyValue { + var attributeForRequest []attribute.KeyValue + if tw.metricAttributesFn != nil { + attributeForRequest = tw.metricAttributesFn(r) + } + return attributeForRequest } diff --git a/instrumentation/github.com/gorilla/mux/otelmux/test/go.mod b/instrumentation/github.com/gorilla/mux/otelmux/test/go.mod index 54e1c443444..07acc005a6f 100644 --- a/instrumentation/github.com/gorilla/mux/otelmux/test/go.mod +++ b/instrumentation/github.com/gorilla/mux/otelmux/test/go.mod @@ -8,6 +8,7 @@ require ( go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.59.0 go.opentelemetry.io/otel v1.34.0 go.opentelemetry.io/otel/sdk v1.34.0 + go.opentelemetry.io/otel/sdk/metric v1.34.0 go.opentelemetry.io/otel/trace v1.34.0 ) diff --git a/instrumentation/github.com/gorilla/mux/otelmux/test/go.sum b/instrumentation/github.com/gorilla/mux/otelmux/test/go.sum index 92c480d0006..d4dcfd167dc 100644 --- a/instrumentation/github.com/gorilla/mux/otelmux/test/go.sum +++ b/instrumentation/github.com/gorilla/mux/otelmux/test/go.sum @@ -31,6 +31,8 @@ go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= diff --git a/instrumentation/github.com/gorilla/mux/otelmux/test/mux_test.go b/instrumentation/github.com/gorilla/mux/otelmux/test/mux_test.go index b009022b1bb..78866e6a6ef 100644 --- a/instrumentation/github.com/gorilla/mux/otelmux/test/mux_test.go +++ b/instrumentation/github.com/gorilla/mux/otelmux/test/mux_test.go @@ -18,6 +18,8 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" "go.opentelemetry.io/otel/trace" @@ -81,8 +83,14 @@ func TestSDKIntegration(t *testing.T) { provider := sdktrace.NewTracerProvider() provider.RegisterSpanProcessor(sr) + reader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + router := mux.NewRouter() - router.Use(otelmux.Middleware("foobar", otelmux.WithTracerProvider(provider))) + router.Use(otelmux.Middleware("foobar", + otelmux.WithTracerProvider(provider), + otelmux.WithMeterProvider(meterProvider))) + router.HandleFunc("/user/{id:[0-9]+}", ok) router.HandleFunc("/book/{title}", ok) @@ -282,3 +290,82 @@ func TestWithPublicEndpointFn(t *testing.T) { }) } } + +func TestHandlerWithMetricAttributesFn(t *testing.T) { + const ( + serverRequestSize = "http.server.request.size" + serverResponseSize = "http.server.response.size" + serverDuration = "http.server.duration" + ) + testCases := []struct { + name string + fn func(r *http.Request) []attribute.KeyValue + expectedAdditionalAttribute []attribute.KeyValue + }{ + { + name: "With a nil function", + fn: nil, + expectedAdditionalAttribute: []attribute.KeyValue{}, + }, + { + name: "With a function that returns an additional attribute", + fn: func(r *http.Request) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("fooKey", "fooValue"), + attribute.String("barKey", "barValue"), + } + }, + expectedAdditionalAttribute: []attribute.KeyValue{ + attribute.String("fooKey", "fooValue"), + attribute.String("barKey", "barValue"), + }, + }, + } + + for _, tc := range testCases { + reader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + + router := mux.NewRouter() + router.Use(otelmux.Middleware("foobar", + otelmux.WithMeterProvider(meterProvider), + otelmux.WithMetricAttributesFn(tc.fn), + )) + + router.HandleFunc("/user/{id:[0-9]+}", ok) + r, err := http.NewRequest(http.MethodGet, "http://localhost/user/123", nil) + require.NoError(t, err) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, r) + + rm := metricdata.ResourceMetrics{} + err = reader.Collect(context.Background(), &rm) + require.NoError(t, err) + require.Len(t, rm.ScopeMetrics, 1) + assert.Len(t, rm.ScopeMetrics[0].Metrics, 3) + + // Verify that the additional attribute is present in the metrics. + for _, m := range rm.ScopeMetrics[0].Metrics { + switch m.Name { + case serverRequestSize, serverResponseSize: + d, ok := m.Data.(metricdata.Sum[int64]) + assert.True(t, ok) + assert.Len(t, d.DataPoints, 1) + containsAttributes(t, d.DataPoints[0].Attributes, testCases[0].expectedAdditionalAttribute) + case serverDuration: + d, ok := m.Data.(metricdata.Histogram[float64]) + assert.True(t, ok) + assert.Len(t, d.DataPoints, 1) + containsAttributes(t, d.DataPoints[0].Attributes, testCases[0].expectedAdditionalAttribute) + } + } + } +} + +func containsAttributes(t *testing.T, attrSet attribute.Set, expected []attribute.KeyValue) { + for _, att := range expected { + actualValue, ok := attrSet.Value(att.Key) + assert.True(t, ok) + assert.Equal(t, att.Value.AsString(), actualValue.AsString()) + } +}