From f3a0cc8093d620a1fe1bc60b07bcd156326aaa46 Mon Sep 17 00:00:00 2001 From: vadimi Date: Sun, 25 Jan 2026 21:52:32 -0500 Subject: [PATCH] add new `github.com/labstack/echo-contrib/otelecho/v5` sub-module This version performs the same instrumentation as previously available `otelecho` in `opentelemetry-go-contrib` repo, but with echo v5 support v5 sub-directory is used specifically in case of new v6, v7, etc versions of echo come out in the future --- otelecho/v5/config.go | 121 ++++++ otelecho/v5/doc.go | 2 + otelecho/v5/echo.go | 186 +++++++++ otelecho/v5/echo_test.go | 425 ++++++++++++++++++++ otelecho/v5/go.mod | 27 ++ otelecho/v5/go.sum | 52 +++ otelecho/v5/internal/semconv/server.go | 403 +++++++++++++++++++ otelecho/v5/internal/semconv/server_test.go | 204 ++++++++++ otelecho/v5/internal/semconv/util.go | 121 ++++++ otelecho/v5/internal/semconv/util_test.go | 69 ++++ otelecho/v5/version.go | 4 + 11 files changed, 1614 insertions(+) create mode 100644 otelecho/v5/config.go create mode 100644 otelecho/v5/doc.go create mode 100644 otelecho/v5/echo.go create mode 100644 otelecho/v5/echo_test.go create mode 100644 otelecho/v5/go.mod create mode 100644 otelecho/v5/go.sum create mode 100644 otelecho/v5/internal/semconv/server.go create mode 100644 otelecho/v5/internal/semconv/server_test.go create mode 100644 otelecho/v5/internal/semconv/util.go create mode 100644 otelecho/v5/internal/semconv/util_test.go create mode 100644 otelecho/v5/version.go diff --git a/otelecho/v5/config.go b/otelecho/v5/config.go new file mode 100644 index 0000000..a0e353e --- /dev/null +++ b/otelecho/v5/config.go @@ -0,0 +1,121 @@ +package otelecho + +import ( + "net/http" + + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/middleware" + "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 + MeterProvider metric.MeterProvider + Propagators propagation.TextMapPropagator + Skipper middleware.Skipper + MetricAttributeFn MetricAttributeFn + EchoMetricAttributeFn EchoMetricAttributeFn + OnError OnErrorFn +} + +// MetricAttributeFn is used to extract additional attributes from the http.Request +// and return them as a slice of attribute.KeyValue. +type MetricAttributeFn func(*http.Request) []attribute.KeyValue + +// EchoMetricAttributeFn is used to extract additional attributes from the echo.Context +// and return them as a slice of attribute.KeyValue. +type EchoMetricAttributeFn func(*echo.Context) []attribute.KeyValue + +// OnErrorFn is used to specify how errors are handled in the middleware. +type OnErrorFn func(*echo.Context, error) + +// defaultOnError is the default function called when an error occurs during request processing. +// In Echo v5, errors are handled by the framework's HTTPErrorHandler automatically. +var defaultOnError = func(_ *echo.Context, _ error) { + // In Echo v5, errors are propagated and handled by HTTPErrorHandler +} + +// Option specifies instrumentation configuration options. +type Option interface { + apply(*config) +} + +type optionFunc func(*config) + +func (o optionFunc) apply(c *config) { + o(c) +} + +// WithPropagators specifies propagators to use for extracting +// information from the HTTP requests. If none are specified, global +// ones will be used. +func WithPropagators(propagators propagation.TextMapPropagator) Option { + return optionFunc(func(cfg *config) { + if propagators != nil { + cfg.Propagators = propagators + } + }) +} + +// WithMeterProvider specifies a meter provider to use for creating a meter. +// 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 + } + }) +} + +// WithTracerProvider specifies a tracer provider to use for creating a tracer. +// If none is specified, the global provider is used. +func WithTracerProvider(provider oteltrace.TracerProvider) Option { + return optionFunc(func(cfg *config) { + if provider != nil { + cfg.TracerProvider = provider + } + }) +} + +// WithSkipper specifies a skipper for allowing requests to skip generating spans. +func WithSkipper(skipper middleware.Skipper) Option { + return optionFunc(func(cfg *config) { + cfg.Skipper = skipper + }) +} + +// WithMetricAttributeFn specifies a function that extracts additional attributes from the http.Request +// and returns them as a slice of attribute.KeyValue. +// +// If attributes are duplicated between this method and `WithEchoMetricAttributeFn`, the attributes in this method will be overridden. +func WithMetricAttributeFn(f MetricAttributeFn) Option { + return optionFunc(func(cfg *config) { + cfg.MetricAttributeFn = f + }) +} + +// WithEchoMetricAttributeFn specifies a function that extracts additional attributes from the echo.Context +// and returns them as a slice of attribute.KeyValue. +// +// If attributes are duplicated between this method and `WithMetricAttributeFn`, the attributes in this method will be used. +func WithEchoMetricAttributeFn(f EchoMetricAttributeFn) Option { + return optionFunc(func(cfg *config) { + cfg.EchoMetricAttributeFn = f + }) +} + +// WithOnError specifies a function that is called when an error occurs during request processing. +// +// In Echo v5, errors are automatically handled by the HTTPErrorHandler after the middleware returns. +// This callback allows you to perform additional actions when an error occurs. +func WithOnError(f OnErrorFn) Option { + return optionFunc(func(cfg *config) { + if f != nil { + cfg.OnError = f + } + }) +} diff --git a/otelecho/v5/doc.go b/otelecho/v5/doc.go new file mode 100644 index 0000000..2fae53e --- /dev/null +++ b/otelecho/v5/doc.go @@ -0,0 +1,2 @@ +// Package otelecho provides OpenTelemetry instrumentation for the echo web framework. +package otelecho diff --git a/otelecho/v5/echo.go b/otelecho/v5/echo.go new file mode 100644 index 0000000..9ff4c17 --- /dev/null +++ b/otelecho/v5/echo.go @@ -0,0 +1,186 @@ +package otelecho + +import ( + "errors" + "net/http" + "slices" + "strings" + "time" + + "github.com/labstack/echo-contrib/otelecho/v5/internal/semconv" + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/middleware" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/propagation" + oteltrace "go.opentelemetry.io/otel/trace" +) + +const ( + tracerKey = "labstack-echo-otelecho-tracer" + // ScopeName is the instrumentation scope name. + ScopeName = "github.com/labstack/echo-contrib/otelecho" +) + +// Middleware returns echo middleware which will trace incoming requests. +func Middleware(serverName string, opts ...Option) echo.MiddlewareFunc { + cfg := config{} + for _, opt := range opts { + opt.apply(&cfg) + } + if cfg.TracerProvider == nil { + cfg.TracerProvider = otel.GetTracerProvider() + } + tracer := cfg.TracerProvider.Tracer( + ScopeName, + oteltrace.WithInstrumentationVersion(Version), + ) + if cfg.Propagators == nil { + cfg.Propagators = otel.GetTextMapPropagator() + } + if cfg.MeterProvider == nil { + cfg.MeterProvider = otel.GetMeterProvider() + } + if cfg.Skipper == nil { + cfg.Skipper = middleware.DefaultSkipper + } + if cfg.OnError == nil { + cfg.OnError = defaultOnError + } + + meter := cfg.MeterProvider.Meter( + ScopeName, + metric.WithInstrumentationVersion(Version), + ) + + semconvSrv := semconv.NewHTTPServer(meter) + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + if cfg.Skipper(c) { + return next(c) + } + + requestStartTime := time.Now() + + c.Set(tracerKey, tracer) + request := c.Request() + savedCtx := request.Context() + defer func() { + request = request.WithContext(savedCtx) + c.SetRequest(request) + }() + ctx := cfg.Propagators.Extract(savedCtx, propagation.HeaderCarrier(request.Header)) + opts := []oteltrace.SpanStartOption{ + oteltrace.WithAttributes( + semconvSrv.RequestTraceAttrs(serverName, request, semconv.RequestTraceAttrsOpts{})..., + ), + oteltrace.WithSpanKind(oteltrace.SpanKindServer), + } + if path := c.Path(); path != "" { + rAttr := semconvSrv.Route(path) + opts = append(opts, oteltrace.WithAttributes(rAttr)) + } + spanName := spanNameFormatter(c) + + ctx, span := tracer.Start(ctx, spanName, opts...) + defer span.End() + + // pass the span through the request context + c.SetRequest(request.WithContext(ctx)) + + // serve the request to the next middleware + err := next(c) + if err != nil { + span.SetAttributes(attribute.String("echo.error", err.Error())) + cfg.OnError(c, err) + } + + // Get the response to access Status and Size after the handler chain completes + resp, _ := echo.UnwrapResponse(c.Response()) + + // Determine status code + // In Echo v5, when there's an error, the HTTPErrorHandler hasn't written the response yet, + // so we need to determine the status from the error itself + var status int + var responseSize int64 + + if err != nil { + // Determine status from error + // First try errors.As for wrapped HTTPError + var he *echo.HTTPError + if errors.As(err, &he) { + status = he.Code + } else { + // Fallback to Internal Server Error + status = http.StatusInternalServerError + } + } else if resp != nil { + // No error, use the response status + status = resp.Status + responseSize = resp.Size + } else { + status = http.StatusOK + } + + // Get response size if not already set + if responseSize == 0 && resp != nil { + responseSize = resp.Size + } + + span.SetStatus(semconvSrv.Status(status)) + span.SetAttributes(semconvSrv.ResponseTraceAttrs(semconv.ResponseTelemetry{ + StatusCode: status, + WriteBytes: responseSize, + })...) + + // Record the server-side attributes. + var additionalAttributes []attribute.KeyValue + if path := c.Path(); path != "" { + additionalAttributes = append(additionalAttributes, semconvSrv.Route(path)) + } + if cfg.MetricAttributeFn != nil { + additionalAttributes = append(additionalAttributes, cfg.MetricAttributeFn(request)...) + } + if cfg.EchoMetricAttributeFn != nil { + additionalAttributes = append(additionalAttributes, cfg.EchoMetricAttributeFn(c)...) + } + + semconvSrv.RecordMetrics(ctx, semconv.ServerMetricData{ + ServerName: serverName, + ResponseSize: responseSize, + MetricAttributes: semconv.MetricAttributes{ + Req: request, + StatusCode: status, + AdditionalAttributes: additionalAttributes, + }, + MetricData: semconv.MetricData{ + RequestSize: request.ContentLength, + ElapsedTime: float64(time.Since(requestStartTime)) / float64(time.Millisecond), + }, + }) + + return err + } + } +} + +func spanNameFormatter(c *echo.Context) string { + method, path := strings.ToUpper(c.Request().Method), c.Path() + if !slices.Contains([]string{ + http.MethodGet, http.MethodPost, + http.MethodPut, http.MethodDelete, + http.MethodHead, http.MethodPatch, + http.MethodConnect, http.MethodOptions, + http.MethodTrace, + }, method) { + method = "HTTP" + } + + if path != "" { + return method + " " + path + } + + return method +} diff --git a/otelecho/v5/echo_test.go b/otelecho/v5/echo_test.go new file mode 100644 index 0000000..82766ef --- /dev/null +++ b/otelecho/v5/echo_test.go @@ -0,0 +1,425 @@ +package otelecho + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" + + b3prop "go.opentelemetry.io/contrib/propagators/b3" + + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest" +) + +func TestGetSpanNotInstrumented(t *testing.T) { + router := echo.New() + router.GET("/ping", func(c *echo.Context) error { + // Assert we don't have a span on the context. + span := trace.SpanFromContext(c.Request().Context()) + ok := !span.SpanContext().IsValid() + assert.True(t, ok) + return c.String(http.StatusOK, "ok") + }) + r := httptest.NewRequest(http.MethodGet, "/ping", http.NoBody) + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + response := w.Result() + assert.Equal(t, http.StatusOK, response.StatusCode) +} + +func TestPropagationWithGlobalPropagators(t *testing.T) { + provider := noop.NewTracerProvider() + otel.SetTextMapPropagator(propagation.TraceContext{}) + + r := httptest.NewRequest(http.MethodGet, "/user/123", http.NoBody) + w := httptest.NewRecorder() + + ctx := t.Context() + sc := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{0x01}, + SpanID: trace.SpanID{0x01}, + }) + ctx = trace.ContextWithRemoteSpanContext(ctx, sc) + ctx, _ = provider.Tracer(ScopeName).Start(ctx, "test") + otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(r.Header)) + + router := echo.New() + router.Use(Middleware("foobar", WithTracerProvider(provider))) + router.GET("/user/:id", func(c *echo.Context) error { + span := trace.SpanFromContext(c.Request().Context()) + assert.Equal(t, sc.TraceID(), span.SpanContext().TraceID()) + assert.Equal(t, sc.SpanID(), span.SpanContext().SpanID()) + return c.NoContent(http.StatusOK) + }) + + router.ServeHTTP(w, r) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator()) + assert.Equal(t, http.StatusOK, w.Result().StatusCode, "should call the 'user' handler") +} + +func TestPropagationWithCustomPropagators(t *testing.T) { + provider := noop.NewTracerProvider() + + b3 := b3prop.New() + + r := httptest.NewRequest(http.MethodGet, "/user/123", http.NoBody) + w := httptest.NewRecorder() + + ctx := t.Context() + sc := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{0x01}, + SpanID: trace.SpanID{0x01}, + }) + ctx = trace.ContextWithRemoteSpanContext(ctx, sc) + ctx, _ = provider.Tracer(ScopeName).Start(ctx, "test") + b3.Inject(ctx, propagation.HeaderCarrier(r.Header)) + + router := echo.New() + router.Use(Middleware("foobar", WithTracerProvider(provider), WithPropagators(b3))) + router.GET("/user/:id", func(c *echo.Context) error { + span := trace.SpanFromContext(c.Request().Context()) + assert.Equal(t, sc.TraceID(), span.SpanContext().TraceID()) + assert.Equal(t, sc.SpanID(), span.SpanContext().SpanID()) + return c.NoContent(http.StatusOK) + }) + + router.ServeHTTP(w, r) + assert.Equal(t, http.StatusOK, w.Result().StatusCode, "should call the 'user' handler") +} + +func TestSkipper(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/ping", http.NoBody) + w := httptest.NewRecorder() + + skipper := func(c *echo.Context) bool { + return c.Request().RequestURI == "/ping" + } + + router := echo.New() + router.Use(Middleware("foobar", WithSkipper(skipper))) + router.GET("/ping", func(c *echo.Context) error { + span := trace.SpanFromContext(c.Request().Context()) + assert.False(t, span.SpanContext().HasSpanID()) + assert.False(t, span.SpanContext().HasTraceID()) + return c.NoContent(http.StatusOK) + }) + + router.ServeHTTP(w, r) + assert.Equal(t, http.StatusOK, w.Result().StatusCode, "should call the 'ping' handler") +} + +func TestMetrics(t *testing.T) { + tests := []struct { + name string + metricAttributeExtractor func(*http.Request) []attribute.KeyValue + echoMetricAttributeExtractor func(*echo.Context) []attribute.KeyValue + requestTarget string + wantRouteAttr string + wantStatus int64 + }{ + { + name: "default", + metricAttributeExtractor: nil, + echoMetricAttributeExtractor: nil, + requestTarget: "/user/123", + wantRouteAttr: "/user/:id", + wantStatus: 200, + }, + { + // Note: In Echo v5, when a route is not found, the error type returned + // may not be *echo.HTTPError, so the middleware falls back to 500. + // The actual HTTP response to the client is still 404 (handled by HTTPErrorHandler), + // but the middleware captures the error status before HTTPErrorHandler runs. + name: "request target not exist", + metricAttributeExtractor: nil, + echoMetricAttributeExtractor: nil, + requestTarget: "/abc/123", + wantStatus: 500, + }, + { + name: "with metric attributes callback", + metricAttributeExtractor: func(r *http.Request) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("key1", "value1"), + attribute.String("key2", "value"), + attribute.String("method", strings.ToUpper(r.Method)), + } + }, + echoMetricAttributeExtractor: func(_ *echo.Context) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("key3", "value3"), + } + }, + requestTarget: "/user/123", + wantRouteAttr: "/user/:id", + wantStatus: 200, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + + e := echo.New() + e.Use(Middleware("foobar", + WithMeterProvider(meterProvider), + WithMetricAttributeFn(tt.metricAttributeExtractor), + WithEchoMetricAttributeFn(tt.echoMetricAttributeExtractor), + )) + e.GET("/user/:id", func(c *echo.Context) error { + id := c.Param("id") + assert.Equal(t, "123", id) + return c.String(http.StatusOK, id) + }) + + r := httptest.NewRequest(http.MethodGet, tt.requestTarget, http.NoBody) + w := httptest.NewRecorder() + e.ServeHTTP(w, r) + + // verify metrics + rm := metricdata.ResourceMetrics{} + require.NoError(t, reader.Collect(t.Context(), &rm)) + + require.Len(t, rm.ScopeMetrics, 1) + sm := rm.ScopeMetrics[0] + assert.Equal(t, ScopeName, sm.Scope.Name) + assert.Equal(t, Version, sm.Scope.Version) + + attrs := []attribute.KeyValue{ + attribute.String("http.request.method", "GET"), + attribute.Int64("http.response.status_code", tt.wantStatus), + attribute.String("network.protocol.name", "http"), + attribute.String("network.protocol.version", fmt.Sprintf("1.%d", r.ProtoMinor)), + attribute.String("server.address", "foobar"), + attribute.String("url.scheme", "http"), + } + if tt.wantRouteAttr != "" { + attrs = append(attrs, attribute.String("http.route", tt.wantRouteAttr)) + } + + if tt.metricAttributeExtractor != nil { + attrs = append(attrs, tt.metricAttributeExtractor(r)...) + } + if tt.echoMetricAttributeExtractor != nil { + // In Echo v5, we don't need to create a mock context + // The attributes are already extracted during the actual request + attrs = append(attrs, attribute.String("key3", "value3")) + } + + metricdatatest.AssertEqual(t, metricdata.Metrics{ + Name: "http.server.request.body.size", + Description: "Size of HTTP server request bodies.", + Unit: "By", + Data: metricdata.Histogram[int64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[int64]{ + { + Attributes: attribute.NewSet(attrs...), + }, + }, + }, + }, sm.Metrics[0], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue(), metricdatatest.IgnoreExemplars()) + + metricdatatest.AssertEqual(t, metricdata.Metrics{ + Name: "http.server.response.body.size", + Description: "Size of HTTP server response bodies.", + Unit: "By", + Data: metricdata.Histogram[int64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[int64]{ + { + Attributes: attribute.NewSet(attrs...), + }, + }, + }, + }, sm.Metrics[1], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue(), metricdatatest.IgnoreExemplars()) + + metricdatatest.AssertEqual(t, metricdata.Metrics{ + Name: "http.server.request.duration", + Description: "Duration of HTTP server requests.", + Unit: "s", + Data: metricdata.Histogram[float64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[float64]{ + { + Attributes: attribute.NewSet(attrs...), + }, + }, + }, + }, sm.Metrics[2], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue(), metricdatatest.IgnoreExemplars()) + }) + } +} + +func TestWithMetricAttributeFn(t *testing.T) { + reader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + + e := echo.New() + e.Use(Middleware("test-service", + WithMeterProvider(meterProvider), + WithMetricAttributeFn(func(r *http.Request) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("custom.header", r.Header.Get("X-Test-Header")), + } + }), + )) + + e.GET("/test", func(c *echo.Context) error { + return c.String(http.StatusOK, "test response") + }) + + r := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + r.Header.Set("X-Test-Header", "test-value") + w := httptest.NewRecorder() + e.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Result().StatusCode) + + // verify metrics + rm := metricdata.ResourceMetrics{} + require.NoError(t, reader.Collect(t.Context(), &rm)) + require.Len(t, rm.ScopeMetrics, 1) + sm := rm.ScopeMetrics[0] + require.Len(t, sm.Metrics, 3) + + // Check that custom attribute is present + found := false + for _, metric := range sm.Metrics { + if metric.Name == "http.server.request.duration" { + histogram := metric.Data.(metricdata.Histogram[float64]) + require.Len(t, histogram.DataPoints, 1) + attrs := histogram.DataPoints[0].Attributes.ToSlice() + for _, attr := range attrs { + if attr.Key == "custom.header" && attr.Value.AsString() == "test-value" { + found = true + break + } + } + } + } + assert.True(t, found, "custom attribute should be found in metrics") +} + +func TestWithEchoMetricAttributeFn(t *testing.T) { + reader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + + e := echo.New() + e.Use(Middleware("test-service", + WithMeterProvider(meterProvider), + WithEchoMetricAttributeFn(func(c *echo.Context) []attribute.KeyValue { + return []attribute.KeyValue{ + // This is just for testing. Avoid high cardinality metrics such as "id" in production code + attribute.String("echo.param.id", c.Param("id")), + attribute.String("echo.path", c.Path()), + } + }), + )) + + e.GET("/user/:id", func(c *echo.Context) error { + return c.String(http.StatusOK, "user: "+c.Param("id")) + }) + + r := httptest.NewRequest(http.MethodGet, "/user/456", http.NoBody) + w := httptest.NewRecorder() + e.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Result().StatusCode) + + // verify metrics + rm := metricdata.ResourceMetrics{} + require.NoError(t, reader.Collect(t.Context(), &rm)) + require.Len(t, rm.ScopeMetrics, 1) + sm := rm.ScopeMetrics[0] + require.Len(t, sm.Metrics, 3) + + // Check that custom attributes are present + foundID := false + foundPath := false + for _, metric := range sm.Metrics { + if metric.Name == "http.server.request.duration" { + histogram := metric.Data.(metricdata.Histogram[float64]) + require.Len(t, histogram.DataPoints, 1) + attrs := histogram.DataPoints[0].Attributes.ToSlice() + for _, attr := range attrs { + if attr.Key == "echo.param.id" && attr.Value.AsString() == "456" { + foundID = true + } + if attr.Key == "echo.path" && attr.Value.AsString() == "/user/:id" { + foundPath = true + } + } + } + } + assert.True(t, foundID, "echo param id attribute should be found") + assert.True(t, foundPath, "echo path attribute should be found") +} + +func TestWithOnError(t *testing.T) { + tests := []struct { + name string + opt Option + wantHandlerCalled int + }{ + { + name: "without WithOnError option (default)", + opt: nil, + wantHandlerCalled: 1, + }, + { + name: "nil WithOnError option", + opt: WithOnError(nil), + wantHandlerCalled: 1, + }, + { + name: "custom onError logging only", + opt: WithOnError(func(_ *echo.Context, err error) { + t.Logf("Inside custom OnError: %v", err) + }), + wantHandlerCalled: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/ping", http.NoBody) + w := httptest.NewRecorder() + + router := echo.New() + if tt.opt != nil { + router.Use(Middleware("foobar", tt.opt)) + } else { + router.Use(Middleware("foobar")) + } + + router.GET("/ping", func(_ *echo.Context) error { + return assert.AnError + }) + + handlerCalled := 0 + router.HTTPErrorHandler = func(c *echo.Context, err error) { + handlerCalled++ + assert.ErrorIs(t, err, assert.AnError, "test error is expected in error handler") + assert.NoError(t, c.NoContent(http.StatusTeapot)) + } + + router.ServeHTTP(w, r) + assert.Equal(t, http.StatusTeapot, w.Result().StatusCode, "should call the 'ping' handler") + assert.Equal(t, tt.wantHandlerCalled, handlerCalled, "handler called times mismatch") + }) + } +} diff --git a/otelecho/v5/go.mod b/otelecho/v5/go.mod new file mode 100644 index 0000000..b1e14b7 --- /dev/null +++ b/otelecho/v5/go.mod @@ -0,0 +1,27 @@ +module github.com/labstack/echo-contrib/otelecho/v5 + +go 1.25.0 + +require ( + github.com/labstack/echo/v5 v5.0.0 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/propagators/b3 v1.39.0 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/metric v1.39.0 + go.opentelemetry.io/otel/sdk/metric v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/time v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/otelecho/v5/go.sum b/otelecho/v5/go.sum new file mode 100644 index 0000000..1e4c48b --- /dev/null +++ b/otelecho/v5/go.sum @@ -0,0 +1,52 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v5 v5.0.0 h1:JHKGrI0cbNsNMyKvranuY0C94O4hSM7yc/HtwcV3Na4= +github.com/labstack/echo/v5 v5.0.0/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk= +go.opentelemetry.io/contrib/propagators/b3 v1.39.0/go.mod h1:5gV/EzPnfYIwjzj+6y8tbGW2PKWhcsz5e/7twptRVQY= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/otelecho/v5/internal/semconv/server.go b/otelecho/v5/internal/semconv/server.go new file mode 100644 index 0000000..110c12c --- /dev/null +++ b/otelecho/v5/internal/semconv/server.go @@ -0,0 +1,403 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/semconv/server.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package semconv provides OpenTelemetry semantic convention types and +// functionality. +package semconv + +import ( + "context" + "fmt" + "net/http" + "slices" + "strings" + "sync" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + semconv "go.opentelemetry.io/otel/semconv/v1.37.0" + "go.opentelemetry.io/otel/semconv/v1.37.0/httpconv" +) + +type RequestTraceAttrsOpts struct { + // If set, this is used as value for the "http.client_ip" attribute. + HTTPClientIP string +} + +type ResponseTelemetry struct { + StatusCode int + ReadBytes int64 + ReadError error + WriteBytes int64 + WriteError error +} + +type HTTPServer struct { + requestBodySizeHistogram httpconv.ServerRequestBodySize + responseBodySizeHistogram httpconv.ServerResponseBodySize + requestDurationHistogram httpconv.ServerRequestDuration +} + +func NewHTTPServer(meter metric.Meter) HTTPServer { + server := HTTPServer{} + + var err error + server.requestBodySizeHistogram, err = httpconv.NewServerRequestBodySize(meter) + handleErr(err) + + server.responseBodySizeHistogram, err = httpconv.NewServerResponseBodySize(meter) + handleErr(err) + + server.requestDurationHistogram, err = httpconv.NewServerRequestDuration( + meter, + metric.WithExplicitBucketBoundaries( + 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, + 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, + ), + ) + handleErr(err) + return server +} + +// Status returns a span status code and message for an HTTP status code +// value returned by a server. Status codes in the 400-499 range are not +// returned as errors. +func (n HTTPServer) Status(code int) (codes.Code, string) { + if code < 100 || code >= 600 { + return codes.Error, fmt.Sprintf("Invalid HTTP status code %d", code) + } + if code >= 500 { + return codes.Error, "" + } + return codes.Unset, "" +} + +// RequestTraceAttrs returns trace attributes for an HTTP request received by a +// server. +// +// The server must be the primary server name if it is known. For example this +// would be the ServerName directive +// (https://httpd.apache.org/docs/2.4/mod/core.html#servername) for an Apache +// server, and the server_name directive +// (http://nginx.org/en/docs/http/ngx_http_core_module.html#server_name) for an +// nginx server. More generically, the primary server name would be the host +// header value that matches the default virtual host of an HTTP server. It +// should include the host identifier and if a port is used to route to the +// server that port identifier should be included as an appropriate port +// suffix. +// +// If the primary server name is not known, server should be an empty string. +// The req Host will be used to determine the server instead. +func (n HTTPServer) RequestTraceAttrs(server string, req *http.Request, opts RequestTraceAttrsOpts) []attribute.KeyValue { + count := 3 // ServerAddress, Method, Scheme + + var host string + var p int + if server == "" { + host, p = SplitHostPort(req.Host) + } else { + // Prioritize the primary server name. + host, p = SplitHostPort(server) + if p < 0 { + _, p = SplitHostPort(req.Host) + } + } + + hostPort := requiredHTTPPort(req.TLS != nil, p) + if hostPort > 0 { + count++ + } + + method, methodOriginal := n.method(req.Method) + if methodOriginal != (attribute.KeyValue{}) { + count++ + } + + scheme := n.scheme(req.TLS != nil) + + peer, peerPort := SplitHostPort(req.RemoteAddr) + if peer != "" { + // The Go HTTP server sets RemoteAddr to "IP:port", this will not be a + // file-path that would be interpreted with a sock family. + count++ + if peerPort > 0 { + count++ + } + } + + useragent := req.UserAgent() + if useragent != "" { + count++ + } + + // For client IP, use, in order: + // 1. The value passed in the options + // 2. The value in the X-Forwarded-For header + // 3. The peer address + clientIP := opts.HTTPClientIP + if clientIP == "" { + clientIP = serverClientIP(req.Header.Get("X-Forwarded-For")) + if clientIP == "" { + clientIP = peer + } + } + if clientIP != "" { + count++ + } + + if req.URL != nil && req.URL.Path != "" { + count++ + } + + protoName, protoVersion := netProtocol(req.Proto) + if protoName != "" && protoName != "http" { + count++ + } + if protoVersion != "" { + count++ + } + + route := httpRoute(req.Pattern) + if route != "" { + count++ + } + + attrs := make([]attribute.KeyValue, 0, count) + attrs = append(attrs, + semconv.ServerAddress(host), + method, + scheme, + ) + + if hostPort > 0 { + attrs = append(attrs, semconv.ServerPort(hostPort)) + } + if methodOriginal != (attribute.KeyValue{}) { + attrs = append(attrs, methodOriginal) + } + + if peer, peerPort := SplitHostPort(req.RemoteAddr); peer != "" { + // The Go HTTP server sets RemoteAddr to "IP:port", this will not be a + // file-path that would be interpreted with a sock family. + attrs = append(attrs, semconv.NetworkPeerAddress(peer)) + if peerPort > 0 { + attrs = append(attrs, semconv.NetworkPeerPort(peerPort)) + } + } + + if useragent != "" { + attrs = append(attrs, semconv.UserAgentOriginal(useragent)) + } + + if clientIP != "" { + attrs = append(attrs, semconv.ClientAddress(clientIP)) + } + + if req.URL != nil && req.URL.Path != "" { + attrs = append(attrs, semconv.URLPath(req.URL.Path)) + } + + if protoName != "" && protoName != "http" { + attrs = append(attrs, semconv.NetworkProtocolName(protoName)) + } + if protoVersion != "" { + attrs = append(attrs, semconv.NetworkProtocolVersion(protoVersion)) + } + + if route != "" { + attrs = append(attrs, n.Route(route)) + } + + return attrs +} + +func (s HTTPServer) NetworkTransportAttr(network string) []attribute.KeyValue { + attr := semconv.NetworkTransportPipe + switch network { + case "tcp", "tcp4", "tcp6": + attr = semconv.NetworkTransportTCP + case "udp", "udp4", "udp6": + attr = semconv.NetworkTransportUDP + case "unix", "unixgram", "unixpacket": + attr = semconv.NetworkTransportUnix + } + + return []attribute.KeyValue{attr} +} + +type ServerMetricData struct { + ServerName string + ResponseSize int64 + + MetricData + MetricAttributes +} + +type MetricAttributes struct { + Req *http.Request + StatusCode int + Route string + AdditionalAttributes []attribute.KeyValue +} + +type MetricData struct { + RequestSize int64 + + // The request duration, in milliseconds + ElapsedTime float64 +} + +var ( + metricAddOptionPool = &sync.Pool{ + New: func() any { + return &[]metric.AddOption{} + }, + } + + metricRecordOptionPool = &sync.Pool{ + New: func() any { + return &[]metric.RecordOption{} + }, + } +) + +func (n HTTPServer) RecordMetrics(ctx context.Context, md ServerMetricData) { + attributes := n.MetricAttributes(md.ServerName, md.Req, md.StatusCode, md.Route, md.AdditionalAttributes) + o := metric.WithAttributeSet(attribute.NewSet(attributes...)) + recordOpts := metricRecordOptionPool.Get().(*[]metric.RecordOption) + *recordOpts = append(*recordOpts, o) + n.requestBodySizeHistogram.Inst().Record(ctx, md.RequestSize, *recordOpts...) + n.responseBodySizeHistogram.Inst().Record(ctx, md.ResponseSize, *recordOpts...) + n.requestDurationHistogram.Inst().Record(ctx, md.ElapsedTime/1000.0, o) + *recordOpts = (*recordOpts)[:0] + metricRecordOptionPool.Put(recordOpts) +} + +func (n HTTPServer) method(method string) (attribute.KeyValue, attribute.KeyValue) { + if method == "" { + return semconv.HTTPRequestMethodGet, attribute.KeyValue{} + } + if attr, ok := methodLookup[method]; ok { + return attr, attribute.KeyValue{} + } + + orig := semconv.HTTPRequestMethodOriginal(method) + if attr, ok := methodLookup[strings.ToUpper(method)]; ok { + return attr, orig + } + return semconv.HTTPRequestMethodGet, orig +} + +func (n HTTPServer) scheme(https bool) attribute.KeyValue { //nolint:revive // ignore linter + if https { + return semconv.URLScheme("https") + } + return semconv.URLScheme("http") +} + +// ResponseTraceAttrs returns trace attributes for telemetry from an HTTP +// response. +// +// If any of the fields in the ResponseTelemetry are not set the attribute will +// be omitted. +func (n HTTPServer) ResponseTraceAttrs(resp ResponseTelemetry) []attribute.KeyValue { + var count int + + if resp.ReadBytes > 0 { + count++ + } + if resp.WriteBytes > 0 { + count++ + } + if resp.StatusCode > 0 { + count++ + } + + attributes := make([]attribute.KeyValue, 0, count) + + if resp.ReadBytes > 0 { + attributes = append(attributes, + semconv.HTTPRequestBodySize(int(resp.ReadBytes)), + ) + } + if resp.WriteBytes > 0 { + attributes = append(attributes, + semconv.HTTPResponseBodySize(int(resp.WriteBytes)), + ) + } + if resp.StatusCode > 0 { + attributes = append(attributes, + semconv.HTTPResponseStatusCode(resp.StatusCode), + ) + } + + return attributes +} + +// Route returns the attribute for the route. +func (n HTTPServer) Route(route string) attribute.KeyValue { + return semconv.HTTPRoute(route) +} + +func (n HTTPServer) MetricAttributes(server string, req *http.Request, statusCode int, route string, additionalAttributes []attribute.KeyValue) []attribute.KeyValue { + num := len(additionalAttributes) + 3 + var host string + var p int + if server == "" { + host, p = SplitHostPort(req.Host) + } else { + // Prioritize the primary server name. + host, p = SplitHostPort(server) + if p < 0 { + _, p = SplitHostPort(req.Host) + } + } + hostPort := requiredHTTPPort(req.TLS != nil, p) + if hostPort > 0 { + num++ + } + protoName, protoVersion := netProtocol(req.Proto) + if protoName != "" { + num++ + } + if protoVersion != "" { + num++ + } + + if statusCode > 0 { + num++ + } + + if route != "" { + num++ + } + + attributes := slices.Grow(additionalAttributes, num) + attributes = append(attributes, + semconv.HTTPRequestMethodKey.String(standardizeHTTPMethod(req.Method)), + n.scheme(req.TLS != nil), + semconv.ServerAddress(host)) + + if hostPort > 0 { + attributes = append(attributes, semconv.ServerPort(hostPort)) + } + if protoName != "" { + attributes = append(attributes, semconv.NetworkProtocolName(protoName)) + } + if protoVersion != "" { + attributes = append(attributes, semconv.NetworkProtocolVersion(protoVersion)) + } + + if statusCode > 0 { + attributes = append(attributes, semconv.HTTPResponseStatusCode(statusCode)) + } + + if route != "" { + attributes = append(attributes, semconv.HTTPRoute(route)) + } + return attributes +} diff --git a/otelecho/v5/internal/semconv/server_test.go b/otelecho/v5/internal/semconv/server_test.go new file mode 100644 index 0000000..e7a8b1a --- /dev/null +++ b/otelecho/v5/internal/semconv/server_test.go @@ -0,0 +1,204 @@ +package semconv + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/otel/attribute" +) + +func TestHTTPServer_MetricAttributes(t *testing.T) { + defaultRequest, err := http.NewRequest("GET", "http://example.com/path?query=test", http.NoBody) + require.NoError(t, err) + + tests := []struct { + name string + server string + req *http.Request + statusCode int + route string + additionalAttributes []attribute.KeyValue + wantFunc func(t *testing.T, attrs []attribute.KeyValue) + }{ + { + name: "routine testing", + server: "", + req: defaultRequest, + statusCode: 200, + route: "", + additionalAttributes: []attribute.KeyValue{attribute.String("test", "test")}, + wantFunc: func(t *testing.T, attrs []attribute.KeyValue) { + require.Len(t, attrs, 7) + assert.ElementsMatch(t, []attribute.KeyValue{ + attribute.String("http.request.method", "GET"), + attribute.String("url.scheme", "http"), + attribute.String("server.address", "example.com"), + attribute.String("network.protocol.name", "http"), + attribute.String("network.protocol.version", "1.1"), + attribute.Int64("http.response.status_code", 200), + attribute.String("test", "test"), + }, attrs) + }, + }, + { + name: "use server address", + server: "example.com:9999", + req: defaultRequest, + statusCode: 200, + route: "/path/${id}", + additionalAttributes: nil, + wantFunc: func(t *testing.T, attrs []attribute.KeyValue) { + require.Len(t, attrs, 8) + assert.ElementsMatch(t, []attribute.KeyValue{ + attribute.String("http.request.method", "GET"), + attribute.String("url.scheme", "http"), + attribute.String("server.address", "example.com"), + attribute.Int("server.port", 9999), + attribute.String("network.protocol.name", "http"), + attribute.String("network.protocol.version", "1.1"), + attribute.Int64("http.response.status_code", 200), + attribute.String("http.route", "/path/${id}"), + }, attrs) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HTTPServer{}.MetricAttributes(tt.server, tt.req, tt.statusCode, tt.route, tt.additionalAttributes) + tt.wantFunc(t, got) + }) + } +} + +func TestNewMethod(t *testing.T) { + testCases := []struct { + method string + n int + want attribute.KeyValue + wantOrig attribute.KeyValue + }{ + { + method: http.MethodPost, + n: 1, + want: attribute.String("http.request.method", "POST"), + }, + { + method: "Put", + n: 2, + want: attribute.String("http.request.method", "PUT"), + wantOrig: attribute.String("http.request.method_original", "Put"), + }, + { + method: "Unknown", + n: 2, + want: attribute.String("http.request.method", "GET"), + wantOrig: attribute.String("http.request.method_original", "Unknown"), + }, + } + + for _, tt := range testCases { + t.Run(tt.method, func(t *testing.T) { + got, gotOrig := HTTPServer{}.method(tt.method) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantOrig, gotOrig) + }) + } +} + +func TestRequestTraceAttrs_HTTPRoute(t *testing.T) { + tests := []struct { + name string + pattern string + wantRoute string + }{ + { + name: "only path", + pattern: "/path/{id}", + wantRoute: "/path/{id}", + }, + { + name: "with method", + pattern: "GET /path/{id}", + wantRoute: "/path/{id}", + }, + { + name: "with domain", + pattern: "example.com/path/{id}", + wantRoute: "/path/{id}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/path/abc123", http.NoBody) + req.Pattern = tt.pattern + + attrs := (HTTPServer{}).RequestTraceAttrs("", req, RequestTraceAttrsOpts{}) + + var gotRoute string + for _, attr := range attrs { + if attr.Key == "http.route" { + gotRoute = attr.Value.AsString() + break + } + } + require.Equal(t, tt.wantRoute, gotRoute) + }) + } +} + +func TestRequestTraceAttrs_ClientIP(t *testing.T) { + for _, tt := range []struct { + name string + requestModifierFn func(r *http.Request) + requestTraceOpts RequestTraceAttrsOpts + + wantClientIP string + }{ + { + name: "with a client IP from the network", + wantClientIP: "1.2.3.4", + }, + { + name: "with a client IP from x-forwarded-for header", + requestModifierFn: func(r *http.Request) { + r.Header.Add("X-Forwarded-For", "5.6.7.8") + }, + wantClientIP: "5.6.7.8", + }, + { + name: "with a client IP in options", + requestModifierFn: func(r *http.Request) { + r.Header.Add("X-Forwarded-For", "5.6.7.8") + }, + requestTraceOpts: RequestTraceAttrsOpts{ + HTTPClientIP: "9.8.7.6", + }, + wantClientIP: "9.8.7.6", + }, + } { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/example", http.NoBody) + req.RemoteAddr = "1.2.3.4:5678" + + if tt.requestModifierFn != nil { + tt.requestModifierFn(req) + } + + var found bool + for _, attr := range (HTTPServer{}).RequestTraceAttrs("", req, tt.requestTraceOpts) { + if attr.Key != "client.address" { + continue + } + found = true + assert.Equal(t, tt.wantClientIP, attr.Value.AsString()) + } + require.True(t, found) + }) + } +} diff --git a/otelecho/v5/internal/semconv/util.go b/otelecho/v5/internal/semconv/util.go new file mode 100644 index 0000000..92c2fd8 --- /dev/null +++ b/otelecho/v5/internal/semconv/util.go @@ -0,0 +1,121 @@ +package semconv + +import ( + "net" + "net/http" + "strconv" + "strings" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + semconvNew "go.opentelemetry.io/otel/semconv/v1.37.0" +) + +// SplitHostPort splits a network address hostport of the form "host", +// "host%zone", "[host]", "[host%zone], "host:port", "host%zone:port", +// "[host]:port", "[host%zone]:port", or ":port" into host or host%zone and +// port. +// +// An empty host is returned if it is not provided or unparsable. A negative +// port is returned if it is not provided or unparsable. +func SplitHostPort(hostport string) (host string, port int) { + port = -1 + + if strings.HasPrefix(hostport, "[") { + addrEnd := strings.LastIndexByte(hostport, ']') + if addrEnd < 0 { + // Invalid hostport. + return + } + if i := strings.LastIndexByte(hostport[addrEnd:], ':'); i < 0 { + host = hostport[1:addrEnd] + return + } + } else { + if i := strings.LastIndexByte(hostport, ':'); i < 0 { + host = hostport + return + } + } + + host, pStr, err := net.SplitHostPort(hostport) + if err != nil { + return + } + + p, err := strconv.ParseUint(pStr, 10, 16) + if err != nil { + return + } + return host, int(p) //nolint:gosec // Byte size checked 16 above. +} + +func requiredHTTPPort(https bool, port int) int { //nolint:revive // ignore linter + if https { + if port > 0 && port != 443 { + return port + } + } else { + if port > 0 && port != 80 { + return port + } + } + return -1 +} + +func serverClientIP(xForwardedFor string) string { + if idx := strings.IndexByte(xForwardedFor, ','); idx >= 0 { + xForwardedFor = xForwardedFor[:idx] + } + return xForwardedFor +} + +func httpRoute(pattern string) string { + if idx := strings.IndexByte(pattern, '/'); idx >= 0 { + return pattern[idx:] + } + return "" +} + +func netProtocol(proto string) (name string, version string) { + name, version, _ = strings.Cut(proto, "/") + switch name { + case "HTTP": + name = "http" + case "QUIC": + name = "quic" + case "SPDY": + name = "spdy" + default: + name = strings.ToLower(name) + } + return name, version +} + +var methodLookup = map[string]attribute.KeyValue{ + http.MethodConnect: semconvNew.HTTPRequestMethodConnect, + http.MethodDelete: semconvNew.HTTPRequestMethodDelete, + http.MethodGet: semconvNew.HTTPRequestMethodGet, + http.MethodHead: semconvNew.HTTPRequestMethodHead, + http.MethodOptions: semconvNew.HTTPRequestMethodOptions, + http.MethodPatch: semconvNew.HTTPRequestMethodPatch, + http.MethodPost: semconvNew.HTTPRequestMethodPost, + http.MethodPut: semconvNew.HTTPRequestMethodPut, + http.MethodTrace: semconvNew.HTTPRequestMethodTrace, +} + +func handleErr(err error) { + if err != nil { + otel.Handle(err) + } +} + +func standardizeHTTPMethod(method string) string { + method = strings.ToUpper(method) + switch method { + case http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPatch, http.MethodPost, http.MethodPut, http.MethodTrace: + default: + method = "_OTHER" + } + return method +} diff --git a/otelecho/v5/internal/semconv/util_test.go b/otelecho/v5/internal/semconv/util_test.go new file mode 100644 index 0000000..dc8d13c --- /dev/null +++ b/otelecho/v5/internal/semconv/util_test.go @@ -0,0 +1,69 @@ +package semconv + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitHostPort(t *testing.T) { + tests := []struct { + hostport string + host string + port int + }{ + {"", "", -1}, + {":8080", "", 8080}, + {"127.0.0.1", "127.0.0.1", -1}, + {"www.example.com", "www.example.com", -1}, + {"127.0.0.1%25en0", "127.0.0.1%25en0", -1}, + {"[]", "", -1}, // Ensure this doesn't panic. + {"[fe80::1", "", -1}, + {"[fe80::1]", "fe80::1", -1}, + {"[fe80::1%25en0]", "fe80::1%25en0", -1}, + {"[fe80::1]:8080", "fe80::1", 8080}, + {"[fe80::1]::", "", -1}, // Too many colons. + {"127.0.0.1:", "127.0.0.1", -1}, + {"127.0.0.1:port", "127.0.0.1", -1}, + {"127.0.0.1:8080", "127.0.0.1", 8080}, + {"www.example.com:8080", "www.example.com", 8080}, + {"127.0.0.1%25en0:8080", "127.0.0.1%25en0", 8080}, + } + + for _, test := range tests { + h, p := SplitHostPort(test.hostport) + assert.Equal(t, test.host, h, test.hostport) + assert.Equal(t, test.port, p, test.hostport) + } +} + +func TestStandardizeHTTPMethod(t *testing.T) { + tests := []struct { + method string + want string + }{ + {"GET", "GET"}, + {"get", "GET"}, + {"POST", "POST"}, + {"post", "POST"}, + {"PUT", "PUT"}, + {"put", "PUT"}, + {"DELETE", "DELETE"}, + {"delete", "DELETE"}, + {"HEAD", "HEAD"}, + {"head", "HEAD"}, + {"OPTIONS", "OPTIONS"}, + {"options", "OPTIONS"}, + {"CONNECT", "CONNECT"}, + {"connect", "CONNECT"}, + {"TRACE", "TRACE"}, + {"trace", "TRACE"}, + {"PATCH", "PATCH"}, + {"patch", "PATCH"}, + {"unknown", "_OTHER"}, + {"", "_OTHER"}, + } + for _, test := range tests { + assert.Equal(t, test.want, standardizeHTTPMethod(test.method)) + } +} diff --git a/otelecho/v5/version.go b/otelecho/v5/version.go new file mode 100644 index 0000000..513ef4f --- /dev/null +++ b/otelecho/v5/version.go @@ -0,0 +1,4 @@ +package otelecho + +// Version is the current release version of the echo instrumentation. +const Version = "5.0.0"