From 4124cac5ca95915d8d8741986ee5e378ded324d7 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 20 Jun 2026 20:39:48 -0400 Subject: [PATCH 1/2] SMOODEV-2023: Go Fiber + Gin observability middleware Add Fiber and Gin middleware adapters as their own Go modules (go/fiber, go/gin) so the core SDK takes no dependency on either framework unless imported. Both mirror the net/http middleware semantics exactly: per-request scope on the request context, user/request-context hydration (PII-scrubbed via header allowlist, guarded so hydration can't break the request), and capture-then-rethrow on panic (SwallowPanics writes a 500 instead). They also capture handler-reported errors (returned error for Fiber, c.Errors for Gin). Adds an exported Scope.User() accessor so cross-package adapters/tests can inspect request scope. Updates README + the net/http middleware gap note. Co-Authored-By: Claude Opus 4.8 (1M context) --- go/README.md | 38 ++++++++- go/fiber/go.mod | 45 +++++++++++ go/fiber/go.sum | 88 ++++++++++++++++++++ go/fiber/middleware.go | 124 ++++++++++++++++++++++++++++ go/fiber/middleware_test.go | 156 ++++++++++++++++++++++++++++++++++++ go/gin/go.mod | 58 ++++++++++++++ go/gin/go.sum | 142 ++++++++++++++++++++++++++++++++ go/gin/middleware.go | 127 +++++++++++++++++++++++++++++ go/gin/middleware_test.go | 141 ++++++++++++++++++++++++++++++++ go/middleware.go | 6 +- go/scope.go | 14 ++++ 11 files changed, 934 insertions(+), 5 deletions(-) create mode 100644 go/fiber/go.mod create mode 100644 go/fiber/go.sum create mode 100644 go/fiber/middleware.go create mode 100644 go/fiber/middleware_test.go create mode 100644 go/gin/go.mod create mode 100644 go/gin/go.sum create mode 100644 go/gin/middleware.go create mode 100644 go/gin/middleware_test.go diff --git a/go/README.md b/go/README.md index a0e0800..13adc3a 100644 --- a/go/README.md +++ b/go/README.md @@ -106,6 +106,39 @@ Establishes a request-scoped scope, records request context, and captures downstream panics (then re-panics so the host's recovery still runs; set `SwallowPanics: true` to write a 500 instead). +## Fiber / Gin middleware + +Fiber and Gin adapters ship as their own Go modules under `go/fiber` and +`go/gin` so the core SDK takes no dependency on either framework unless you +import the adapter: + +```go +// Fiber — github.com/SmooAI/observability/go/fiber +import fiberobs "github.com/SmooAI/observability/go/fiber" + +app.Use(fiberobs.New(obs.Default, func(c *fiber.Ctx) *obs.User { + return &obs.User{ID: c.Get("X-User-Id")} +})) +``` + +```go +// Gin — github.com/SmooAI/observability/go/gin +import ginobs "github.com/SmooAI/observability/go/gin" + +r.Use(ginobs.New(obs.Default, func(c *gin.Context) *obs.User { + return &obs.User{ID: c.GetHeader("X-User-Id")} +})) +``` + +Both mirror the `net/http` middleware exactly: per-request scope on the request +context, user/`request` context hydration (PII-scrubbed via the allowlist), +and capture-then-rethrow on panic (`SwallowPanics: true` writes a 500 instead). +They additionally capture handler-reported errors — the returned `error` for +Fiber, `c.Errors` for Gin — tagged `source: fiber.middleware` / `gin.middleware`. +Neither framework installs panic recovery by default in the way the host app +expects, so pair the middleware with the framework's own recovery +(`recover.New()` for Fiber, `gin.Recovery()` for Gin) when you rely on re-panic. + ## Wire format `ObservabilityEvent` JSON is byte-compatible with the TS `ObservabilityEvent` @@ -115,8 +148,9 @@ so one backend ingest endpoint (`type: "error"`) serves both SDKs. The SDK name ## Gaps / deferred -- **Fiber / Gin / Echo adapters** — only `net/http` ships. They can be thin - adapters over the same `Scope` + `CaptureException` primitives. +- **Echo adapter** — `net/http`, Fiber, and Gin ship (see above). Echo can be + added the same way: a thin adapter over the same `Scope` + `CaptureException` + primitives, as its own `go/echo` module. - **Span-implicit capture** — Go has no ambient span, so span-correlated capture uses `CaptureExceptionOnSpan(ctx, ...)` (reads the span off `ctx`). The plain `CaptureException` still records via transport + a synthetic span. diff --git a/go/fiber/go.mod b/go/fiber/go.mod new file mode 100644 index 0000000..e041a70 --- /dev/null +++ b/go/fiber/go.mod @@ -0,0 +1,45 @@ +module github.com/SmooAI/observability/go/fiber + +go 1.25.0 + +require ( + github.com/SmooAI/observability/go v0.0.0 + github.com/gofiber/fiber/v2 v2.52.5 +) + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // 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/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +replace github.com/SmooAI/observability/go => ../ diff --git a/go/fiber/go.sum b/go/fiber/go.sum new file mode 100644 index 0000000..b05b9a7 --- /dev/null +++ b/go/fiber/go.sum @@ -0,0 +1,88 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +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/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= +github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +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/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/go/fiber/middleware.go b/go/fiber/middleware.go new file mode 100644 index 0000000..01ad33b --- /dev/null +++ b/go/fiber/middleware.go @@ -0,0 +1,124 @@ +// Package fiberobs provides a Fiber (github.com/gofiber/fiber/v2) middleware for +// the Smoo observability SDK. It is a thin adapter over the same Scope + +// CaptureException primitives as the core net/http middleware, with identical +// semantics: +// +// 1. Establishes a fresh request-scoped Scope on the request's user context +// (Fiber's c.UserContext()), so any CaptureExceptionOnSpan / scope mutation +// fired from a downstream handler picks up this request's identity. +// 2. Resolves user identity (via ResolveUser) and records a "request" context +// block (method, path, allowlisted headers). Hydration is wrapped so a +// failure there never breaks the request. +// 3. Captures downstream panics AND returned errors as exceptions (tagged +// source "fiber.middleware") before propagating them. Panics are re-panicked +// by default so Fiber's own recovery still runs; set SwallowPanics to write a +// 500 instead. Returned errors are always re-returned so Fiber's ErrorHandler +// renders the response. +// +// Lives in its own Go module so the core SDK does not take a hard dependency on +// Fiber unless this adapter is imported. +package fiberobs + +import ( + "fmt" + "net/http" + + "github.com/gofiber/fiber/v2" + + obs "github.com/SmooAI/observability/go" +) + +// Options configures Middleware. +type Options struct { + // Client to capture on. Defaults to the package obs.Default client. + Client *obs.Client + // ResolveUser extracts user identity from the request. Return nil to skip. + ResolveUser func(c *fiber.Ctx) *obs.User + // RequestHeaderAllowlist names headers recorded on the request context. + // Defaults to a conservative, safe-to-send set. + RequestHeaderAllowlist []string + // SwallowPanics, when true, makes the middleware capture a downstream panic, + // write a 500, and NOT re-panic. The default (false) re-panics after + // capturing so the host's own recovery middleware still runs. + SwallowPanics bool +} + +var defaultHeaderAllowlist = []string{"user-agent", "referer", "x-request-id", "x-trace-id", "x-correlation-id"} + +// Middleware returns a Fiber handler that establishes per-request scope and +// captures downstream panics/errors. +func Middleware(opts Options) fiber.Handler { + client := opts.Client + if client == nil { + client = obs.Default + } + allowlist := opts.RequestHeaderAllowlist + if allowlist == nil { + allowlist = defaultHeaderAllowlist + } + rethrow := !opts.SwallowPanics + + return func(c *fiber.Ctx) (err error) { + if !client.IsInitialized() { + return c.Next() + } + + scope := obs.NewScope() + ctx := obs.ContextWithScope(c.UserContext(), scope) + c.SetUserContext(ctx) + + hydrateScope(c, scope, opts.ResolveUser, allowlist) + + defer func() { + if rec := recover(); rec != nil { + client.CaptureExceptionOnSpan(ctx, panicToError(rec), map[string]string{"source": "fiber.middleware"}) + if rethrow { + panic(rec) + } + err = c.SendStatus(http.StatusInternalServerError) + } + }() + + err = c.Next() + if err != nil { + client.CaptureExceptionOnSpan(ctx, err, map[string]string{"source": "fiber.middleware"}) + } + return err + } +} + +// New is the convenience constructor — captures panics then re-panics so the +// host's recovery still runs. +func New(client *obs.Client, resolveUser func(c *fiber.Ctx) *obs.User) fiber.Handler { + return Middleware(Options{Client: client, ResolveUser: resolveUser}) +} + +// hydrateScope sets user + request context on the scope. Wrapped so a panic in +// ResolveUser or header reads never breaks the request, mirroring the core +// middleware's recoverSilently guard. +func hydrateScope(c *fiber.Ctx, scope *obs.Scope, resolveUser func(c *fiber.Ctx) *obs.User, allowlist []string) { + defer func() { _ = recover() }() + if resolveUser != nil { + if u := resolveUser(c); u != nil { + scope.SetUser(u) + } + } + headers := map[string]string{} + for _, name := range allowlist { + if v := c.Get(name); v != "" { + headers[name] = v + } + } + scope.SetContext("request", map[string]any{ + "method": c.Method(), + "path": c.Path(), + "headers": headers, + }) +} + +func panicToError(rec any) error { + if err, ok := rec.(error); ok { + return err + } + return fmt.Errorf("panic: %v", rec) +} diff --git a/go/fiber/middleware_test.go b/go/fiber/middleware_test.go new file mode 100644 index 0000000..8a712c2 --- /dev/null +++ b/go/fiber/middleware_test.go @@ -0,0 +1,156 @@ +package fiberobs + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + fiberrecover "github.com/gofiber/fiber/v2/middleware/recover" + + obs "github.com/SmooAI/observability/go" +) + +func TestMiddlewareSetsScopeAndPassesThrough(t *testing.T) { + c := obs.NewClient() + c.Init(obs.ClientOptions{DSN: "x"}) + + var sawUser *obs.User + app := fiber.New() + app.Use(Middleware(Options{ + Client: c, + ResolveUser: func(fc *fiber.Ctx) *obs.User { + return &obs.User{ID: fc.Get("X-User")} + }, + })) + app.Get("/path", func(fc *fiber.Ctx) error { + sawUser = obs.ScopeFromContext(fc.UserContext()).User() + return fc.SendStatus(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/path", nil) + req.Header.Set("X-User", "u1") + req.Header.Set("User-Agent", "test-agent") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d", resp.StatusCode) + } + if sawUser == nil || sawUser.ID != "u1" { + t.Errorf("scope user not set: %+v", sawUser) + } +} + +func TestMiddlewareCapturesReturnedError(t *testing.T) { + c := obs.NewClient() + c.Init(obs.ClientOptions{DSN: "x"}) + var captured int + c.RegisterTransport(func(b []obs.ObservabilityEvent) { captured += len(b) }) + + app := fiber.New() + app.Use(Middleware(Options{Client: c})) + app.Get("/", func(fc *fiber.Ctx) error { + return fiber.NewError(http.StatusBadGateway, "downstream boom") + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadGateway { + t.Errorf("status = %d, want 502 (error re-returned to Fiber ErrorHandler)", resp.StatusCode) + } + if captured != 1 { + t.Errorf("expected 1 captured event, got %d", captured) + } +} + +func TestMiddlewareCapturesPanicAndRethrows(t *testing.T) { + c := obs.NewClient() + c.Init(obs.ClientOptions{DSN: "x"}) + var captured int + c.RegisterTransport(func(b []obs.ObservabilityEvent) { captured += len(b) }) + + // Pair with Fiber's recover middleware so the re-panic is turned into a 500 + // (Fiber, unlike Gin, has no recovery installed by default — panic recovery + // is opt-in). The middleware re-panics after capturing (default), so Fiber's + // recover renders the response. + app := fiber.New() + app.Use(fiberrecover.New()) + app.Use(Middleware(Options{Client: c})) // default: rethrow + app.Get("/", func(fc *fiber.Ctx) error { + panic("handler exploded") + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("status = %d, want 500 (Fiber recovery after re-panic)", resp.StatusCode) + } + if captured != 1 { + t.Errorf("expected 1 captured event, got %d", captured) + } +} + +func TestMiddlewareSwallowPanics(t *testing.T) { + c := obs.NewClient() + c.Init(obs.ClientOptions{DSN: "x"}) + var captured int + c.RegisterTransport(func(b []obs.ObservabilityEvent) { captured += len(b) }) + + app := fiber.New() + app.Use(Middleware(Options{Client: c, SwallowPanics: true})) + app.Get("/", func(fc *fiber.Ctx) error { + panic("boom") + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", resp.StatusCode) + } + if captured != 1 { + t.Errorf("expected 1 captured event, got %d", captured) + } +} + +func TestMiddlewarePassThroughWhenUninitialized(t *testing.T) { + c := obs.NewClient() // not initialized + called := false + + app := fiber.New() + app.Use(Middleware(Options{Client: c})) + app.Get("/", func(fc *fiber.Ctx) error { + called = true + // Scope helper still returns a non-nil scope even uninitialized. + if obs.ScopeFromContext(fc.UserContext()) == nil { + t.Error("nil scope") + } + return fc.SendStatus(http.StatusOK) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); resp.Body.Close() }() + + if !called { + t.Error("handler not called") + } +} diff --git a/go/gin/go.mod b/go/gin/go.mod new file mode 100644 index 0000000..142aa4d --- /dev/null +++ b/go/gin/go.mod @@ -0,0 +1,58 @@ +module github.com/SmooAI/observability/go/gin + +go 1.25.0 + +require ( + github.com/SmooAI/observability/go v0.0.0 + github.com/gin-gonic/gin v1.10.0 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/SmooAI/observability/go => ../ diff --git a/go/gin/go.sum b/go/gin/go.sum new file mode 100644 index 0000000..eacf94f --- /dev/null +++ b/go/gin/go.sum @@ -0,0 +1,142 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +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/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +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/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +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/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/go/gin/middleware.go b/go/gin/middleware.go new file mode 100644 index 0000000..741d877 --- /dev/null +++ b/go/gin/middleware.go @@ -0,0 +1,127 @@ +// Package ginobs provides a Gin (github.com/gin-gonic/gin) middleware for the +// Smoo observability SDK. It is a thin adapter over the same Scope + +// CaptureException primitives as the core net/http middleware, with identical +// semantics: +// +// 1. Establishes a fresh request-scoped Scope on the request context +// (c.Request = c.Request.WithContext(...)), so any CaptureExceptionOnSpan / +// scope mutation fired from a downstream handler picks up this request's +// identity. +// 2. Resolves user identity (via ResolveUser) and records a "request" context +// block (method, path, allowlisted headers). Hydration is wrapped so a +// failure there never breaks the request. +// 3. Captures downstream panics AND errors attached to the Gin context +// (c.Errors) as exceptions (tagged source "gin.middleware"). Panics are +// re-panicked by default so Gin's own Recovery middleware still runs; set +// SwallowPanics to abort with a 500 instead. +// +// Lives in its own Go module so the core SDK does not take a hard dependency on +// Gin unless this adapter is imported. +package ginobs + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + obs "github.com/SmooAI/observability/go" +) + +// Options configures Middleware. +type Options struct { + // Client to capture on. Defaults to the package obs.Default client. + Client *obs.Client + // ResolveUser extracts user identity from the request. Return nil to skip. + ResolveUser func(c *gin.Context) *obs.User + // RequestHeaderAllowlist names headers recorded on the request context. + // Defaults to a conservative, safe-to-send set. + RequestHeaderAllowlist []string + // SwallowPanics, when true, makes the middleware capture a downstream panic, + // abort with a 500, and NOT re-panic. The default (false) re-panics after + // capturing so the host's own Recovery middleware still runs. + SwallowPanics bool +} + +var defaultHeaderAllowlist = []string{"user-agent", "referer", "x-request-id", "x-trace-id", "x-correlation-id"} + +// Middleware returns a Gin handler that establishes per-request scope and +// captures downstream panics/errors. +func Middleware(opts Options) gin.HandlerFunc { + client := opts.Client + if client == nil { + client = obs.Default + } + allowlist := opts.RequestHeaderAllowlist + if allowlist == nil { + allowlist = defaultHeaderAllowlist + } + rethrow := !opts.SwallowPanics + + return func(c *gin.Context) { + if !client.IsInitialized() { + c.Next() + return + } + + scope := obs.NewScope() + ctx := obs.ContextWithScope(c.Request.Context(), scope) + c.Request = c.Request.WithContext(ctx) + + hydrateScope(c, scope, opts.ResolveUser, allowlist) + + defer func() { + if rec := recover(); rec != nil { + client.CaptureExceptionOnSpan(ctx, panicToError(rec), map[string]string{"source": "gin.middleware"}) + if rethrow { + panic(rec) + } + c.AbortWithStatus(http.StatusInternalServerError) + } + }() + + c.Next() + + // Capture any errors handlers attached to the Gin context (the idiomatic + // way Gin handlers report failures via c.Error(err)). + for _, ginErr := range c.Errors { + client.CaptureExceptionOnSpan(ctx, ginErr.Err, map[string]string{"source": "gin.middleware"}) + } + } +} + +// New is the convenience constructor — captures panics then re-panics so the +// host's Recovery still runs. +func New(client *obs.Client, resolveUser func(c *gin.Context) *obs.User) gin.HandlerFunc { + return Middleware(Options{Client: client, ResolveUser: resolveUser}) +} + +// hydrateScope sets user + request context on the scope. Wrapped so a panic in +// ResolveUser or header reads never breaks the request, mirroring the core +// middleware's recoverSilently guard. +func hydrateScope(c *gin.Context, scope *obs.Scope, resolveUser func(c *gin.Context) *obs.User, allowlist []string) { + defer func() { _ = recover() }() + if resolveUser != nil { + if u := resolveUser(c); u != nil { + scope.SetUser(u) + } + } + headers := map[string]string{} + for _, name := range allowlist { + if v := c.GetHeader(name); v != "" { + headers[name] = v + } + } + scope.SetContext("request", map[string]any{ + "method": c.Request.Method, + "path": c.FullPath(), + "headers": headers, + }) +} + +func panicToError(rec any) error { + if err, ok := rec.(error); ok { + return err + } + return fmt.Errorf("panic: %v", rec) +} diff --git a/go/gin/middleware_test.go b/go/gin/middleware_test.go new file mode 100644 index 0000000..fd0588c --- /dev/null +++ b/go/gin/middleware_test.go @@ -0,0 +1,141 @@ +package ginobs + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + obs "github.com/SmooAI/observability/go" +) + +func init() { gin.SetMode(gin.TestMode) } + +func TestMiddlewareSetsScopeAndPassesThrough(t *testing.T) { + c := obs.NewClient() + c.Init(obs.ClientOptions{DSN: "x"}) + + var sawUser *obs.User + r := gin.New() + r.Use(Middleware(Options{ + Client: c, + ResolveUser: func(gc *gin.Context) *obs.User { + return &obs.User{ID: gc.GetHeader("X-User")} + }, + })) + r.GET("/path", func(gc *gin.Context) { + sawUser = obs.ScopeFromContext(gc.Request.Context()).User() + gc.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/path", nil) + req.Header.Set("X-User", "u1") + req.Header.Set("User-Agent", "test-agent") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("status = %d", rr.Code) + } + if sawUser == nil || sawUser.ID != "u1" { + t.Errorf("scope user not set: %+v", sawUser) + } +} + +func TestMiddlewareCapturesGinContextErrors(t *testing.T) { + c := obs.NewClient() + c.Init(obs.ClientOptions{DSN: "x"}) + var captured int + c.RegisterTransport(func(b []obs.ObservabilityEvent) { captured += len(b) }) + + r := gin.New() + r.Use(Middleware(Options{Client: c})) + r.GET("/", func(gc *gin.Context) { + // Idiomatic Gin error reporting. + _ = gc.Error(errors.New("downstream boom")) + gc.Status(http.StatusBadGateway) + }) + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + if rr.Code != http.StatusBadGateway { + t.Errorf("status = %d, want 502", rr.Code) + } + if captured != 1 { + t.Errorf("expected 1 captured event, got %d", captured) + } +} + +func TestMiddlewareCapturesPanicAndRethrows(t *testing.T) { + c := obs.NewClient() + c.Init(obs.ClientOptions{DSN: "x"}) + var captured int + c.RegisterTransport(func(b []obs.ObservabilityEvent) { captured += len(b) }) + + // Pair with Gin's Recovery so the re-panic is turned into a 500 response. + r := gin.New() + r.Use(gin.Recovery()) + r.Use(Middleware(Options{Client: c})) // default: rethrow + r.GET("/", func(gc *gin.Context) { + panic("handler exploded") + }) + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500 (Gin Recovery after re-panic)", rr.Code) + } + if captured != 1 { + t.Errorf("expected 1 captured event, got %d", captured) + } +} + +func TestMiddlewareSwallowPanics(t *testing.T) { + c := obs.NewClient() + c.Init(obs.ClientOptions{DSN: "x"}) + var captured int + c.RegisterTransport(func(b []obs.ObservabilityEvent) { captured += len(b) }) + + // No gin.Recovery — SwallowPanics aborts with a 500 itself. + r := gin.New() + r.Use(Middleware(Options{Client: c, SwallowPanics: true})) + r.GET("/", func(gc *gin.Context) { + panic("boom") + }) + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", rr.Code) + } + if captured != 1 { + t.Errorf("expected 1 captured event, got %d", captured) + } +} + +func TestMiddlewarePassThroughWhenUninitialized(t *testing.T) { + c := obs.NewClient() // not initialized + called := false + + r := gin.New() + r.Use(Middleware(Options{Client: c})) + r.GET("/", func(gc *gin.Context) { + called = true + if obs.ScopeFromContext(gc.Request.Context()) == nil { + t.Error("nil scope") + } + gc.Status(http.StatusOK) + }) + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + if !called { + t.Error("handler not called") + } +} diff --git a/go/middleware.go b/go/middleware.go index 26b7af9..cc0c4b7 100644 --- a/go/middleware.go +++ b/go/middleware.go @@ -13,9 +13,9 @@ import ( // exceptions (status 500) before re-panicking so the host's own recovery // can still run. // -// Fiber / Gin / Echo integrations are deferred — see the gap note in the README. -// They can be written as thin adapters over the same Scope + CaptureException -// primitives. +// Fiber and Gin integrations live in their own modules (go/fiber, go/gin) as +// thin adapters over the same Scope + CaptureException primitives. Echo is still +// deferred — see the gap note in the README. // MiddlewareOptions configures Middleware. type MiddlewareOptions struct { diff --git a/go/scope.go b/go/scope.go index cda27b0..27733bc 100644 --- a/go/scope.go +++ b/go/scope.go @@ -39,6 +39,20 @@ func (s *Scope) SetUser(u *User) { s.user = u } +// User returns the scope's current user (nil if unset). Returns a copy so +// callers can't mutate scope state through the pointer. Symmetric with SetUser +// and used by the framework adapters (and their tests) to inspect request scope +// across package boundaries. +func (s *Scope) User() *User { + s.mu.Lock() + defer s.mu.Unlock() + if s.user == nil { + return nil + } + u := *s.user + return &u +} + // SetTag sets a single tag. func (s *Scope) SetTag(key, value string) { s.mu.Lock() From 391d5d5384970aefd353aecc4b0bad431045451c Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 20 Jun 2026 20:41:42 -0400 Subject: [PATCH 2/2] SMOODEV-2023: run Go CI vet/test per-module (cover Fiber/Gin submodules) --- .github/workflows/pr-checks.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 5514d62..2df74a0 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -146,11 +146,21 @@ jobs: exit 1 fi + # The Fiber/Gin adapters are separate Go modules (own go.mod) so the + # core stays dependency-free; vet/test must run per-module. - name: Vet - run: go vet ./... + run: | + for m in . fiber gin; do + [ -f "$m/go.mod" ] || continue + echo "::group::go vet ($m)"; (cd "$m" && go vet ./...); echo "::endgroup::" + done - name: Test - run: go test ./... + run: | + for m in . fiber gin; do + [ -f "$m/go.mod" ] || continue + echo "::group::go test ($m)"; (cd "$m" && go test ./...); echo "::endgroup::" + done # Python lane — ruff lint + format, pytest. Package in python/. python: