diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4a965e673..b79f40b547f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The next release will require at least [Go 1.22]. This module provides an OpenTelemetry logging bridge for `github.com/rs/zerolog`. (#5405) - Add `WithGinFilter` filter parameter in `go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin` to allow filtering requests with `*gin.Context`. (#5743) - Support [Go 1.23]. (#6017) +- Add the `WithMetricsAttributesFn` option to allow setting dynamic, per-request metric attributes in `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`. (#5876) ### Removed diff --git a/instrumentation/net/http/otelhttp/config.go b/instrumentation/net/http/otelhttp/config.go index f0a9bb9efeb..a01bfafbe07 100644 --- a/instrumentation/net/http/otelhttp/config.go +++ b/instrumentation/net/http/otelhttp/config.go @@ -8,6 +8,8 @@ import ( "net/http" "net/http/httptrace" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/propagation" @@ -33,8 +35,9 @@ type config struct { SpanNameFormatter func(string, *http.Request) string ClientTrace func(context.Context) *httptrace.ClientTrace - TracerProvider trace.TracerProvider - MeterProvider metric.MeterProvider + TracerProvider trace.TracerProvider + MeterProvider metric.MeterProvider + MetricAttributesFn func(*http.Request) []attribute.KeyValue } // Option interface used for setting optional config properties. @@ -194,3 +197,11 @@ func WithServerName(server string) Option { c.ServerName = server }) } + +// 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/net/http/otelhttp/test/transport_test.go b/instrumentation/net/http/otelhttp/test/transport_test.go index e61950dd7dd..6f38363fd6b 100644 --- a/instrumentation/net/http/otelhttp/test/transport_test.go +++ b/instrumentation/net/http/otelhttp/test/transport_test.go @@ -507,11 +507,15 @@ func TestCustomAttributesHandling(t *testing.T) { })) defer ts.Close() + expectedAttributes := []attribute.KeyValue{ + attribute.String("foo", "fooValue"), + attribute.String("bar", "barValue"), + } + r, err := http.NewRequest(http.MethodGet, ts.URL, nil) require.NoError(t, err) labeler := &otelhttp.Labeler{} - labeler.Add(attribute.String("foo", "fooValue")) - labeler.Add(attribute.String("bar", "barValue")) + labeler.Add(expectedAttributes...) ctx = otelhttp.ContextWithLabeler(ctx, labeler) r = r.WithContext(ctx) @@ -534,30 +538,85 @@ func TestCustomAttributesHandling(t *testing.T) { d, ok := m.Data.(metricdata.Sum[int64]) assert.True(t, ok) assert.Len(t, d.DataPoints, 1) - attrSet := d.DataPoints[0].Attributes - fooAtrr, ok := attrSet.Value(attribute.Key("foo")) - assert.True(t, ok) - assert.Equal(t, "fooValue", fooAtrr.AsString()) - barAtrr, ok := attrSet.Value(attribute.Key("bar")) - assert.True(t, ok) - assert.Equal(t, "barValue", barAtrr.AsString()) - assert.False(t, attrSet.HasValue(attribute.Key("baz"))) + containsAttributes(t, d.DataPoints[0].Attributes, expectedAttributes) case clientDuration: d, ok := m.Data.(metricdata.Histogram[float64]) assert.True(t, ok) assert.Len(t, d.DataPoints, 1) - attrSet := d.DataPoints[0].Attributes - fooAtrr, ok := attrSet.Value(attribute.Key("foo")) + containsAttributes(t, d.DataPoints[0].Attributes, expectedAttributes) + } + } +} + +func TestDefaultAttributesHandling(t *testing.T) { + var rm metricdata.ResourceMetrics + const ( + clientRequestSize = "http.client.request.size" + clientDuration = "http.client.duration" + ) + ctx := context.TODO() + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + defer func() { + err := provider.Shutdown(ctx) + if err != nil { + t.Errorf("Error shutting down provider: %v", err) + } + }() + + defaultAttributes := []attribute.KeyValue{ + attribute.String("defaultFoo", "fooValue"), + attribute.String("defaultBar", "barValue"), + } + + transport := otelhttp.NewTransport( + http.DefaultTransport, otelhttp.WithMeterProvider(provider), + otelhttp.WithMetricAttributesFn(func(_ *http.Request) []attribute.KeyValue { + return defaultAttributes + })) + client := http.Client{Transport: transport} + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + r, err := http.NewRequest(http.MethodGet, ts.URL, nil) + require.NoError(t, err) + + resp, err := client.Do(r) + require.NoError(t, err) + + _ = resp.Body.Close() + + err = reader.Collect(ctx, &rm) + assert.NoError(t, err) + + assert.Len(t, rm.ScopeMetrics[0].Metrics, 3) + for _, m := range rm.ScopeMetrics[0].Metrics { + switch m.Name { + case clientRequestSize: + d, ok := m.Data.(metricdata.Sum[int64]) assert.True(t, ok) - assert.Equal(t, "fooValue", fooAtrr.AsString()) - barAtrr, ok := attrSet.Value(attribute.Key("bar")) + assert.Len(t, d.DataPoints, 1) + containsAttributes(t, d.DataPoints[0].Attributes, defaultAttributes) + case clientDuration: + d, ok := m.Data.(metricdata.Histogram[float64]) assert.True(t, ok) - assert.Equal(t, "barValue", barAtrr.AsString()) - assert.False(t, attrSet.HasValue(attribute.Key("baz"))) + assert.Len(t, d.DataPoints, 1) + containsAttributes(t, d.DataPoints[0].Attributes, defaultAttributes) } } } +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()) + } +} + func BenchmarkTransportRoundTrip(b *testing.B) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "Hello World") diff --git a/instrumentation/net/http/otelhttp/transport.go b/instrumentation/net/http/otelhttp/transport.go index fc4dd98f3d0..b4119d3438b 100644 --- a/instrumentation/net/http/otelhttp/transport.go +++ b/instrumentation/net/http/otelhttp/transport.go @@ -28,13 +28,14 @@ import ( type Transport struct { rt http.RoundTripper - tracer trace.Tracer - meter metric.Meter - propagators propagation.TextMapPropagator - spanStartOptions []trace.SpanStartOption - filters []Filter - spanNameFormatter func(string, *http.Request) string - clientTrace func(context.Context) *httptrace.ClientTrace + tracer trace.Tracer + meter metric.Meter + propagators propagation.TextMapPropagator + spanStartOptions []trace.SpanStartOption + filters []Filter + spanNameFormatter func(string, *http.Request) string + clientTrace func(context.Context) *httptrace.ClientTrace + metricAttributesFn func(*http.Request) []attribute.KeyValue semconv semconv.HTTPClient requestBytesCounter metric.Int64Counter @@ -80,6 +81,7 @@ func (t *Transport) applyConfig(c *config) { t.filters = c.Filters t.spanNameFormatter = c.SpanNameFormatter t.clientTrace = c.ClientTrace + t.metricAttributesFn = c.MetricAttributesFn } func (t *Transport) createMeasures() { @@ -175,7 +177,7 @@ func (t *Transport) RoundTrip(r *http.Request) (*http.Response, error) { } // metrics - metricAttrs := append(labeler.Get(), semconvutil.HTTPClientRequestMetrics(r)...) + metricAttrs := append(append(labeler.Get(), semconvutil.HTTPClientRequestMetrics(r)...), t.metricAttributesFromRequest(r)...) if res.StatusCode > 0 { metricAttrs = append(metricAttrs, semconv.HTTPStatusCode(res.StatusCode)) } @@ -201,6 +203,14 @@ func (t *Transport) RoundTrip(r *http.Request) (*http.Response, error) { return res, err } +func (t *Transport) metricAttributesFromRequest(r *http.Request) []attribute.KeyValue { + var attributeForRequest []attribute.KeyValue + if t.metricAttributesFn != nil { + attributeForRequest = t.metricAttributesFn(r) + } + return attributeForRequest +} + // newWrappedBody returns a new and appropriately scoped *wrappedBody as an // io.ReadCloser. If the passed body implements io.Writer, the returned value // will implement io.ReadWriteCloser.