Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow setting custom metric attributes in otelhttp transport #5876

Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 13 additions & 2 deletions instrumentation/net/http/otelhttp/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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
})
}
91 changes: 75 additions & 16 deletions instrumentation/net/http/otelhttp/test/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)

_, err = client.Do(r)
luca-filipponi marked this conversation as resolved.
Show resolved Hide resolved
require.NoError(t, err)

err = reader.Collect(ctx, &rm)
assert.NoError(t, err)

// http.client.response.size is not recorded so the assert.Len
// above should be 2 instead of 3(test bonus)
assert.Len(t, rm.ScopeMetrics[0].Metrics, 2)
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")
Expand Down
26 changes: 18 additions & 8 deletions instrumentation/net/http/otelhttp/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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))
}
Expand All @@ -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.
Expand Down
Loading