From 61763d05af1130b3f29e84f2304dcec98abb7563 Mon Sep 17 00:00:00 2001 From: DevMiner Date: Tue, 23 Jul 2024 17:46:15 +0200 Subject: [PATCH] chore: init --- fiber_middleware.go | 280 ++++++++++++++++++++++++++++++++++++++++++++ go.mod | 51 ++++++++ go.sum | 128 ++++++++++++++++++++ helper.go | 40 +++++++ pgx_tracing.go | 98 ++++++++++++++++ request_logger.go | 85 ++++++++++++++ telemetry.go | 240 +++++++++++++++++++++++++++++++++++++ traced_transport.go | 78 ++++++++++++ tracing.go | 228 ++++++++++++++++++++++++++++++++++++ version.go | 6 + 10 files changed, 1234 insertions(+) create mode 100644 fiber_middleware.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 helper.go create mode 100644 pgx_tracing.go create mode 100644 request_logger.go create mode 100644 telemetry.go create mode 100644 traced_transport.go create mode 100644 tracing.go create mode 100644 version.go diff --git a/fiber_middleware.go b/fiber_middleware.go new file mode 100644 index 0000000..4a4ada2 --- /dev/null +++ b/fiber_middleware.go @@ -0,0 +1,280 @@ +package unitel + +import ( + "fmt" + "net/http" + "slices" + "time" + + "github.com/getsentry/sentry-go" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/utils" + "github.com/rs/zerolog/log" + "github.com/valyala/fasthttp/fasthttpadaptor" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/propagation" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "go.opentelemetry.io/otel/trace" +) + +const fiberMwClientID = libBase + "#FiberMiddleware" + +type FiberMiddlewareConfig struct { + Repanic bool + WaitForDelivery bool + Timeout time.Duration + TraceRequestHeaders []string + TraceResponseHeaders []string + IgnoredRoutes []string +} + +var fiberMiddlewareConfigDefault = FiberMiddlewareConfig{ + Repanic: false, + WaitForDelivery: false, + Timeout: time.Second * 2, + TraceRequestHeaders: []string{}, + TraceResponseHeaders: []string{}, + IgnoredRoutes: []string{}, +} + +func newFiberMiddlewareTracer(tp trace.TracerProvider) trace.Tracer { + return tp.Tracer(fiberMwClientID, trace.WithInstrumentationVersion(libVersion)) +} + +func newFiberMiddlewareMeter(mp metric.MeterProvider) metric.Meter { + return mp.Meter(fiberMwClientID, metric.WithInstrumentationVersion(libVersion)) +} + +func (t *Telemetry) FiberMiddleware(config ...FiberMiddlewareConfig) fiber.Handler { + cfg := fiberMiddlewareConfigDefault + if len(config) > 0 { + cfg = config[0] + } + if cfg.Timeout == 0 { + cfg.Timeout = time.Second * 2 + } + if cfg.TraceRequestHeaders == nil { + cfg.TraceRequestHeaders = []string{} + } + if cfg.TraceResponseHeaders == nil { + cfg.TraceResponseHeaders = []string{} + } + if cfg.IgnoredRoutes == nil { + cfg.IgnoredRoutes = []string{} + } + + meter := newFiberMiddlewareMeter(t.meterProvider) + tracer := newFiberMiddlewareTracer(t.tracerProvider) + + mDuration, err := meter.Float64Histogram( + "http.server.duration", + metric.WithDescription("HTTP request response times"), + metric.WithUnit("ms"), + ) + if err != nil { + log.Fatal().Err(err).Msg("failed to create request duration histogram") + } + + mActiveRequests, err := meter.Int64UpDownCounter( + "http.server.active_requests", + metric.WithDescription("Number of in-flight HTTP requests"), + metric.WithUnit("1"), + ) + if err != nil { + log.Fatal().Err(err).Msg("failed to create active requests counter") + } + + mRequestSize, err := meter.Int64Histogram( + "http.server.request.size", + metric.WithUnit("By"), + metric.WithDescription("HTTP request sizes"), + ) + if err != nil { + log.Fatal().Err(err).Msg("failed to create request size histogram") + } + + mResponseSize, err := meter.Int64Histogram( + "http.server.response.size", + metric.WithUnit("By"), + metric.WithDescription("HTTP response sizes"), + ) + if err != nil { + log.Fatal().Err(err).Msg("failed to create response size histogram") + } + + return func(c *fiber.Ctx) error { + // Skip ignored routes (/ping for example) + if slices.Contains(cfg.IgnoredRoutes, c.Path()) { + return c.Next() + } + + start := time.Now() + + requestMetricsAttrs := httpServerTraceAttributesFromRequest(c) + mActiveRequests.Add(c.Context(), 1, metric.WithAttributes(requestMetricsAttrs...)) + + responseMetricAttrs := make([]attribute.KeyValue, len(requestMetricsAttrs)) + copy(responseMetricAttrs, requestMetricsAttrs) + + var stdRequest http.Request + if err := fasthttpadaptor.ConvertRequest(c.Context(), &stdRequest, true); err != nil { + return err + } + + ctx := t.propagator.Extract(c.UserContext(), propagation.HeaderCarrier(stdRequest.Header)) + + hub := sentry.CurrentHub().Clone() + if client := hub.Client(); client != nil { + client.SetSDKIdentifier(fiberMwClientID) + } + + scope := hub.Scope() + scope.SetRequest(&stdRequest) + scope.SetRequestBody(utils.CopyBytes(c.Body())) + ctx = sentry.SetHubOnContext(ctx, hub) + + description := fmt.Sprintf("%s %s", c.Method(), c.Path()) + span := t.StartSpan( + ctx, + "http.server", + description, + WithOtelOptions(trace.WithSpanKind(trace.SpanKindServer)), + WithOtelTracer(tracer), + t.ContinueFromRequest(&stdRequest), + ) + defer func() { + // TODO: Report panics properly + if err := recover(); err != nil { + timeout := (*time.Duration)(nil) + if cfg.WaitForDelivery { + timeout = &cfg.Timeout + } + + span.Recover(ctx, fmt.Errorf("%v", err), timeout) + + if cfg.Repanic { + panic(err) + } + } + }() + defer span.End() + + defer func() { + h := propagation.HeaderCarrier{} + t.propagator.Inject(ctx, h) + + for _, k := range h.Keys() { + c.Set(k, h.Get(k)) + } + }() + + span.AddAttributes(httpServerTraceAttributesFromRequest(c)...) + for _, k := range cfg.TraceRequestHeaders { + if h := c.Get(k); h != "" { + span.AddAttributes(attribute.String(fmt.Sprintf("http.request.header.%s", k), h)) + } + } + ctx = span.Context() + c.SetUserContext(ctx) + + var err error = nil + if err = c.Next(); err != nil { + shouldReport := false + + switch err := err.(type) { + case *fiber.Error: + shouldReport = err.Code >= http.StatusInternalServerError + default: + shouldReport = true + } + + if shouldReport { + span.CaptureError(err) + } + + err = c.App().Config().ErrorHandler(c, err) + } + + defer func() { + responseAttrs := []attribute.KeyValue{ + semconv.HTTPResponseStatusCode(c.Response().StatusCode()), + semconv.HTTPRouteKey.String(c.Route().Path), + } + + requestSize := int64(len(c.Request().Body())) + responseSize := int64(len(c.Response().Body())) + + responseMetricAttrs = append(responseMetricAttrs, responseAttrs...) + + mActiveRequests.Add(c.Context(), -1, metric.WithAttributes(requestMetricsAttrs...)) + mDuration.Record(ctx, float64(time.Since(start).Milliseconds()), metric.WithAttributes(responseMetricAttrs...)) + mRequestSize.Record(ctx, requestSize, metric.WithAttributes(responseMetricAttrs...)) + mResponseSize.Record(ctx, responseSize, metric.WithAttributes(responseMetricAttrs...)) + + span. + AddAttributes(responseAttrs...). + AddAttributes(attribute.Int64("http.request.headers.content-length", requestSize)). + SetName(c.Route().Path). + SetStatus(httpStatusToSpanStatus(c.Response().StatusCode(), true), "") + + for _, k := range cfg.TraceResponseHeaders { + if h := c.GetRespHeader(k); h != "" { + span.AddAttributes(attribute.String(fmt.Sprintf("http.response.header.%s", k), h)) + } + } + }() + + return err + } +} + +func httpStatusToSpanStatus(code int, isServer bool) SpanStatus { + sentryStatus := sentry.HTTPtoSpanStatus(code) + + if code < http.StatusBadRequest { + return SpanStatus{codes.Ok, sentryStatus} + } + + if code < http.StatusInternalServerError { + // For HTTP status codes in the 4xx range span status MUST be left unset + // in case of SpanKind.SERVER and MUST be set to Error in case of SpanKind.CLIENT. + if isServer { + return SpanStatus{codes.Unset, sentryStatus} + } + + return SpanStatus{codes.Error, sentryStatus} + } + + return SpanStatus{codes.Error, sentryStatus} +} + +func httpFlavorAttribute(c *fiber.Ctx) attribute.KeyValue { + if c.Request().Header.IsHTTP11() { + return semconv.NetworkProtocolName("HTTP/1.1") + } + + return semconv.NetworkProtocolName("HTTP/1.0") +} + +func httpServerTraceAttributesFromRequest(c *fiber.Ctx) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + httpFlavorAttribute(c), + semconv.HTTPRequestMethodKey.String(utils.CopyString(c.Method())), + attribute.Int("http.response.header.content-length", c.Request().Header.ContentLength()), + semconv.URLScheme(utils.CopyString(c.Protocol())), + semconv.URLPath(utils.CopyString(string(c.Request().RequestURI()))), + semconv.URLFull(utils.CopyString(c.OriginalURL())), + semconv.ServerAddress(utils.CopyString(c.Hostname())), + semconv.UserAgentOriginalKey.String(utils.CopyString(string(c.Request().Header.UserAgent()))), + semconv.NetworkTransportTCP, + } + + clientIP := c.IP() + if len(clientIP) > 0 { + attrs = append(attrs, semconv.ClientAddressKey.String(utils.CopyString(clientIP))) + } + + return attrs +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0744c15 --- /dev/null +++ b/go.mod @@ -0,0 +1,51 @@ +module git.devminer.xyz/devminer/unitel + +go 1.22.5 + +require ( + github.com/getsentry/sentry-go v0.28.1 + github.com/gofiber/fiber/v2 v2.52.5 + github.com/jackc/pgx/v5 v5.6.0 + github.com/qustavo/sqlhooks/v2 v2.1.0 + github.com/rs/zerolog v1.33.0 + github.com/valyala/fasthttp v1.55.0 + go.opentelemetry.io/otel v1.28.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.28.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0 + go.opentelemetry.io/otel/metric v1.28.0 + go.opentelemetry.io/otel/sdk v1.28.0 + go.opentelemetry.io/otel/sdk/metric v1.28.0 + go.opentelemetry.io/otel/trace v1.28.0 +) + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/go-logr/logr v1.4.2 // 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.20.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/klauspost/compress v1.17.9 // 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/tcplisten v1.0.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect + google.golang.org/grpc v1.64.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3fd06ef --- /dev/null +++ b/go.sum @@ -0,0 +1,128 @@ +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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/getsentry/sentry-go v0.28.1 h1:zzaSm/vHmGllRM6Tpx1492r0YDzauArdBfkJRtY6P5k= +github.com/getsentry/sentry-go v0.28.1/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/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-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +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.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= +github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/qustavo/sqlhooks/v2 v2.1.0 h1:54yBemHnGHp/7xgT+pxwmIlMSDNYKx5JW5dfRAiCZi0= +github.com/qustavo/sqlhooks/v2 v2.1.0/go.mod h1:aMREyKo7fOKTwiLuWPsaHRXEmtqG4yREztO0idF83AU= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= +github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= +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/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0 h1:U2guen0GhqH8o/G2un8f/aG/y++OuW6MyCo6hT9prXk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0/go.mod h1:yeGZANgEcpdx/WK0IvvRFC+2oLiMS2u4L/0Rj2M2Qr0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.28.0 h1:BJee2iLkfRfl9lc7aFmBwkWxY/RI1RDdXepSF6y8TPE= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.28.0/go.mod h1:DIzlHs3DRscCIBU3Y9YSzPfScwnYnzfnCd4g8zA7bZc= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0 h1:EVSnY9JbEEW92bEkIYOVMw4q1WJxIAGoFTrtYOzWuRQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0/go.mod h1:Ea1N1QQryNXpCD0I1fdLibBAIpQuBkznMmkdKrapk1Y= +go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= +go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= +go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE= +go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= +go.opentelemetry.io/otel/sdk/metric v1.28.0 h1:OkuaKgKrgAbYrrY0t92c+cC+2F6hsFNnCQArXCKlg08= +go.opentelemetry.io/otel/sdk/metric v1.28.0/go.mod h1:cWPjykihLAPvXKi4iZc1dpER3Jdq2Z0YLse3moQUCpg= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +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/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 h1:0+ozOGcrp+Y8Aq8TLNN2Aliibms5LEzsq99ZZmAGYm0= +google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094/go.mod h1:fJ/e3If/Q67Mj99hin0hMhiNyCRmt6BQ2aWIJshUSJw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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= diff --git a/helper.go b/helper.go new file mode 100644 index 0000000..19e2399 --- /dev/null +++ b/helper.go @@ -0,0 +1,40 @@ +package unitel + +import ( + "context" + + "github.com/getsentry/sentry-go" + "github.com/gofiber/fiber/v2" +) + +func GetHubFromFiberContext(ctx *fiber.Ctx) *sentry.Hub { + return sentry.GetHubFromContext(ctx.UserContext()) +} + +func MustGetHubFromContext(ctx context.Context) *sentry.Hub { + hub := sentry.GetHubFromContext(ctx) + if hub == nil { + panic("sentry hub not found in context") + } + + return hub +} + +func AddBreadcrumbToFiberCtx(c *fiber.Ctx, breadcrumb *sentry.Breadcrumb, hint *sentry.BreadcrumbHint) { + if hub := GetHubFromFiberContext(c); hub != nil { + hub.AddBreadcrumb(breadcrumb, hint) + } +} + +func AddBreadcrumbToContext(c context.Context, breadcrumb *sentry.Breadcrumb) { + if hub := sentry.GetHubFromContext(c); hub != nil { + hub.AddBreadcrumb(breadcrumb, nil) + } +} + +func (o *Telemetry) Trace(ctx context.Context, op string, options []ConfigureSpanStartFunc, fn func(context.Context)) { + tx := o.StartSpan(ctx, op, op, options...) + defer tx.End() + + fn(tx.Context()) +} diff --git a/pgx_tracing.go b/pgx_tracing.go new file mode 100644 index 0000000..06b8b82 --- /dev/null +++ b/pgx_tracing.go @@ -0,0 +1,98 @@ +package unitel + +import ( + "context" + "database/sql" + "fmt" + "strings" + + pgx "github.com/jackc/pgx/v5/stdlib" + "github.com/qustavo/sqlhooks/v2" + "github.com/rs/zerolog/log" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +const ( + pgxClientID = libBase + "#TracedPGx" + TracedPGxDriverName = "pgx-traced" +) + +type tracedPgxHooks struct { + t *Telemetry + printQueries bool + tracer trace.Tracer +} + +func (h *tracedPgxHooks) Before(ctx context.Context, query string, args ...interface{}) (context.Context, error) { + cleanedQuery := strings.ReplaceAll(query, "\n", " ") + cleanedQuery = strings.ReplaceAll(cleanedQuery, "\t", " ") + cleanedQuery = strings.ReplaceAll(cleanedQuery, " ", " ") + cleanedQuery = strings.TrimSpace(cleanedQuery) + + s := h.t.StartSpan(ctx, "db.sql.query", query) + s.AddAttributes( + attribute.String("db.system", "postgres"), + attribute.String("db.statement", cleanedQuery), + attribute.StringSlice("db.params", formatArgs(args)), + ) + + if h.printQueries { + l := log.Trace() + + for i, arg := range args { + l = l.Interface(fmt.Sprintf("arg%d", i), arg) + } + + l.Msg(cleanedQuery) + } + + return s.Context(), nil +} + +func (h *tracedPgxHooks) After(ctx context.Context, query string, args ...interface{}) (context.Context, error) { + if s := SpanFromContext(ctx); s != nil { + s.End() + } + + return ctx, nil +} + +func (t *Telemetry) RegisterTracedPGx(printQueries bool) { + tracer := t.tracerProvider.Tracer(pgxClientID, trace.WithInstrumentationVersion(libVersion)) + + sql.Register(TracedPGxDriverName, sqlhooks.Wrap(&pgx.Driver{}, &tracedPgxHooks{ + printQueries: printQueries, + t: t, + tracer: tracer, + })) + + log.Debug().Msgf("Registered %s driver", TracedPGxDriverName) +} + +func formatArgs(args []interface{}) []string { + formattedArgs := make([]string, len(args)) + + for i, arg := range args { + switch v := arg.(type) { + case int: + formattedArgs[i] = fmt.Sprint(v) + case int64: + formattedArgs[i] = fmt.Sprint(v) + case float64: + formattedArgs[i] = fmt.Sprint(v) + case string: + formattedArgs[i] = v + case []byte: + formattedArgs[i] = string(v) + case bool: + formattedArgs[i] = fmt.Sprint(v) + case fmt.Stringer: + formattedArgs[i] = v.String() + default: + formattedArgs[i] = fmt.Sprintf("%+v", v) + } + } + + return formattedArgs +} diff --git a/request_logger.go b/request_logger.go new file mode 100644 index 0000000..2ec4961 --- /dev/null +++ b/request_logger.go @@ -0,0 +1,85 @@ +package unitel + +import ( + "time" + + "github.com/getsentry/sentry-go" + "github.com/gofiber/fiber/v2" + "github.com/rs/zerolog" + "go.opentelemetry.io/otel/trace" +) + +const requestBufferSize = 1000 + +func RequestLogger(l zerolog.Logger) fiber.Handler { + type request struct { + Method string + Path string + Status int + Duration time.Duration + OtelSpanID *trace.SpanID + SentrySpanID *sentry.SpanID + } + + reqs := make(chan request, requestBufferSize) + + go func() { + for { + r := <-reqs + + l2 := l.Trace(). + Str("method", r.Method). + Str("path", r.Path). + Int("status", r.Status). + Dur("duration", r.Duration) + + if r.OtelSpanID != nil { + l2 = l2.Str("otel_span_id", r.OtelSpanID.String()) + } + + if r.SentrySpanID != nil { + l2 = l2.Str("sentry_span_id", r.SentrySpanID.String()) + } + + l2.Msg("request") + } + }() + + return func(c *fiber.Ctx) error { + var otelSpanId *trace.SpanID = nil + if spanId := trace.SpanContextFromContext(c.UserContext()).SpanID(); spanId.IsValid() { + otelSpanId = &spanId + } + + var sentrySpanId *sentry.SpanID = nil + if span := sentry.SpanFromContext(c.UserContext()); span != nil { + sentrySpanId = &span.SpanID + } + + start := time.Now() + err := c.Next() + duration := time.Since(start) + + status := c.Response().StatusCode() + + if err != nil { + if e, ok := err.(*fiber.Error); ok { + status = e.Code + } else { + status = fiber.StatusInternalServerError + } + } + + // pushing the request to the logger, so we don't block the requests + reqs <- request{ + Method: c.Method(), + Path: c.Path(), + Status: status, + Duration: duration, + OtelSpanID: otelSpanId, + SentrySpanID: sentrySpanId, + } + + return err + } +} diff --git a/telemetry.go b/telemetry.go new file mode 100644 index 0000000..6d69d30 --- /dev/null +++ b/telemetry.go @@ -0,0 +1,240 @@ +package unitel + +import ( + "context" + "os" + "time" + + "github.com/getsentry/sentry-go" + "github.com/rs/zerolog/log" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +type otlpContextKey struct{} + +type Opts struct { + Environment string + ServiceName string + OTLPEndpoint string + SentryDSN string +} + +func ParseOpts(serviceName string) Opts { + return Opts{ + Environment: os.Getenv("ENVIRONMENT"), + ServiceName: serviceName, + OTLPEndpoint: os.Getenv("OTLP_ENDPOINT"), + SentryDSN: os.Getenv("SENTRY_DSN"), + } +} + +type Telemetry struct { + opts Opts + propagator propagation.TextMapPropagator + tracerProvider trace.TracerProvider + meterProvider metric.MeterProvider + Tracer trace.Tracer + Meter metric.Meter +} + +func (o *Telemetry) Shutdown(ctx context.Context) { + if tp, ok := o.tracerProvider.(*sdktrace.TracerProvider); ok { + if err := tp.Shutdown(ctx); err != nil { + log.Error().Err(err).Msg("failed to shutdown TracerProvider") + } + } + + if mp, ok := o.meterProvider.(*sdkmetric.MeterProvider); ok { + if err := mp.Shutdown(ctx); err != nil { + log.Error().Err(err).Msg("failed to stop MeterProvider") + } + } + + if o.opts.SentryDSN != "" { + if ok := sentry.Flush(5 * time.Second); !ok { + log.Error().Msg("failed to flush Sentry") + } + } +} + +func SetOnContext(o *Telemetry, ctx context.Context) context.Context { + return context.WithValue(ctx, otlpContextKey{}, o) +} +func FromContext(ctx context.Context) *Telemetry { + return ctx.Value(otlpContextKey{}).(*Telemetry) +} + +func newSpanExporter(ctx context.Context, opts Opts) (sdktrace.SpanExporter, error) { + return otlptracegrpc.New( + ctx, + otlptracegrpc.WithInsecure(), + otlptracegrpc.WithEndpoint(opts.OTLPEndpoint), + otlptracegrpc.WithReconnectionPeriod(time.Second*5), + ) +} + +func newMetricExporter(ctx context.Context, opts Opts) (*otlpmetricgrpc.Exporter, error) { + return otlpmetricgrpc.New( + ctx, + otlpmetricgrpc.WithInsecure(), + otlpmetricgrpc.WithEndpoint(opts.OTLPEndpoint), + otlpmetricgrpc.WithReconnectionPeriod(time.Second*5), + ) +} + +func newTextMapPropagator() propagation.TextMapPropagator { + return propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ) +} + +func getSampleRate(opts Opts) float64 { + switch opts.Environment { + case "development": + return 1.0 + case "staging": + return 0.5 + case "production": + return 0.2 + default: + return 1.0 + } +} + +func initializeSentry(opts Opts) error { + if opts.SentryDSN == "" { + return nil + } + + return sentry.Init(sentry.ClientOptions{ + Dsn: opts.SentryDSN, + Release: libVersion, + AttachStacktrace: true, + SampleRate: getSampleRate(opts), + EnableTracing: true, + TracesSampleRate: 1.0, + ProfilesSampleRate: 1.0, + Debug: false, + Environment: opts.Environment, + BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + // Only send stuff to Sentry when it includes exceptions + if len(event.Exception) == 0 { + return nil + } + + return event + }, + // BeforeSendTransaction: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + // // Only send stuff to Sentry when it includes exceptions + // if len(event.Exception) == 0 { + // return nil + // } + + // return event + // }, + }) +} + +func Initialize(opts Opts) (*Telemetry, error) { + propagator := newTextMapPropagator() + otel.SetTextMapPropagator(propagator) + + r, err := resource.Merge( + resource.Default(), + resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName(opts.ServiceName), + semconv.ServiceVersion(libVersion), + semconv.DeploymentEnvironment(opts.Environment), + ), + ) + if err != nil { + return nil, err + } + + var tracerProvider trace.TracerProvider + if opts.OTLPEndpoint == "" { + tracerProvider = noop.NewTracerProvider() + } else { + var exp sdktrace.SpanExporter + var err error + if opts.OTLPEndpoint == "console" { + exp, err = stdouttrace.New(stdouttrace.WithPrettyPrint()) + } else { + exp, err = newSpanExporter(context.Background(), opts) + } + + if err != nil { + return nil, err + } + + tracerProvider = sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exp), + sdktrace.WithResource(r), + sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(getSampleRate(opts)))), + ) + } + + var metricProvider metric.MeterProvider + if opts.OTLPEndpoint == "" { + // noop meter provider + metricProvider = sdkmetric.NewMeterProvider(sdkmetric.WithResource(r)) + } else { + var exp sdkmetric.Exporter + if opts.OTLPEndpoint == "console" { + exp, err = stdoutmetric.New(stdoutmetric.WithPrettyPrint()) + } else { + exp, err = newMetricExporter(context.Background(), opts) + } + + if err != nil { + return nil, err + } + + metricProvider = sdkmetric.NewMeterProvider( + sdkmetric.WithResource(r), + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp, sdkmetric.WithInterval(15*time.Second))), + ) + } + + otel.SetTracerProvider(tracerProvider) + otel.SetMeterProvider(metricProvider) + + if err := initializeSentry(opts); err != nil { + return nil, err + } + + return &Telemetry{ + opts: opts, + propagator: propagator, + tracerProvider: tracerProvider, + meterProvider: metricProvider, + Tracer: tracerProvider.Tracer(opts.ServiceName), + Meter: metricProvider.Meter(opts.ServiceName), + }, nil +} + +func (t *Telemetry) HandleError(ctx context.Context, err error) { + if span := SpanFromContext(ctx); span != nil { + span.CaptureError(err) + } else { + if hub := sentry.CurrentHub().Clone(); hub != nil { + hub.CaptureException(err) + } + + otel.Handle(err) + } +} diff --git a/traced_transport.go b/traced_transport.go new file mode 100644 index 0000000..1e450f6 --- /dev/null +++ b/traced_transport.go @@ -0,0 +1,78 @@ +package unitel + +import ( + "fmt" + "net/http" + + "github.com/getsentry/sentry-go" + "go.opentelemetry.io/otel/attribute" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "go.opentelemetry.io/otel/trace" +) + +const httpClientTransportClientID = libBase + "#HTTPTansport" + +type HTTPTransport struct { + telemetry *Telemetry + forwardTrace bool + tracedRequestHeaders []string + tracedResponseHeaders []string + Transport http.RoundTripper + + tracer trace.Tracer +} + +func (t *Telemetry) NewTracedTransport(transport http.RoundTripper, forwardTrace bool, tracedRequestHeaders []string, tracedResponseHeaders []string) *HTTPTransport { + return &HTTPTransport{ + telemetry: t, + forwardTrace: forwardTrace, + tracedRequestHeaders: tracedRequestHeaders, + tracedResponseHeaders: tracedResponseHeaders, + Transport: transport, + + tracer: t.tracerProvider.Tracer(httpClientTransportClientID, trace.WithInstrumentationVersion(libVersion)), + } +} + +func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + span := t.telemetry. + StartSpan(req.Context(), "http.client", fmt.Sprintf("%s %s", req.Method, req.URL), WithOtelOptions(trace.WithSpanKind(trace.SpanKindClient))). + AddAttributes( + semconv.HTTPRequestMethodKey.String(req.Method), + semconv.URLFull(req.URL.String()), + ) + defer span.End() + ctx := span.Context() + req = req.WithContext(ctx) + + if t.forwardTrace { + t.telemetry.InjectIntoHeaders(ctx, req.Header) + } + + for _, header := range t.tracedRequestHeaders { + if v := req.Header.Get(header); v != "" { + span.AddAttributes(attribute.String(fmt.Sprintf("http.request.header.%s", header), v)) + } + } + + resp, err := t.Transport.RoundTrip(req) + if err != nil { + if hub := sentry.GetHubFromContext(ctx); hub != nil { + hub.CaptureException(err) + } + + return resp, err + } + + span. + AddAttributes(semconv.HTTPResponseStatusCode(resp.StatusCode)). + SetStatus(httpStatusToSpanStatus(resp.StatusCode, false), "") + + for _, header := range t.tracedResponseHeaders { + if v := resp.Header.Get(header); v != "" { + span.AddAttributes(attribute.String(fmt.Sprintf("http.response.header.%s", header), v)) + } + } + + return resp, err +} diff --git a/tracing.go b/tracing.go new file mode 100644 index 0000000..6a30283 --- /dev/null +++ b/tracing.go @@ -0,0 +1,228 @@ +package unitel + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/getsentry/sentry-go" + "github.com/rs/zerolog/log" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/propagation" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "go.opentelemetry.io/otel/trace" +) + +type contextKey struct { + Key string +} + +var ( + tracerContextKey = contextKey{"tracer"} + spanContextKey = contextKey{"span"} +) + +type Span struct { + otel trace.Span + sentry *sentry.Span +} + +func (t *Telemetry) StartSpan(ctx context.Context, operation, name string, cfgs ...ConfigureSpanStartFunc) *Span { + otelStartOpts := make([]trace.SpanStartOption, 0) + sentryStartOpts := []sentry.SpanOption{sentry.WithTransactionName(name), sentry.WithDescription(name)} + + for _, opt := range cfgs { + var otel []trace.SpanStartOption + var sentry []sentry.SpanOption + + ctx, otel, sentry = opt(ctx) + + otelStartOpts = append(otelStartOpts, otel...) + sentryStartOpts = append(sentryStartOpts, sentry...) + } + + var otelTracer trace.Tracer + if tracer, ok := ctx.Value(tracerContextKey).(trace.Tracer); ok && tracer != nil { + otelTracer = tracer + } else { + otelTracer = t.Tracer + } + + ctx, otelSpan := otelTracer.Start(ctx, name, otelStartOpts...) + sentrySpan := sentry.StartSpan(ctx, operation, sentryStartOpts...) + + return &Span{ + otel: otelSpan, + sentry: sentrySpan, + } +} + +func (s *Span) Context() context.Context { + return context.WithValue(s.sentry.Context(), spanContextKey, s) +} + +func (s *Span) AddAttributes(attributes ...attribute.KeyValue) *Span { + for _, attr := range attributes { + s.sentry.SetData(string(attr.Key), attr.Value.AsInterface()) + } + + s.otel.SetAttributes(attributes...) + + return s +} + +func (s *Span) AddAttribute(key string, value any) *Span { + var attr attribute.KeyValue + switch v := value.(type) { + case string: + attr = attribute.String(key, v) + case fmt.Stringer: + attr = attribute.String(key, v.String()) + default: + attr = attribute.String(key, fmt.Sprintf("%v", v)) + } + + return s.AddAttributes(attr) +} + +type SpanStatus struct { + Otel codes.Code + Sentry sentry.SpanStatus +} + +func (s *Span) SetStatus(status SpanStatus, description string) *Span { + s.otel.SetStatus(status.Otel, description) + s.sentry.Status = status.Sentry + + return s +} + +type SimpleStatus uint8 + +const ( + Unset SimpleStatus = 0 + Error SimpleStatus = 1 + Ok SimpleStatus = 2 +) + +func (s *Span) SetSimpleStatus(status SimpleStatus, description string) *Span { + var otelStatus codes.Code + var sentryStatus sentry.SpanStatus + + switch status { + case Error: + otelStatus = codes.Error + sentryStatus = sentry.SpanStatusInternalError + case Ok: + otelStatus = codes.Ok + sentryStatus = sentry.SpanStatusOK + default: + otelStatus = codes.Unset + sentryStatus = sentry.SpanStatusUndefined + } + + return s.SetStatus(SpanStatus{Otel: otelStatus, Sentry: sentryStatus}, description) +} + +func (s *Span) CaptureError(err error) *Span { + if hub := sentry.GetHubFromContext(s.Context()); hub != nil { + hub.CaptureException(err) + } + + s.otel.RecordError(err, trace.WithStackTrace(true)) + + return s +} + +func (s *Span) SetName(name string) *Span { + s.otel.SetName(name) + s.sentry.Name = name + + return s +} + +func (s *Span) SetUser(id uint64, username, ip, permissions string) *Span { + uid := fmt.Sprintf("%d", id) + + if hub := sentry.GetHubFromContext(s.Context()); hub != nil { + hub.Scope().SetUser(sentry.User{ + ID: uid, + Username: username, + IPAddress: ip, + }) + } + + s.otel.SetAttributes( + semconv.EnduserID(uid), + semconv.EnduserScope(permissions), + attribute.String(libBase+"/username", username), + semconv.ClientAddress(ip), + ) + + return s +} + +func (s *Span) End() { + s.otel.End() + s.sentry.Finish() +} + +type ConfigureSpanStartFunc = func(context.Context) (context.Context, []trace.SpanStartOption, []sentry.SpanOption) + +func WithOtelOptions(opts ...trace.SpanStartOption) ConfigureSpanStartFunc { + return func(ctx context.Context) (context.Context, []trace.SpanStartOption, []sentry.SpanOption) { + return ctx, opts, []sentry.SpanOption{} + } +} + +func (t *Telemetry) InjectIntoHeaders(ctx context.Context, headers http.Header) { + t.propagator.Inject(ctx, propagation.HeaderCarrier(headers)) + + if sentrySpan := sentry.SpanFromContext(ctx); sentrySpan == nil { + // this should never happen... + log.Trace().Msgf("failed to inject Sentry span ID, Sentry span could not be extracted from the context.Context") + } else { + headers.Set("sentry-trace", sentrySpan.ToSentryTrace()) + headers.Set("sentry-baggage", sentrySpan.ToBaggage()) + } +} + +func (t *Telemetry) ContinueFromRequest(r *http.Request) ConfigureSpanStartFunc { + return func(ctx context.Context) (context.Context, []trace.SpanStartOption, []sentry.SpanOption) { + ctx = t.propagator.Extract(ctx, propagation.HeaderCarrier(r.Header)) + + return ctx, []trace.SpanStartOption{}, []sentry.SpanOption{sentry.ContinueFromRequest(r), sentry.WithTransactionSource(sentry.SourceURL)} + } +} + +func WithOtelTracer(tracer trace.Tracer) ConfigureSpanStartFunc { + return func(ctx context.Context) (context.Context, []trace.SpanStartOption, []sentry.SpanOption) { + return context.WithValue(ctx, tracerContextKey, tracer), []trace.SpanStartOption{}, []sentry.SpanOption{} + } +} + +func SpanFromContext(ctx context.Context) *Span { + if span, ok := ctx.Value(spanContextKey).(*Span); ok { + return span + } + + return nil +} + +func (s *Span) Recover(ctx context.Context, err error, flushTimeout *time.Duration) { + s.CaptureError(err) + + hub := sentry.GetHubFromContext(s.Context()) + if hub == nil { + return + } + + // context.WithValue(context.Background(), sentry.RequestContextKey, c), + eventID := hub.RecoverWithContext(ctx, err) + + if flushTimeout != nil && eventID != nil { + hub.Flush(*flushTimeout) + } +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..95af79c --- /dev/null +++ b/version.go @@ -0,0 +1,6 @@ +package unitel + +const ( + libBase = "git.devminer.xyz/devminer/unitel" + libVersion = "0.0.1" +)