Skip to content

Commit

Permalink
Allow setting custom metric attributes in otelhttp transport (#5876)
Browse files Browse the repository at this point in the history
There is no option to include a set of attribute on metrics for every
request

---------

Co-authored-by: Damien Mathieu <[email protected]>
  • Loading branch information
luca-filipponi and dmathieu authored Aug 21, 2024
1 parent abb5953 commit 9b2f4d9
Show file tree
Hide file tree
Showing 4 changed files with 107 additions and 26 deletions.
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)

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")
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

0 comments on commit 9b2f4d9

Please sign in to comment.