diff --git a/middleware/helpers.go b/middleware/helpers.go index d0c46bf..2789a45 100644 --- a/middleware/helpers.go +++ b/middleware/helpers.go @@ -3,6 +3,7 @@ package middleware import ( "context" "net/url" + "reflect" "regexp" "strings" @@ -18,6 +19,109 @@ var uuidPattern = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{ // internalServicePattern matches Lerian internal service user-agent strings. var internalServicePattern = regexp.MustCompile(`^[\w-]+/[\d.]+\s+LerianStudio$`) +// knownHTTPMethods is the canonical case-sensitive set per OpenTelemetry +// HTTP semantic conventions; methods outside this set are reported as +// "_OTHER" on telemetry to keep label cardinality bounded. +var knownHTTPMethods = map[string]struct{}{ + "GET": {}, "HEAD": {}, "POST": {}, "PUT": {}, "DELETE": {}, + "CONNECT": {}, "OPTIONS": {}, "TRACE": {}, "PATCH": {}, +} + +// normalizeHTTPMethod returns the canonical method label and, if a +// substitution happened, the original verb. Comparison is intentionally +// case-sensitive: compliant clients send uppercase, and lowercase variants +// genuinely belong in "_OTHER". +func normalizeHTTPMethod(raw string) (normalized, original string, replaced bool) { + if _, ok := knownHTTPMethods[raw]; ok { + return raw, "", false + } + + return "_OTHER", raw, true +} + +// routeAttribute returns the route template suitable for the http.route +// telemetry attribute, plus a present flag. Fiber v2 exposes Route().Path +// == "/" for unmatched requests (its default catch-all), which would +// conflate scanner/404 traffic with the actual root handler in dashboards. +// We detect this case (effective status == 404 AND route == "/" AND the +// request path is NOT "/") and report the attribute as absent so callers +// can omit it entirely, matching OTel guidance that http.route SHOULD be +// absent when no route matched. +func routeAttribute(c *fiber.Ctx, effectiveStatus int) (string, bool) { + if c == nil { + return "", false + } + + r := c.Route() + if r == nil { + return "", false + } + + if effectiveStatus == fiber.StatusNotFound && r.Path == "/" && c.Path() != "/" { + return "", false + } + + return r.Path, true +} + +// maxUserAgentAttrLen caps the user_agent.original span attribute to avoid +// inflating trace storage/index cost. 256 bytes is sufficient for canonical +// client/library/version identifiers in practice. +const maxUserAgentAttrLen = 256 + +// truncateUserAgent caps the user-agent string at maxUserAgentAttrLen bytes, +// truncating at a rune boundary so the returned string is always valid UTF-8. +// Compliant user-agents are ASCII, but defensive callers may receive +// multi-byte sequences; a byte-level slice could leave a partial rune in the +// span attribute. +func truncateUserAgent(ua string) string { + if len(ua) <= maxUserAgentAttrLen { + return ua + } + + // for i := range ua iterates over rune boundaries; i is the byte index + // at the start of each rune. We track the last boundary that still fits + // within the cap and return up to it, so the result never exceeds + // maxUserAgentAttrLen bytes and never splits a rune. + lastFit := 0 + + for i := range ua { + if i > maxUserAgentAttrLen { + return ua[:lastFit] + } + + lastFit = i + } + + return ua[:lastFit] +} + +// errorTypeOriginal returns the originating Go type name of handlerErr, +// suitable as a high-cardinality debugging attribute on spans. Returns +// "" if handlerErr is nil. Unwraps all pointer levels so "***fiber.Error" +// surfaces as "fiber.Error". Falls back to "error" when reflect cannot +// resolve a meaningful name. +func errorTypeOriginal(handlerErr error) string { + if handlerErr == nil { + return "" + } + + t := reflect.TypeOf(handlerErr) + if t == nil { + return "error" + } + + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + if name := t.String(); name != "" { + return name + } + + return "error" +} + // isRouteExcludedFromList reports whether the request path matches any // excluded route on a path-segment boundary. A route matches when the // path equals it exactly or starts with "route + /", so "/health" excludes diff --git a/middleware/telemetry.go b/middleware/telemetry.go index 9d47fe3..21a0dc3 100644 --- a/middleware/telemetry.go +++ b/middleware/telemetry.go @@ -7,8 +7,10 @@ import ( "errors" "fmt" "reflect" + "strconv" "strings" "sync" + "time" observability "github.com/LerianStudio/lib-observability" "github.com/LerianStudio/lib-observability/tracing" @@ -16,12 +18,47 @@ import ( "github.com/google/uuid" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" "google.golang.org/grpc" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) +// httpServerRequestDurationMetric is the OpenTelemetry semantic-convention metric name +// for HTTP server request duration. Recorded as a Float64 histogram in seconds. +const httpServerRequestDurationMetric = "http.server.request.duration" + +// httpServerDurationBuckets follows the current OpenTelemetry HTTP semantic +// conventions advisory layout for http.server.request.duration. Update only +// in lockstep with the spec. +var httpServerDurationBuckets = []float64{ + 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, +} + +// newHTTPServerDurationHistogram builds the float64 histogram instrument for +// http.server.request.duration on the given meter. Returns nil if the meter is +// nil or instrument creation fails - callers must treat nil as "do not record". +func newHTTPServerDurationHistogram(meter metric.Meter) metric.Float64Histogram { + if meter == nil { + return nil + } + + hist, err := meter.Float64Histogram( + httpServerRequestDurationMetric, + metric.WithUnit("s"), + metric.WithDescription("Duration of HTTP server requests."), + metric.WithExplicitBucketBoundaries(httpServerDurationBuckets...), + ) + if err != nil { + return nil + } + + return hist +} + // Header and metadata key constants used by the middleware. const ( // headerID is the request identifier header key. @@ -83,7 +120,41 @@ func NewTelemetryMiddleware(tl *tracing.Telemetry) *TelemetryMiddleware { } // WithTelemetry is a middleware that adds tracing to the context. +// +// When the effective Telemetry has a non-nil MeterProvider AND a non-nil +// MetricsFactory, the middleware also records the OpenTelemetry semantic- +// convention HTTP server metric `http.server.request.duration` (Float64 seconds +// histogram) for every non-excluded request. Recording is best-effort: nil +// telemetry, nil MeterProvider, nil MetricsFactory, excluded routes, and +// instrument creation errors all silently skip the metric without affecting +// the request path or existing span behavior. func (tm *TelemetryMiddleware) WithTelemetry(tl *tracing.Telemetry, excludedRoutes ...string) fiber.Handler { + // Build the duration histogram once at handler-construction time. The + // effective Telemetry may be supplied either via the explicit `tl` argument + // or via the receiver's stored Telemetry, mirroring the per-request logic + // below. If neither resolves, or any required component is nil, the + // histogram is left nil and recording is skipped. + var durationHistogram metric.Float64Histogram + + bootstrapTelemetry := tl + if bootstrapTelemetry == nil && tm != nil { + bootstrapTelemetry = tm.Telemetry + } + + // MetricsFactory presence is used here as the canonical "metrics subsystem + // enabled" signal across this library, even though the histogram itself is + // built directly from MeterProvider below. Keeping this gate aligned with + // the rest of the metrics package (see metrics/doc.go) ensures callers that + // disable metrics by nil-ing MetricsFactory also stop receiving the duration + // histogram, without us needing a separate enablement flag. + if bootstrapTelemetry != nil && + bootstrapTelemetry.MeterProvider != nil && + bootstrapTelemetry.MetricsFactory != nil { + durationHistogram = newHTTPServerDurationHistogram( + bootstrapTelemetry.MeterProvider.Meter(bootstrapTelemetry.LibraryName), + ) + } + return func(c *fiber.Ctx) error { effectiveTelemetry := tl if effectiveTelemetry == nil && tm != nil { @@ -100,6 +171,11 @@ func (tm *TelemetryMiddleware) WithTelemetry(tl *tracing.Telemetry, excludedRout setRequestHeaderID(c) + // Capture the request start time before any downstream work so the + // duration metric reflects the full handler chain, regardless of + // whether tracing is enabled below. + requestStart := time.Now() + ctx := c.UserContext() _, _, reqId, _ := observability.NewTrackingFromContext(ctx) @@ -107,16 +183,22 @@ func (tm *TelemetryMiddleware) WithTelemetry(tl *tracing.Telemetry, excludedRout attribute.String("app.request.request_id", reqId), )) - if effectiveTelemetry.TracerProvider == nil { - return c.Next() - } - // Capture all Fiber context string values BEFORE c.Next(). Fiber v2 uses // utils.UnsafeString which returns pointers into fasthttp's request buffer. // After c.Next() returns, fasthttp may recycle the underlying RequestCtx // for the next connection, corrupting any previously returned string slices. // Safe copies via string([]byte(...)) ensure the data is heap-owned. - method := string([]byte(c.Method())) + rawMethod := string([]byte(c.Method())) + method, methodOriginal, methodReplaced := normalizeHTTPMethod(rawMethod) + + if effectiveTelemetry.TracerProvider == nil { + err := c.Next() + + recordHTTPServerDuration(c, durationHistogram, method, requestStart, err) + + return err + } + originalURL := string([]byte(c.OriginalURL())) protocol := string([]byte(c.Protocol())) hostname := string([]byte(c.Hostname())) @@ -150,27 +232,150 @@ func (tm *TelemetryMiddleware) WithTelemetry(tl *tracing.Telemetry, excludedRout err = c.Next() - statusCode := c.Response().StatusCode() - span.SetAttributes( - attribute.String("http.request.method", method), - attribute.String("url.path", sanitizeURL(originalURL)), - attribute.String("http.route", c.Route().Path), - attribute.String("url.scheme", protocol), - attribute.String("server.address", hostname), - attribute.String("user_agent.original", userAgent), - attribute.Int("http.response.status_code", statusCode), - ) + // Reconcile the effective status the client will observe (same helper + // the metric uses) so the span's status code, error.type, and + // error.type_original stay consistent with the duration metric. + statusCode := httpStatusCode(c, err) - if err != nil { - tracing.HandleSpanError(span, "handler error", err) - } else if statusCode >= 500 { - span.SetStatus(codes.Error, fmt.Sprintf("HTTP %d", statusCode)) - } + applyTelemetrySpanAttributes(span, c, statusCode, telemetryRequestAttrs{ + method: method, + methodOriginal: methodOriginal, + methodReplaced: methodReplaced, + originalURL: originalURL, + protocol: protocol, + hostname: hostname, + userAgent: userAgent, + handlerErr: err, + }) + + recordHTTPServerDuration(c, durationHistogram, method, requestStart, err) return err } } +// telemetryRequestAttrs groups the per-request fields needed to apply OTel +// span attributes after c.Next() returns. Kept package-private; only +// applyTelemetrySpanAttributes consumes it. +type telemetryRequestAttrs struct { + method string + methodOriginal string + methodReplaced bool + originalURL string + protocol string + hostname string + userAgent string + handlerErr error +} + +// applyTelemetrySpanAttributes sets the OTel HTTP semantic-convention +// attributes on the request span and finalizes its status. Extracted from +// WithTelemetry to keep that function's cyclomatic complexity within the +// repo's lint budget; the behavior is identical to setting the attributes +// inline. +func applyTelemetrySpanAttributes( + span trace.Span, + c *fiber.Ctx, + statusCode int, + req telemetryRequestAttrs, +) { + spanAttrs := []attribute.KeyValue{ + attribute.String("http.request.method", req.method), + attribute.String("url.path", sanitizeURL(req.originalURL)), + attribute.String("url.scheme", req.protocol), + attribute.String("server.address", req.hostname), + attribute.String("user_agent.original", truncateUserAgent(req.userAgent)), + attribute.Int("http.response.status_code", statusCode), + } + if routePath, present := routeAttribute(c, statusCode); present { + spanAttrs = append(spanAttrs, attribute.String("http.route", routePath)) + } + + if req.methodReplaced { + spanAttrs = append(spanAttrs, attribute.String("http.request.method_original", req.methodOriginal)) + } + + if errType := classifyHTTPErrorType(statusCode); errType != "" { + spanAttrs = append(spanAttrs, attribute.String("error.type", errType)) + } + + if origType := errorTypeOriginal(req.handlerErr); origType != "" { + spanAttrs = append(spanAttrs, attribute.String("error.type_original", origType)) + } + + span.SetAttributes(spanAttrs...) + + if req.handlerErr != nil { + tracing.HandleSpanError(span, "handler error", req.handlerErr) + return + } + + if statusCode >= 500 { + span.SetStatus(codes.Error, fmt.Sprintf("HTTP %d", statusCode)) + } +} + +// recordHTTPServerDuration emits the http.server.request.duration histogram +// observation for a completed Fiber request. It is a no-op when the histogram +// is nil (telemetry/MeterProvider/MetricsFactory absent or instrument creation +// failed) so callers can invoke it unconditionally without nil checks. +// +// Attribute set follows OpenTelemetry HTTP semantic conventions: +// - http.request.method: captured before c.Next() to survive fasthttp recycling +// - http.route: c.Route().Path - low-cardinality route template, never raw paths; +// omitted entirely when no route matched (Fiber's catch-all 404), so scanner/ +// unmatched traffic does not pollute the root-route series. +// - http.response.status_code: the effective status the client will observe; +// derived from the handler error (*fiber.Error.Code, or 500 for generic +// errors) when Fiber's error handler has not yet rewritten the response, +// otherwise read directly from the response. This matches httpStatusCode +// used by the logging middleware and avoids reporting 200 for failures. +// - error.type: only set when effective status >= 500, using the numeric +// status code as a stable, low-cardinality label. +func recordHTTPServerDuration( + c *fiber.Ctx, + hist metric.Float64Histogram, + method string, + start time.Time, + handlerErr error, +) { + if hist == nil || c == nil { + return + } + + statusCode := httpStatusCode(c, handlerErr) + + attrs := []attribute.KeyValue{ + attribute.String("http.request.method", method), + attribute.Int("http.response.status_code", statusCode), + } + if routePath, present := routeAttribute(c, statusCode); present { + attrs = append(attrs, attribute.String("http.route", routePath)) + } + + if errType := classifyHTTPErrorType(statusCode); errType != "" { + attrs = append(attrs, attribute.String("error.type", errType)) + } + + durationSeconds := time.Since(start).Seconds() + hist.Record(c.UserContext(), durationSeconds, metric.WithAttributes(attrs...)) +} + +// classifyHTTPErrorType returns the stable, low-cardinality error.type +// label for the http.server.request.duration metric per OpenTelemetry HTTP +// semantic conventions. Status-driven by design: a 503 surfaced via +// fiber.NewError(503) and a 503 surfaced via c.SendStatus(503) MUST produce +// the same time series so alert rules of the form error_type=~"5.." +// aggregate reliably. The originating Go type identity, when useful for +// debugging, is published separately on the span via errorTypeOriginal. +func classifyHTTPErrorType(statusCode int) string { + if statusCode >= 500 { + return strconv.Itoa(statusCode) + } + + return "" +} + // EndTracingSpans is a middleware that ends the tracing spans. func (tm *TelemetryMiddleware) EndTracingSpans(c *fiber.Ctx) error { if c == nil { diff --git a/middleware/telemetry_metrics_test.go b/middleware/telemetry_metrics_test.go new file mode 100644 index 0000000..83ac1e9 --- /dev/null +++ b/middleware/telemetry_metrics_test.go @@ -0,0 +1,847 @@ +//go:build unit + +package middleware + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "unicode/utf8" + + "github.com/LerianStudio/lib-observability/metrics" + "github.com/LerianStudio/lib-observability/tracing" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +// newMetricsHarness wires a real OTel SDK ManualReader so tests can assert on +// the http.server.request.duration histogram exactly as it would appear to an +// exporter. Returns the configured Telemetry pointer plus a flush function. +func newMetricsHarness(t *testing.T) (*tracing.Telemetry, *sdkmetric.ManualReader) { + t.Helper() + + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { _ = mp.Shutdown(context.Background()) }) + + factory, err := metrics.NewMetricsFactory(mp.Meter("test-library"), nil) + require.NoError(t, err) + + tel := &tracing.Telemetry{ + TelemetryConfig: tracing.TelemetryConfig{ + LibraryName: "test-library", + EnableTelemetry: true, + }, + MeterProvider: mp, + MetricsFactory: factory, + } + + return tel, reader +} + +// findDurationHistogram extracts the http.server.request.duration histogram +// data point from a ManualReader collection. Returns nil if the metric is +// absent (which the tests use to assert non-recording paths). +func findDurationHistogram( + t *testing.T, + reader *sdkmetric.ManualReader, +) *metricdata.HistogramDataPoint[float64] { + t.Helper() + + rm := &metricdata.ResourceMetrics{} + require.NoError(t, reader.Collect(context.Background(), rm)) + + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name != httpServerRequestDurationMetric { + continue + } + + h, ok := m.Data.(metricdata.Histogram[float64]) + require.True(t, ok, "expected Float64 histogram for %s, got %T", m.Name, m.Data) + require.NotEmpty(t, h.DataPoints, "histogram has no data points") + require.Equal(t, "s", m.Unit, "metric unit must be seconds") + + dp := h.DataPoints[0] + return &dp + } + } + + return nil +} + +func attrValue(set attribute.Set, key string) (string, bool) { + v, ok := set.Value(attribute.Key(key)) + if !ok { + return "", false + } + + return v.AsString(), true +} + +// newTelemetryHarness extends newMetricsHarness with a real TracerProvider +// backed by an InMemoryExporter so tests can assert on both the +// http.server.request.duration histogram and the span attributes produced +// by WithTelemetry in a single fixture. +func newTelemetryHarness( + t *testing.T, +) (*tracing.Telemetry, *sdkmetric.ManualReader, *tracetest.InMemoryExporter) { + t.Helper() + + tel, reader := newMetricsHarness(t) + + spanExp := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(spanExp)) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + + tel.TracerProvider = tp + + return tel, reader, spanExp +} + +// getSpanAttr returns the string value for the named attribute on the given +// stub span, or "" if absent. Non-string attribute values are also returned +// stringified to keep call-site assertions uniform. +func getSpanAttr(span tracetest.SpanStub, key string) string { + for _, kv := range span.Attributes { + if string(kv.Key) == key { + return kv.Value.Emit() + } + } + + return "" +} + +// TestHTTPServerDurationBuckets_MatchOTelAdvisory locks the bucket layout +// against the current OTel HTTP semconv advisory. Any change to this slice +// is observable from dashboards, so it MUST be a deliberate spec-tracking +// update — never an accidental edit. +func TestHTTPServerDurationBuckets_MatchOTelAdvisory(t *testing.T) { + expected := []float64{ + 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, + } + assert.Equal(t, expected, httpServerDurationBuckets) +} + +// TestWithTelemetry_RecordsDurationOnSuccess verifies that a successful 200 +// request emits the duration histogram with the route template (not the raw +// path) and no error.type attribute. +func TestWithTelemetry_RecordsDurationOnSuccess(t *testing.T) { + tel, reader := newMetricsHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + app.Get("/api/users/:id", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusOK) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/users/42", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + dp := findDurationHistogram(t, reader) + require.NotNil(t, dp, "expected http.server.request.duration to be recorded") + assert.EqualValues(t, 1, dp.Count, "exactly one request should have been recorded") + assert.GreaterOrEqual(t, dp.Sum, 0.0, "duration sum must be non-negative seconds") + + method, ok := attrValue(dp.Attributes, "http.request.method") + require.True(t, ok) + assert.Equal(t, "GET", method) + + route, ok := attrValue(dp.Attributes, "http.route") + require.True(t, ok) + assert.Equal(t, "/api/users/:id", route, "must use route template, never raw path") + + statusVal, ok := dp.Attributes.Value(attribute.Key("http.response.status_code")) + require.True(t, ok) + assert.EqualValues(t, http.StatusOK, statusVal.AsInt64()) + + _, hasErr := dp.Attributes.Value(attribute.Key("error.type")) + assert.False(t, hasErr, "error.type must not be set on a 2xx response") +} + +// TestWithTelemetry_RecordsDurationOn4xx verifies a client-error response is +// recorded without an error.type attribute (4xx is not classified as an error +// per OTel HTTP server conventions). +func TestWithTelemetry_RecordsDurationOn4xx(t *testing.T) { + tel, reader := newMetricsHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + app.Get("/api/items/:id", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusNotFound) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/items/missing", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + dp := findDurationHistogram(t, reader) + require.NotNil(t, dp) + assert.EqualValues(t, 1, dp.Count) + + statusVal, ok := dp.Attributes.Value(attribute.Key("http.response.status_code")) + require.True(t, ok) + assert.EqualValues(t, http.StatusNotFound, statusVal.AsInt64()) + + _, hasErr := dp.Attributes.Value(attribute.Key("error.type")) + assert.False(t, hasErr, "4xx must not set error.type") +} + +// TestWithTelemetry_RecordsDurationOn5xxStatus verifies that a 5xx response +// returned by the handler (without returning an error) is classified with a +// numeric error.type derived from the status code. +func TestWithTelemetry_RecordsDurationOn5xxStatus(t *testing.T) { + tel, reader := newMetricsHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + app.Get("/api/health", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusServiceUnavailable) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/health", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + + dp := findDurationHistogram(t, reader) + require.NotNil(t, dp) + + errType, ok := attrValue(dp.Attributes, "error.type") + require.True(t, ok, "5xx without handler error must still set error.type") + assert.Equal(t, "503", errType) + + // SendStatus(503) carries no handler error, so error.type_original must + // be absent everywhere (regression guard against re-introducing the + // Go-type label on the metric). + _, hasOrigOnMetric := dp.Attributes.Value(attribute.Key("error.type_original")) + assert.False(t, hasOrigOnMetric, + "error.type_original must NEVER appear on the metric") +} + +// TestWithTelemetry_RecordsDurationOnHandlerError verifies the Opção C +// hybrid for a generic handler-returned error reconciled to 500: +// - Metric error.type is the status code string ("500"), never the Go type +// name (which would balloon cardinality across application error types). +// - Metric does NOT carry error.type_original. +// - Span carries the same numeric error.type plus error.type_original with +// the originating Go type name for debugging. +func TestWithTelemetry_RecordsDurationOnHandlerError(t *testing.T) { + tel, reader, spanExp := newTelemetryHarness(t) + + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + return c.Status(http.StatusInternalServerError).SendString(err.Error()) + }, + }) + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + sentinel := errors.New("boom") + app.Get("/api/explode", func(c *fiber.Ctx) error { + return sentinel + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/explode", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + + // Metric: numeric error.type, no error.type_original. + dp := findDurationHistogram(t, reader) + require.NotNil(t, dp) + + errType, ok := attrValue(dp.Attributes, "error.type") + require.True(t, ok) + assert.Equal(t, "500", errType, + "metric error.type must be status-driven for low cardinality") + + _, hasOrigOnMetric := dp.Attributes.Value(attribute.Key("error.type_original")) + assert.False(t, hasOrigOnMetric, + "error.type_original must NEVER appear on the metric") + + // Span: same numeric error.type + originating Go type name. + spans := spanExp.GetSpans() + require.NotEmpty(t, spans) + assert.Equal(t, "500", getSpanAttr(spans[0], "error.type")) + assert.Equal(t, "errors.errorString", + getSpanAttr(spans[0], "error.type_original"), + "span must surface the originating Go type name for debugging") +} + +// TestWithTelemetry_FiberError4xxOmitsErrorTypeOnMetric verifies that a +// handler returning fiber.NewError(4xx) records the effective HTTP status +// from the error but does NOT set error.type on the metric (4xx is not +// classified as an error per the status-driven contract). The originating +// *fiber.Error type is preserved on the span as error.type_original. +func TestWithTelemetry_FiberError4xxOmitsErrorTypeOnMetric(t *testing.T) { + tel, reader, spanExp := newTelemetryHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + app.Get("/api/items/:id", func(c *fiber.Ctx) error { + return fiber.NewError(http.StatusNotFound, "not found") + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/items/missing", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusNotFound, resp.StatusCode, + "client must observe the 404 from Fiber's default error handler") + + dp := findDurationHistogram(t, reader) + require.NotNil(t, dp) + assert.EqualValues(t, 1, dp.Count) + + statusVal, ok := dp.Attributes.Value(attribute.Key("http.response.status_code")) + require.True(t, ok) + assert.EqualValues(t, http.StatusNotFound, statusVal.AsInt64(), + "status_code must reflect the *fiber.Error code, not the unwritten default") + + // Metric MUST omit error.type and error.type_original for 4xx. + _, hasErrTypeOnMetric := dp.Attributes.Value(attribute.Key("error.type")) + assert.False(t, hasErrTypeOnMetric, + "4xx must not set error.type on the metric per status-driven classification") + _, hasOrigOnMetric := dp.Attributes.Value(attribute.Key("error.type_original")) + assert.False(t, hasOrigOnMetric, + "error.type_original must NEVER appear on the metric") + + // Span MUST also omit error.type but carry error.type_original. + spans := spanExp.GetSpans() + require.NotEmpty(t, spans) + assert.Empty(t, getSpanAttr(spans[0], "error.type"), + "span error.type follows the same status-driven rule") + assert.Equal(t, "fiber.Error", + getSpanAttr(spans[0], "error.type_original"), + "span must preserve the originating *fiber.Error type") +} + +// TestWithTelemetry_FiberError400OmitsErrorTypeOnMetric asserts the same +// contract for 4xx bad-request errors raised via fiber.NewError, independently +// from the 404 case to catch regressions for either code path. +func TestWithTelemetry_FiberError400OmitsErrorTypeOnMetric(t *testing.T) { + tel, reader, spanExp := newTelemetryHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + app.Post("/api/validate", func(c *fiber.Ctx) error { + return fiber.NewError(http.StatusBadRequest, "invalid payload") + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodPost, "/api/validate", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + dp := findDurationHistogram(t, reader) + require.NotNil(t, dp) + + statusVal, ok := dp.Attributes.Value(attribute.Key("http.response.status_code")) + require.True(t, ok) + assert.EqualValues(t, http.StatusBadRequest, statusVal.AsInt64(), + "status_code must reflect fiber.NewError(400)") + + _, hasErrTypeOnMetric := dp.Attributes.Value(attribute.Key("error.type")) + assert.False(t, hasErrTypeOnMetric, + "400 must not set error.type on the metric") + _, hasOrigOnMetric := dp.Attributes.Value(attribute.Key("error.type_original")) + assert.False(t, hasOrigOnMetric) + + spans := spanExp.GetSpans() + require.NotEmpty(t, spans) + assert.Empty(t, getSpanAttr(spans[0], "error.type")) + assert.Equal(t, "fiber.Error", + getSpanAttr(spans[0], "error.type_original")) +} + +// TestWithTelemetry_RecordsDurationOnFiberError5xx verifies that a 5xx +// fiber.NewError is reflected in status_code AND error.type on the duration +// metric using the status-driven numeric label, with the originating Go type +// preserved on the span as error.type_original (never on the metric). +func TestWithTelemetry_RecordsDurationOnFiberError5xx(t *testing.T) { + tel, reader, spanExp := newTelemetryHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + app.Get("/api/down", func(c *fiber.Ctx) error { + return fiber.NewError(http.StatusBadGateway, "upstream gone") + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/down", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) + + dp := findDurationHistogram(t, reader) + require.NotNil(t, dp) + + statusVal, ok := dp.Attributes.Value(attribute.Key("http.response.status_code")) + require.True(t, ok) + assert.EqualValues(t, http.StatusBadGateway, statusVal.AsInt64()) + + errType, ok := attrValue(dp.Attributes, "error.type") + require.True(t, ok) + assert.Equal(t, "502", errType, + "metric error.type must be status-driven for low cardinality") + + _, hasOrigOnMetric := dp.Attributes.Value(attribute.Key("error.type_original")) + assert.False(t, hasOrigOnMetric, + "error.type_original must NEVER appear on the metric") + + spans := spanExp.GetSpans() + require.NotEmpty(t, spans) + assert.Equal(t, "502", getSpanAttr(spans[0], "error.type")) + assert.Equal(t, "fiber.Error", getSpanAttr(spans[0], "error.type_original")) +} + +// TestWithTelemetry_FiberErrorAndSendStatusAreConsistent verifies the core +// motivation of the status-driven contract: a 503 raised via +// fiber.NewError(503) and a 503 written via c.SendStatus(503) MUST produce +// the same metric time series so alert rules of the form error_type=~"5.." +// aggregate reliably across both code paths. +func TestWithTelemetry_FiberErrorAndSendStatusAreConsistent(t *testing.T) { + tel, reader := newMetricsHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + app.Get("/api/raise", func(c *fiber.Ctx) error { + return fiber.NewError(http.StatusServiceUnavailable, "down") + }) + app.Get("/api/send", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusServiceUnavailable) + }) + + r1, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/raise", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, r1.Body.Close()) }() + require.Equal(t, http.StatusServiceUnavailable, r1.StatusCode) + + r2, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/send", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, r2.Body.Close()) }() + require.Equal(t, http.StatusServiceUnavailable, r2.StatusCode) + + // Both requests share method+status+error.type but have different + // http.route values, so they remain two distinct time series, each with + // Count==1. Both MUST carry error.type="503" (status-driven) and no + // error.type_original on the metric. + rm := &metricdata.ResourceMetrics{} + require.NoError(t, reader.Collect(context.Background(), rm)) + + var points []metricdata.HistogramDataPoint[float64] + + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name != httpServerRequestDurationMetric { + continue + } + + h, ok := m.Data.(metricdata.Histogram[float64]) + require.True(t, ok) + points = append(points, h.DataPoints...) + } + } + + require.Len(t, points, 2, + "each route is a distinct series; both must record exactly one point") + + for _, dp := range points { + errType, ok := attrValue(dp.Attributes, "error.type") + require.True(t, ok, + "both fiber.NewError and SendStatus 5xx paths MUST set error.type") + assert.Equal(t, "503", errType, + "both paths MUST produce the same status-driven error.type label") + + _, hasOrig := dp.Attributes.Value(attribute.Key("error.type_original")) + assert.False(t, hasOrig, + "error.type_original must NEVER appear on the metric") + + assert.EqualValues(t, 1, dp.Count) + } +} + +// TestWithTelemetry_GenericHandlerErrorStatusCodeIs500 verifies that when a +// handler returns a non-fiber error and no custom ErrorHandler has rewritten +// the status code by the time the metric is recorded, the metric reports +// status_code=500 with the status-driven error.type="500" (not the Go type +// name). The originating Go type identity is preserved on the span via +// error.type_original. +func TestWithTelemetry_GenericHandlerErrorStatusCodeIs500(t *testing.T) { + tel, reader, spanExp := newTelemetryHarness(t) + + // Use Fiber's default ErrorHandler (no override) so the response status + // is materialized AFTER the WithTelemetry middleware unwinds. + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + app.Get("/api/explode", func(c *fiber.Ctx) error { + return errors.New("boom") + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/explode", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode, + "Fiber's default error handler maps unknown errors to 500") + + dp := findDurationHistogram(t, reader) + require.NotNil(t, dp) + + statusVal, ok := dp.Attributes.Value(attribute.Key("http.response.status_code")) + require.True(t, ok) + assert.EqualValues(t, http.StatusInternalServerError, statusVal.AsInt64(), + "generic handler error must record status_code=500 to match client view") + + errType, ok := attrValue(dp.Attributes, "error.type") + require.True(t, ok) + assert.Equal(t, "500", errType) + + _, hasOrigOnMetric := dp.Attributes.Value(attribute.Key("error.type_original")) + assert.False(t, hasOrigOnMetric) + + spans := spanExp.GetSpans() + require.NotEmpty(t, spans) + assert.Equal(t, "500", getSpanAttr(spans[0], "error.type")) + assert.Equal(t, "errors.errorString", + getSpanAttr(spans[0], "error.type_original")) +} + +// TestWithTelemetry_DoesNotRecordForExcludedRoute verifies that excluded +// routes bypass duration recording entirely. +func TestWithTelemetry_DoesNotRecordForExcludedRoute(t *testing.T) { + tel, reader := newMetricsHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel, "/swagger")) + + app.Get("/swagger/index.html", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusOK) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assert.Nil(t, findDurationHistogram(t, reader), + "excluded route must not record http.server.request.duration") +} + +// TestWithTelemetry_NilTelemetryDoesNotRecord verifies the handler is safe and +// silent when no Telemetry is configured. +func TestWithTelemetry_NilTelemetryDoesNotRecord(t *testing.T) { + // Standalone reader without a real Telemetry attached - we still expect + // the metric to be absent because the middleware short-circuits. + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { _ = mp.Shutdown(context.Background()) }) + + app := fiber.New() + mid := NewTelemetryMiddleware(nil) + app.Use(mid.WithTelemetry(nil)) + + app.Get("/ping", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusOK) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/ping", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + rm := &metricdata.ResourceMetrics{} + require.NoError(t, reader.Collect(context.Background(), rm)) + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + assert.NotEqual(t, httpServerRequestDurationMetric, m.Name, + "nil telemetry must not record duration") + } + } +} + +// TestWithTelemetry_NilMeterProviderDoesNotRecord verifies that recording is +// skipped when Telemetry is present but has no MeterProvider, while the +// request itself still completes successfully. +func TestWithTelemetry_NilMeterProviderDoesNotRecord(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { _ = mp.Shutdown(context.Background()) }) + + tel := &tracing.Telemetry{ + TelemetryConfig: tracing.TelemetryConfig{LibraryName: "test-library"}, + // MeterProvider intentionally nil. + MetricsFactory: metrics.NewNopFactory(), + } + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + app.Get("/no-mp", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusOK) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/no-mp", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assert.Nil(t, findDurationHistogram(t, reader), + "nil MeterProvider must not record duration") +} + +// TestWithTelemetry_NilMetricsFactoryDoesNotRecord verifies that recording is +// gated on MetricsFactory presence even when MeterProvider is configured. +// This matches the requirement that nil MetricsFactory must silently skip. +func TestWithTelemetry_NilMetricsFactoryDoesNotRecord(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { _ = mp.Shutdown(context.Background()) }) + + tel := &tracing.Telemetry{ + TelemetryConfig: tracing.TelemetryConfig{LibraryName: "test-library"}, + MeterProvider: mp, + // MetricsFactory intentionally nil. + } + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + app.Get("/no-factory", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusOK) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/no-factory", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assert.Nil(t, findDurationHistogram(t, reader), + "nil MetricsFactory must not record duration") +} + +// TestWithTelemetry_UnmatchedRouteOmitsHTTPRoute verifies the catch-all 404 +// guard: Fiber v2's default unmatched-path handler exposes Route().Path=="/", +// which would conflate scanner/404 traffic with the actual root handler in +// dashboards. The middleware MUST omit http.route entirely from both the span +// and the metric in that case, while still recording http.route="/" for a +// legitimately-registered root handler. +func TestWithTelemetry_UnmatchedRouteOmitsHTTPRoute(t *testing.T) { + t.Run("unmatched 404 omits http.route", func(t *testing.T) { + tel, reader, spanExp := newTelemetryHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + // No routes registered: any request hits Fiber's catch-all 404. + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/not-registered", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + dp := findDurationHistogram(t, reader) + require.NotNil(t, dp, "duration must still be recorded for the 404") + assert.EqualValues(t, 1, dp.Count) + + _, hasRoute := dp.Attributes.Value(attribute.Key("http.route")) + assert.False(t, hasRoute, + "http.route must be absent on the metric for unmatched 404") + + spans := spanExp.GetSpans() + require.NotEmpty(t, spans) + assert.Empty(t, getSpanAttr(spans[0], "http.route"), + "http.route must be absent on the span for unmatched 404") + }) + + t.Run("registered root handler retains http.route", func(t *testing.T) { + tel, reader, spanExp := newTelemetryHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + app.Get("/", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + dp := findDurationHistogram(t, reader) + require.NotNil(t, dp) + route, ok := attrValue(dp.Attributes, "http.route") + require.True(t, ok, + "http.route must be present for a legitimately-registered root handler") + assert.Equal(t, "/", route) + + spans := spanExp.GetSpans() + require.NotEmpty(t, spans) + assert.Equal(t, "/", getSpanAttr(spans[0], "http.route")) + }) +} + +// TestWithTelemetry_NormalizesUnknownMethodOnSpan verifies that an unknown +// HTTP verb is normalized to "_OTHER" on both the metric and the span, with +// the original verb preserved exclusively on the span's +// http.request.method_original attribute (never on the metric, to keep +// label cardinality bounded). +func TestWithTelemetry_NormalizesUnknownMethodOnSpan(t *testing.T) { + tel, reader, spanExp := newTelemetryHarness(t) + + // Fiber rejects unknown verbs at the framework boundary by default. + // Extend RequestMethods so the middleware actually sees PROPFIND. + app := fiber.New(fiber.Config{ + RequestMethods: append(fiber.DefaultMethods, "PROPFIND"), + }) + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + app.Add("PROPFIND", "/dav/:resource", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusOK) + }) + + resp, err := app.Test(httptest.NewRequest("PROPFIND", "/dav/foo", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Metric assertions: normalized to "_OTHER", no method_original. + dp := findDurationHistogram(t, reader) + require.NotNil(t, dp) + + method, ok := attrValue(dp.Attributes, "http.request.method") + require.True(t, ok) + assert.Equal(t, "_OTHER", method) + + _, hasOrigOnMetric := dp.Attributes.Value(attribute.Key("http.request.method_original")) + assert.False(t, hasOrigOnMetric, "method_original must NEVER appear on the metric") + + // Span assertions: normalized method + original preserved. + spans := spanExp.GetSpans() + require.Len(t, spans, 1) + assert.Equal(t, "_OTHER", getSpanAttr(spans[0], "http.request.method")) + assert.Equal(t, "PROPFIND", getSpanAttr(spans[0], "http.request.method_original")) +} + +// TestWithTelemetry_KnownMethodHasNoOriginal verifies that a canonical method +// (GET) does not emit http.request.method_original anywhere. +func TestWithTelemetry_KnownMethodHasNoOriginal(t *testing.T) { + tel, reader, spanExp := newTelemetryHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + + app.Get("/ping", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusOK) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/ping", nil)) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + + dp := findDurationHistogram(t, reader) + require.NotNil(t, dp) + method, _ := attrValue(dp.Attributes, "http.request.method") + assert.Equal(t, "GET", method) + _, hasOrig := dp.Attributes.Value(attribute.Key("http.request.method_original")) + assert.False(t, hasOrig) + + spans := spanExp.GetSpans() + require.NotEmpty(t, spans) + assert.Equal(t, "GET", getSpanAttr(spans[0], "http.request.method")) + assert.Empty(t, getSpanAttr(spans[0], "http.request.method_original")) +} + +// TestWithTelemetry_TruncatesLongUserAgent verifies the user_agent.original +// span attribute is capped at maxUserAgentAttrLen bytes regardless of input +// length, protecting trace storage from pathological clients. +func TestWithTelemetry_TruncatesLongUserAgent(t *testing.T) { + tel, _, spanExp := newTelemetryHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + app.Get("/x", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + longUA := strings.Repeat("a", 4000) + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("User-Agent", longUA) + + resp, err := app.Test(req) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + + spans := spanExp.GetSpans() + require.NotEmpty(t, spans) + + ua := getSpanAttr(spans[0], "user_agent.original") + assert.Len(t, ua, maxUserAgentAttrLen) + assert.Equal(t, strings.Repeat("a", maxUserAgentAttrLen), ua) +} + +// TestWithTelemetry_TruncatesUserAgentAtRuneBoundary verifies that a multi-byte +// UTF-8 user-agent is truncated at a rune boundary, never mid-codepoint, so +// the resulting span attribute is always valid UTF-8 and never exceeds the +// byte cap. Uses a 3-byte rune ("€" = 0xE2 0x82 0xAC) repeated so the byte +// cap (256) falls strictly inside a codepoint; a naive byte slice would +// produce invalid UTF-8 at the boundary. +func TestWithTelemetry_TruncatesUserAgentAtRuneBoundary(t *testing.T) { + tel, _, spanExp := newTelemetryHarness(t) + + app := fiber.New() + mid := NewTelemetryMiddleware(tel) + app.Use(mid.WithTelemetry(tel)) + app.Get("/x", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + longUA := strings.Repeat("€", 1000) // 3000 bytes + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("User-Agent", longUA) + + resp, err := app.Test(req) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + + spans := spanExp.GetSpans() + require.NotEmpty(t, spans) + + ua := getSpanAttr(spans[0], "user_agent.original") + assert.LessOrEqual(t, len(ua), maxUserAgentAttrLen, + "truncated user-agent must not exceed the byte cap") + assert.True(t, utf8.ValidString(ua), + "truncated user-agent must remain valid UTF-8 (never split a codepoint)") + // 256 / 3 = 85 complete "€" runes (255 bytes), which is the largest + // rune-aligned prefix that fits within the cap. + assert.Equal(t, strings.Repeat("€", 85), ua) +}