Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
121 changes: 121 additions & 0 deletions otelecho/v5/config.go
Original file line number Diff line number Diff line change
@@ -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
}
})
}
2 changes: 2 additions & 0 deletions otelecho/v5/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package otelecho provides OpenTelemetry instrumentation for the echo web framework.
package otelecho
186 changes: 186 additions & 0 deletions otelecho/v5/echo.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading