diff --git a/README.md b/README.md index 5e9545e..575ce67 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ The following environment variables can be used to configure the API. | `TIBIADATA_LOG_CF_RAY` | `false` | Logs Cloudflare's `Cf-Ray` header as `request_id` when `true`. Enable only when the API is reachable exclusively through Cloudflare. | | `TIBIADATA_LOG_FORMAT` | `text` | Log output format: `text` or `json`. | | `TIBIADATA_LOG_LEVEL` | `info` (or `debug` when `DEBUG_MODE=true` and unset) | Application log level: `debug`, `info`, `warn`, `error`. | +| `TIBIADATA_OTEL_ENABLED` | `false` | Enables OpenTelemetry traces and metrics (see [OpenTelemetry](#opentelemetry)). | | `TIBIADATA_PROTOCOL` | `https` | Protocol used together with `TIBIADATA_HOST` when generating API URLs. | | `TIBIADATA_PROXY` | `www.tibia.com` | Domain to fetch data from instead of `www.tibia.com`, e.g. a caching proxy. | | `TIBIADATA_PROXY_PROTOCOL` | `https` | Protocol for `TIBIADATA_PROXY` (`http` or `https`). Only used when `TIBIADATA_PROXY` is set. | @@ -137,6 +138,39 @@ The following environment variables can be used to configure the API. Set `TIBIADATA_LOG_CF_RAY=true` to log Cloudflare's `Cf-Ray` header as `request_id` when present. Restrict origin access to Cloudflare so that clients cannot send arbitrary `Cf-Ray` values directly to the API. +### OpenTelemetry + +Set `TIBIADATA_OTEL_ENABLED=true` to enable [OpenTelemetry](https://opentelemetry.io/) traces and metrics. When enabled, the API: + +- creates a server span for each incoming request and continues traces from incoming W3C `traceparent` headers, +- creates client spans for upstream requests (e.g. to tibia.com) and propagates the trace context to them, +- records HTTP server and client metrics such as `http.server.request.duration` (request count, latency, method, route and status code), +- adds `trace_id` and `span_id` to request logs, so logs can be correlated with traces. + +Probe endpoints (`/ping`, `/health`, `/healthz`, `/readyz`) are included in metrics but not traced. + +All other settings use the standard [OpenTelemetry environment variables](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/), for example: + +| Variable | Default | Description | +| --- | --- | --- | +| `OTEL_SERVICE_NAME` | `tibiadata-api-go` | Service name reported in telemetry. | +| `OTEL_RESOURCE_ATTRIBUTES` | _(empty)_ | Additional resource attributes, e.g. `deployment.environment=production`. | +| `OTEL_TRACES_EXPORTER` | `otlp` | Trace exporter: `otlp`, `console` or `none`. | +| `OTEL_METRICS_EXPORTER` | `otlp` | Metrics exporter: `otlp`, `prometheus`, `console` or `none`. | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4318` | OTLP collector endpoint. | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | `http/protobuf` | OTLP protocol: `http/protobuf` or `grpc`. | +| `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | Trace sampler, e.g. `parentbased_traceidratio` together with `OTEL_TRACES_SAMPLER_ARG=0.1`. | +| `OTEL_EXPORTER_PROMETHEUS_HOST` / `OTEL_EXPORTER_PROMETHEUS_PORT` | `localhost` / `9464` | Listen address of the Prometheus `/metrics` endpoint when `OTEL_METRICS_EXPORTER=prometheus`. Set the host to `0.0.0.0` to expose it outside the container. | + +A signal whose exporter is set to `none` creates no spans or measurements. When telemetry is disabled, or both exporters are `none`, no instrumentation is installed at all. For example, to collect only metrics: + +```bash +TIBIADATA_OTEL_ENABLED=true +OTEL_TRACES_EXPORTER=none +OTEL_METRICS_EXPORTER=prometheus +OTEL_EXPORTER_PROMETHEUS_HOST=0.0.0.0 +``` + ### Deployment note You should consider to add a layer in front of this application, so you can do caching of endpoints, access controll or what ever your needs are. diff --git a/go.mod b/go.mod index 21e46ac..8aa36dc 100644 --- a/go.mod +++ b/go.mod @@ -17,40 +17,81 @@ require ( github.com/stretchr/testify v1.12.1 github.com/tibiadata/tibiadata-api-go/src/static v0.0.0-20260708000506-76c8bf3494d0 github.com/tibiadata/tibiadata-api-go/src/validation v0.0.0-20260708000506-76c8bf3494d0 + go.opentelemetry.io/contrib/exporters/autoexport v0.72.0 + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.72.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 + go.opentelemetry.io/otel v1.47.0 + go.opentelemetry.io/otel/sdk v1.47.0 + go.opentelemetry.io/otel/sdk/metric v1.47.0 + go.opentelemetry.io/otel/trace v1.47.0 golang.org/x/net v0.61.0 golang.org/x/text v0.43.0 ) require ( github.com/andybalholm/cascadia v1.3.4 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.4 // indirect - github.com/bytedance/sonic v1.15.2 // indirect - github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/bytedance/sonic v1.15.4 // indirect + github.com/bytedance/sonic/loader v0.5.2 // 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.7 // indirect - github.com/gabriel-vasile/mimetype v1.4.13 // indirect - github.com/gin-contrib/sse v1.1.1 // 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.30.3 // indirect - github.com/goccy/go-json v0.10.6 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect + github.com/gin-contrib/sse v1.1.2 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.2 // indirect + github.com/go-playground/universal-translator v0.18.2 // indirect + github.com/go-playground/validator/v10 v10.30.5 // indirect + github.com/goccy/go-json v0.11.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.23 // indirect + github.com/leodido/go-urn v1.5.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/projectbarks/cimap v0.1.1 // indirect + github.com/prometheus/client_golang v1.24.1 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.72.0 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.22.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.60.0 // indirect + github.com/quic-go/quic-go v0.63.0 // indirect github.com/tibiadata/tibiadata-api-go/src/tibiamapping v0.0.0-20260707225952-a0db793b45fa // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.1 // indirect - go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect + github.com/ugorji/go/codec v1.3.2 // indirect + go.mongodb.org/mongo-driver/v2 v2.9.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.72.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.69.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.23.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/otel/sdk/log v1.47.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.1 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/arch v0.29.0 // indirect + golang.org/x/arch v0.31.0 // indirect golang.org/x/crypto v0.58.0 // indirect golang.org/x/sys v0.49.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/grpc v1.84.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/go.sum b/go.sum index e4af1d0..c4db567 100644 --- a/go.sum +++ b/go.sum @@ -2,68 +2,103 @@ github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g= github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= -github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= -github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= -github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= -github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.4 h1:FgtV/4aBHpla9AxuMpuuzVUpa/Cf3izufkxNmnEzdI8= +github.com/bytedance/sonic v1.15.4/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc= +github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo= +github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +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.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= 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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= -github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/gin-contrib/gzip v1.2.8 h1:wDb1thtVsSUe+126xHdgLiWcZqHdWXiBzdZj2yhdUM8= github.com/gin-contrib/gzip v1.2.8/go.mod h1:OiNBR7FxAwHN3iwoDtjj2RlypuONCk48zmtagvASfrc= -github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= -github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo= +github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/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.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= -github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY= +github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU= +github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk= +github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4= +github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ= github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= -github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= -github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA= +github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +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.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo= 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/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= -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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= github.com/mantyr/go-charset v0.0.0-20160510214718-44d054d82c4a h1:WJXeKt5afI65LpiwNdMo3KzkSQHdQHwjp3Aj1/i/XG4= github.com/mantyr/go-charset v0.0.0-20160510214718-44d054d82c4a/go.mod h1:lgDGvifiwLKvbOecA+o0c3QMv7kv+xZEQTqm7Q3ouAc= -github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= -github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/projectbarks/cimap v0.1.1 h1:F9C2UvcjrbRifzcABVZ1tPMSuWq9iZV4NSPMfEmAeQg= github.com/projectbarks/cimap v0.1.1/go.mod h1:CuebbhEuH1t2Iktn7QYFCstI6Kb3BAM1cQ/ozp393QE= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4= +github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= -github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78A= +github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= 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= @@ -77,16 +112,74 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= 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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= -github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= -go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc= +github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.9.1 h1:jewiFs2m1/VOQp8qhFshX6hWZ+EAXDhZHXExAUMcOgQ= +go.mongodb.org/mongo-driver/v2 v2.9.1/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/prometheus v0.72.0 h1:OUIUu7ovHCEvG4IE5LQd2PNBH2eVYh50w/1ROwHEM7M= +go.opentelemetry.io/contrib/bridges/prometheus v0.72.0/go.mod h1:pCrnkC07AZ5+h+0A0Z/1GYyqB+xu3A8LaEEhxvv9s6o= +go.opentelemetry.io/contrib/exporters/autoexport v0.72.0 h1:TPXrl7vEkmh69tp7dHTpz+Y6AUenFg1Du3Nx3a6dlxY= +go.opentelemetry.io/contrib/exporters/autoexport v0.72.0/go.mod h1:0jE6K8nsOgeiF53rcEhy71cgakek+XEWi4OPROUqLoI= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.72.0 h1:Vgm7vrC26vvecffpDcy2UINqBvASUg5qkfZle3Yrkug= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.72.0/go.mod h1:zgCChxie/dA3qG7IgZGswax6voxkOR4+33msLT/RgNg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw= +go.opentelemetry.io/contrib/propagators/b3 v1.47.0 h1:vlKDmtjhe1R0d7kdov/+KVTKWOKeGLyohB+iXoDPRMQ= +go.opentelemetry.io/contrib/propagators/b3 v1.47.0/go.mod h1:0zRiXIfXU30PKYMsdbqYbGK8iHF8KqyPi0qdXT2wAuE= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0 h1:5Ek4qQ5XX0PSQfY7lMW3CvcR36yy8YFDfGnYnaku8zE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0/go.mod h1:NUKIMOKSexQdTmS1pSxOQBDiVes3DHRier2ePsGW/gQ= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0 h1:5WVp40k2fJHuc5BtNTgKk+EugKFT8Eg3lywFfCPI/nE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0/go.mod h1:59nH0RYCGHRlwt15WyioeSYINBbuObeBpTowPhEKWHY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 h1:LaLVGJtIzHcvHjVs2OftmKcc+M9mdOqS8vZ1fBD1Yes= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0/go.mod h1:vF6H5sNDuQBJWgSX00iZgbIqk2MZZoVMH0ar5t5bOpw= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 h1:7kszRN4Ze5GeAkUnBjBSUIunVBMA+mZIG04auueqguM= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0/go.mod h1:CLzakYy8jOdkBfcveCWYkeKJxujcF4xPhh2Z7IfE98U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38= +go.opentelemetry.io/otel/exporters/prometheus v0.69.0 h1:LQVBTGnHvzrfGJpDoCSVTY7eaLZArvaTSq5l84Skxew= +go.opentelemetry.io/otel/exporters/prometheus v0.69.0/go.mod h1:moif0nlgj/M16NRT8lDZVt8MC52+EOAJfR8EKRJ8LyU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.23.0 h1:PW/E9Tw6ZykC88pey0MZY8hKOUeEw+hX6PU+oXA4A9o= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.23.0/go.mod h1:hUw4VNfiOnQ/yzGgy6ZdWVnudXw7iRfImhd6FjXOnvI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0 h1:ww5DYBpMKcqLdxjWrhGUIh6O+ZfvZUaMk4niVyNQPEQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0/go.mod h1:Av5t+Ezx5S/Ks2dsEfWhny+GK+wrZ1K7NZ6aHSWnX3U= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM= +go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/log v1.47.0 h1:W4N/ZfgpkRRnmARnqYoQ6BrWA0mrj8IzVDNLlpmUJZY= +go.opentelemetry.io/otel/sdk/log v1.47.0/go.mod h1:Wu74Z2EmOKXi2FleSNAb0Hyau2G+r5kubSEMJw5aAMs= +go.opentelemetry.io/otel/sdk/log/logtest v0.23.0 h1:j3pm42QCrKYYLLkI6OrV1QHFkUfDA6/4EP+Y1PzggLg= +go.opentelemetry.io/otel/sdk/log/logtest v0.23.0/go.mod h1:zt0gEnuTZgDvqvczHwYswz93eqMaIr6upTTsdV/HUlU= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.opentelemetry.io/proto/otlp v1.11.1 h1:SCrPqH9NE5CmBrOqpjNCiT0X5mGwMyGERXdogfR7Yjw= +go.opentelemetry.io/proto/otlp v1.11.1/go.mod h1:wq8W4aSf6bOy8RZyESOeQFr9Pu7wydWX/TY4sgvrTPg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho= -golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk= +golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= @@ -97,8 +190,16 @@ golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/TibiaHousesOverview.go b/src/TibiaHousesOverview.go index 2a5c642..0bcf5a3 100644 --- a/src/TibiaHousesOverview.go +++ b/src/TibiaHousesOverview.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "net/http" "regexp" @@ -49,7 +50,7 @@ var ( ) // TibiaHousesOverview func -func TibiaHousesOverviewImpl(c *gin.Context, world, town string, htmlDataCollector func(TibiaDataRequestStruct) (string, error)) (HousesOverviewResponse, error) { +func TibiaHousesOverviewImpl(c *gin.Context, world, town string, htmlDataCollector func(context.Context, TibiaDataRequestStruct) (string, error)) (HousesOverviewResponse, error) { // Creating empty vars var HouseData, GuildhallData []HousesHouse var TibiaHouseURLs []string @@ -59,7 +60,7 @@ func TibiaHousesOverviewImpl(c *gin.Context, world, town string, htmlDataCollect // running over the FansiteTypes array for _, HouseType := range HouseTypes { - houses, houseUrl, err := makeHouseRequest(HouseType, world, town, tibiaDataUserAgentForRequest(c), htmlDataCollector) + houses, houseUrl, err := makeHouseRequest(tibiaDataRequestContext(c), HouseType, world, town, tibiaDataUserAgentForRequest(c), htmlDataCollector) if err != nil { return HousesOverviewResponse{}, fmt.Errorf("[error] TibiaHousesOverviewImpl failed at makeHouseRequest, type: %s, err: %s", HouseType, err) } @@ -93,7 +94,7 @@ func TibiaHousesOverviewImpl(c *gin.Context, world, town string, htmlDataCollect }, nil } -func makeHouseRequest(HouseType, world, town, userAgent string, htmlDataCollector func(TibiaDataRequestStruct) (string, error)) ([]HousesHouse, string, error) { +func makeHouseRequest(ctx context.Context, HouseType, world, town, userAgent string, htmlDataCollector func(context.Context, TibiaDataRequestStruct) (string, error)) ([]HousesHouse, string, error) { // Creating an empty var var output []HousesHouse @@ -103,7 +104,7 @@ func makeHouseRequest(HouseType, world, town, userAgent string, htmlDataCollecto UserAgent: userAgent, } - BoxContentHTML, err := htmlDataCollector(tibiadataRequest) + BoxContentHTML, err := htmlDataCollector(ctx, tibiadataRequest) // return error (e.g. for maintenance mode) if err != nil { return nil, "", err diff --git a/src/TibiaHousesOverview_test.go b/src/TibiaHousesOverview_test.go index bea466e..3620057 100644 --- a/src/TibiaHousesOverview_test.go +++ b/src/TibiaHousesOverview_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "io" "net/http" @@ -31,8 +32,12 @@ func TestTibiaHousesOverviewUserAgent(t *testing.T) { t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) { c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/v%d/houses/Antica/Thais", version), nil) + ctx, cancel := context.WithCancel(c.Request.Context()) + defer cancel() + c.Request = c.Request.WithContext(ctx) var urls []string - _, err := TibiaHousesOverviewImpl(c, "Antica", "Thais", func(request TibiaDataRequestStruct) (string, error) { + _, err := TibiaHousesOverviewImpl(c, "Antica", "Thais", func(ctx context.Context, request TibiaDataRequestStruct) (string, error) { + assert.Equal(t, c.Request.Context(), ctx) assert.Equal(t, TibiaDataUserAgentGenerator(version), request.UserAgent) urls = append(urls, request.URL) if strings.Contains(request.URL, "guildhalls") { @@ -76,7 +81,7 @@ func TestAnticaThaisHousesOverview(t *testing.T) { nil, "Antica", "Thais", - func(request TibiaDataRequestStruct) (string, error) { + func(_ context.Context, request TibiaDataRequestStruct) (string, error) { if strings.Contains(request.URL, "guildhalls") { return string(guildData), nil } @@ -165,7 +170,7 @@ func TestPremiaFarmineHousesOverview(t *testing.T) { nil, "Premia", "Farmine", - func(request TibiaDataRequestStruct) (string, error) { + func(_ context.Context, request TibiaDataRequestStruct) (string, error) { if strings.Contains(request.URL, "guildhalls") { return string(guildData), nil } @@ -222,7 +227,7 @@ func TestPremiaEdronHousesOverview(t *testing.T) { nil, "Premia", "Edron", - func(request TibiaDataRequestStruct) (string, error) { + func(_ context.Context, request TibiaDataRequestStruct) (string, error) { if strings.Contains(request.URL, "guildhalls") { return string(guildData), nil } diff --git a/src/logging.go b/src/logging.go index bf477d3..aecc6f8 100644 --- a/src/logging.go +++ b/src/logging.go @@ -12,6 +12,7 @@ import ( "time" "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/trace" ) const ( @@ -80,8 +81,11 @@ func newTibiaDataLogHandler(w io.Writer, format string, level slog.Leveler) slog } func traceLogAttrs(ctx context.Context) []any { - _ = ctx - return nil // extended when OpenTelemetry tracing (#749) is wired + sc := trace.SpanContextFromContext(ctx) + if !sc.IsValid() { + return nil + } + return []any{"trace_id", sc.TraceID().String(), "span_id", sc.SpanID().String()} } func requestLogAttrs(r *http.Request) []any { @@ -151,6 +155,7 @@ func ginRecoveryMiddleware() gin.HandlerFunc { "path", c.Request.URL.Path, "stack", string(debug.Stack()), } + attrs = append(attrs, traceLogAttrs(c.Request.Context())...) attrs = append(attrs, requestLogAttrs(c.Request)...) slog.Error("panic recovered", attrs...) c.AbortWithStatus(http.StatusInternalServerError) diff --git a/src/logging_test.go b/src/logging_test.go index ffc35c7..2366cea 100644 --- a/src/logging_test.go +++ b/src/logging_test.go @@ -11,6 +11,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/trace" ) func TestParseTibiaDataLogLevel(t *testing.T) { @@ -150,6 +151,21 @@ func TestTraceLogAttrsEmptyWithoutTracing(t *testing.T) { assert.Empty(t, traceLogAttrs(context.Background())) } +func TestTraceLogAttrsFromSpanContext(t *testing.T) { + traceID, _ := trace.TraceIDFromHex("0123456789abcdef0123456789abcdef") + spanID, _ := trace.SpanIDFromHex("0123456789abcdef") + ctx := trace.ContextWithSpanContext(context.Background(), trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.FlagsSampled, + })) + + assert.Equal(t, []any{ + "trace_id", "0123456789abcdef0123456789abcdef", + "span_id", "0123456789abcdef", + }, traceLogAttrs(ctx)) +} + func captureLogs(t *testing.T, level slog.Level) *bytes.Buffer { var buf bytes.Buffer previous := slog.Default() diff --git a/src/main.go b/src/main.go index d4c609c..eb454d4 100644 --- a/src/main.go +++ b/src/main.go @@ -75,6 +75,7 @@ func init() { slog.Info("TibiaData API debug-mode", "enabled", TibiaDataDebug) TibiaDataInitializer() + setupTibiaDataTelemetry() TibiaDataUserAgent = TibiaDataUserAgentGenerator(TibiaDataAPIversion) tibiaDataInitUserAgents() diff --git a/src/telemetry.go b/src/telemetry.go new file mode 100644 index 0000000..2341d09 --- /dev/null +++ b/src/telemetry.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/contrib/exporters/autoexport" + "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "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" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +const ( + telemetryEnabledEnv = "TIBIADATA_OTEL_ENABLED" + telemetryService = "tibiadata-api-go" +) + +var ( + tibiaDataTelemetryEnabled bool + tibiaDataTracingEnabled bool + tibiaDataMetricsEnabled bool + tibiaDataTelemetryStop = func(context.Context) error { return nil } +) + +// initTibiaDataTelemetry configures the global OpenTelemetry tracer and meter +// providers when TIBIADATA_OTEL_ENABLED is true. Exporters and SDK settings are +// read from the standard OTEL_* environment variables. +func initTibiaDataTelemetry(ctx context.Context) (func(context.Context) error, error) { + noopShutdown := func(context.Context) error { return nil } + + tibiaDataTelemetryEnabled = getEnvAsBool(telemetryEnabledEnv, false) + tibiaDataTracingEnabled, tibiaDataMetricsEnabled = false, false + if !tibiaDataTelemetryEnabled { + slog.Info("TibiaData API opentelemetry", "enabled", false) + return noopShutdown, nil + } + + res, err := tibiaDataTelemetryResource(ctx) + if err != nil { + return noopShutdown, err + } + + var shutdowns []func(context.Context) error + shutdown := func(ctx context.Context) error { + var errs []error + for _, s := range shutdowns { + errs = append(errs, s(ctx)) + } + return errors.Join(errs...) + } + + // Signals whose exporter is "none" keep the global no-op provider, so no + // spans or measurements are created only to be discarded. + spanExporter, err := autoexport.NewSpanExporter(ctx) + if err != nil { + return noopShutdown, err + } + if !autoexport.IsNoneSpanExporter(spanExporter) { + tracerProvider := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(spanExporter), + sdktrace.WithResource(res), + ) + otel.SetTracerProvider(tracerProvider) + tibiaDataTracingEnabled = true + shutdowns = append(shutdowns, tracerProvider.Shutdown) + } + + metricReader, err := autoexport.NewMetricReader(ctx) + if err != nil { + return noopShutdown, errors.Join(err, shutdown(ctx)) + } + if !autoexport.IsNoneMetricReader(metricReader) { + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(metricReader), + sdkmetric.WithResource(res), + ) + otel.SetMeterProvider(meterProvider) + tibiaDataMetricsEnabled = true + shutdowns = append(shutdowns, meterProvider.Shutdown) + } + + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { + slog.Warn("TibiaData API opentelemetry error", "error", err) + })) + + slog.Info("TibiaData API opentelemetry", + "enabled", true, + "traces_exporter", envOrDefault("OTEL_TRACES_EXPORTER", "otlp"), + "metrics_exporter", envOrDefault("OTEL_METRICS_EXPORTER", "otlp"), + ) + + return shutdown, nil +} + +func tibiaDataTelemetryResource(ctx context.Context) (*resource.Resource, error) { + // Attributes from OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES take precedence. + defaults := resource.NewSchemaless( + attribute.String("service.name", telemetryService), + attribute.String("service.version", TibiaDataBuildRelease), + ) + detected, err := resource.New(ctx, + resource.WithFromEnv(), + resource.WithTelemetrySDK(), + ) + if err != nil && detected == nil { + return nil, err + } + return resource.Merge(defaults, detected) +} + +func envOrDefault(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +type skipTraceKey struct{} + +// tibiaDataTelemetryActive reports whether at least one telemetry signal is +// exported, i.e. whether request instrumentation should be installed. +func tibiaDataTelemetryActive() bool { + return tibiaDataTelemetryEnabled && (tibiaDataTracingEnabled || tibiaDataMetricsEnabled) +} + +// ginTelemetryMiddleware instruments requests with spans and HTTP server +// metrics. Probe endpoints are recorded in metrics but not traced. +func ginTelemetryMiddleware() gin.HandlerFunc { + otelMiddleware := otelgin.Middleware( + telemetryService, + otelgin.WithTracerProvider(probeFilteringTracerProvider{otel.GetTracerProvider()}), + ) + if !tibiaDataTracingEnabled { + return otelMiddleware + } + return func(c *gin.Context) { + if isProbePath(c.Request.URL.Path) { + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), skipTraceKey{}, true)) + } + otelMiddleware(c) + } +} + +// probeFilteringTracerProvider returns non-recording spans for requests whose +// context is marked with skipTraceKey, so otelgin still records their metrics. +type probeFilteringTracerProvider struct { + trace.TracerProvider +} + +func (p probeFilteringTracerProvider) Tracer(name string, opts ...trace.TracerOption) trace.Tracer { + return probeFilteringTracer{Tracer: p.TracerProvider.Tracer(name, opts...)} +} + +type probeFilteringTracer struct { + trace.Tracer +} + +var noopTracer = noop.NewTracerProvider().Tracer("") + +func (t probeFilteringTracer) Start(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { + if skip, _ := ctx.Value(skipTraceKey{}).(bool); skip { + return noopTracer.Start(ctx, name, opts...) + } + return t.Tracer.Start(ctx, name, opts...) +} + +func setupTibiaDataTelemetry() { + shutdown, err := initTibiaDataTelemetry(context.Background()) + if err != nil { + tibiaDataTelemetryEnabled = false + slog.Error("TibiaData API opentelemetry setup failed, continuing without telemetry", "error", err) + return + } + tibiaDataTelemetryStop = shutdown +} + +// shutdownTibiaDataTelemetry flushes and stops the telemetry providers. +func shutdownTibiaDataTelemetry() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := tibiaDataTelemetryStop(ctx); err != nil { + slog.Warn("TibiaData API opentelemetry shutdown failed", "error", err) + } +} diff --git a/src/telemetry_test.go b/src/telemetry_test.go new file mode 100644 index 0000000..9a3af4a --- /dev/null +++ b/src/telemetry_test.go @@ -0,0 +1,238 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" +) + +// setupTestTelemetry installs in-memory tracer and meter providers as the +// global providers and restores the previous state when the test ends. +func setupTestTelemetry(t *testing.T) (*tracetest.SpanRecorder, *sdkmetric.ManualReader) { + t.Helper() + + recorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + reader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + + prevTP, prevMP, prevProp := otel.GetTracerProvider(), otel.GetMeterProvider(), otel.GetTextMapPropagator() + prevEnabled, prevTracing, prevMetrics := tibiaDataTelemetryEnabled, tibiaDataTracingEnabled, tibiaDataMetricsEnabled + + otel.SetTracerProvider(tracerProvider) + otel.SetMeterProvider(meterProvider) + otel.SetTextMapPropagator(propagation.TraceContext{}) + tibiaDataTelemetryEnabled, tibiaDataTracingEnabled, tibiaDataMetricsEnabled = true, true, true + + t.Cleanup(func() { + otel.SetTracerProvider(prevTP) + otel.SetMeterProvider(prevMP) + otel.SetTextMapPropagator(prevProp) + tibiaDataTelemetryEnabled, tibiaDataTracingEnabled, tibiaDataMetricsEnabled = prevEnabled, prevTracing, prevMetrics + _ = tracerProvider.Shutdown(context.Background()) + _ = meterProvider.Shutdown(context.Background()) + }) + + return recorder, reader +} + +func requestDurationCount(t *testing.T, reader *sdkmetric.ManualReader, route string) uint64 { + t.Helper() + + var rm metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &rm)) + + var count uint64 + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name != "http.server.request.duration" { + continue + } + hist, ok := m.Data.(metricdata.Histogram[float64]) + require.True(t, ok) + for _, dp := range hist.DataPoints { + if v, ok := dp.Attributes.Value("http.route"); ok && v.AsString() == route { + count += dp.Count + } + } + } + } + return count +} + +func TestInitTibiaDataTelemetryDisabledByDefault(t *testing.T) { + t.Setenv(telemetryEnabledEnv, "") + prevTP := otel.GetTracerProvider() + + shutdown, err := initTibiaDataTelemetry(context.Background()) + + require.NoError(t, err) + assert.False(t, tibiaDataTelemetryEnabled) + assert.Equal(t, prevTP, otel.GetTracerProvider()) + assert.NoError(t, shutdown(context.Background())) +} + +func TestInitTibiaDataTelemetryEnabled(t *testing.T) { + for _, test := range []struct { + traces, metrics string + wantTraces, wantMetrics bool + }{ + {"console", "none", true, false}, + {"none", "console", false, true}, + } { + t.Run(test.traces+"/"+test.metrics, func(t *testing.T) { + t.Setenv(telemetryEnabledEnv, "true") + t.Setenv("OTEL_TRACES_EXPORTER", test.traces) + t.Setenv("OTEL_METRICS_EXPORTER", test.metrics) + t.Setenv("OTEL_METRIC_EXPORT_INTERVAL", "3600000") + prevTP, prevMP, prevProp := otel.GetTracerProvider(), otel.GetMeterProvider(), otel.GetTextMapPropagator() + t.Cleanup(func() { + otel.SetTracerProvider(prevTP) + otel.SetMeterProvider(prevMP) + otel.SetTextMapPropagator(prevProp) + tibiaDataTelemetryEnabled, tibiaDataTracingEnabled, tibiaDataMetricsEnabled = false, false, false + }) + + shutdown, err := initTibiaDataTelemetry(context.Background()) + + require.NoError(t, err) + assert.True(t, tibiaDataTelemetryEnabled) + assert.Equal(t, test.wantTraces, tibiaDataTracingEnabled) + assert.Equal(t, test.wantMetrics, tibiaDataMetricsEnabled) + _, tracesSDK := otel.GetTracerProvider().(*sdktrace.TracerProvider) + assert.Equal(t, test.wantTraces, tracesSDK) + _, metricsSDK := otel.GetMeterProvider().(*sdkmetric.MeterProvider) + assert.Equal(t, test.wantMetrics, metricsSDK) + assert.NoError(t, shutdown(context.Background())) + }) + } +} + +func TestTibiaDataTelemetryResourceDefaults(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + res, err := tibiaDataTelemetryResource(context.Background()) + require.NoError(t, err) + + name, ok := res.Set().Value("service.name") + assert.True(t, ok) + assert.Equal(t, telemetryService, name.AsString()) + + t.Setenv("OTEL_SERVICE_NAME", "custom") + res, err = tibiaDataTelemetryResource(context.Background()) + require.NoError(t, err) + name, _ = res.Set().Value("service.name") + assert.Equal(t, "custom", name.AsString()) +} + +func TestGinTelemetryMiddlewareTracesAndCorrelatesLogs(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder, reader := setupTestTelemetry(t) + logs := captureLogs(t, slog.LevelInfo) + + router := gin.New() + router.Use(ginTelemetryMiddleware()) + router.Use(ginAccessLogMiddleware()) + router.GET("/v4/worlds/:world", func(c *gin.Context) { c.Status(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/v4/worlds/Antica", nil) + req.Header.Set("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + router.ServeHTTP(httptest.NewRecorder(), req) + + spans := recorder.Ended() + require.Len(t, spans, 1) + span := spans[0] + assert.Equal(t, trace.SpanKindServer, span.SpanKind()) + assert.Equal(t, "0af7651916cd43dd8448eb211c80319c", span.SpanContext().TraceID().String()) + assert.Equal(t, "b7ad6b7169203331", span.Parent().SpanID().String()) + + assert.Contains(t, logs.String(), "trace_id="+span.SpanContext().TraceID().String()) + assert.Contains(t, logs.String(), "span_id="+span.SpanContext().SpanID().String()) + + assert.Equal(t, uint64(1), requestDurationCount(t, reader, "/v4/worlds/:world")) +} + +func TestGinTelemetryMiddlewareSkipsProbeTracesButKeepsMetrics(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder, reader := setupTestTelemetry(t) + + router := gin.New() + router.Use(ginTelemetryMiddleware()) + var probeCtx context.Context + router.GET("/healthz", func(c *gin.Context) { + probeCtx = c.Request.Context() + c.Status(http.StatusOK) + }) + + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/healthz", nil)) + + assert.Empty(t, recorder.Ended()) + assert.False(t, trace.SpanFromContext(probeCtx).IsRecording()) + assert.Equal(t, uint64(1), requestDurationCount(t, reader, "/healthz")) +} + +func TestTibiaDataHTMLDataCollectorPropagatesTraceContext(t *testing.T) { + recorder, _ := setupTestTelemetry(t) + + prevClient := tibiaDataClient + initTibiaDataClient() + t.Cleanup(func() { tibiaDataClient = prevClient }) + + var traceparent string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + traceparent = r.Header.Get("traceparent") + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + ctx, parent := otel.Tracer("test").Start(context.Background(), "parent") + _, err := TibiaDataHTMLDataCollector(ctx, TibiaDataRequestStruct{ + Method: http.MethodGet, + URL: server.URL, + RawBody: true, + }) + parent.End() + require.NoError(t, err) + + var client sdktrace.ReadOnlySpan + for _, s := range recorder.Ended() { + if s.SpanKind() == trace.SpanKindClient { + client = s + } + } + require.NotNil(t, client) + assert.Equal(t, parent.SpanContext().SpanID(), client.Parent().SpanID()) + assert.Contains(t, traceparent, parent.SpanContext().TraceID().String()) + assert.Contains(t, traceparent, client.SpanContext().SpanID().String()) +} + +func TestTibiaDataTelemetryActive(t *testing.T) { + prevEnabled, prevTracing, prevMetrics := tibiaDataTelemetryEnabled, tibiaDataTracingEnabled, tibiaDataMetricsEnabled + t.Cleanup(func() { + tibiaDataTelemetryEnabled, tibiaDataTracingEnabled, tibiaDataMetricsEnabled = prevEnabled, prevTracing, prevMetrics + }) + + for _, tc := range []struct { + enabled, tracing, metrics, want bool + }{ + {false, true, true, false}, + {true, false, false, false}, + {true, false, true, true}, + {true, true, false, true}, + } { + tibiaDataTelemetryEnabled, tibiaDataTracingEnabled, tibiaDataMetricsEnabled = tc.enabled, tc.tracing, tc.metrics + assert.Equal(t, tc.want, tibiaDataTelemetryActive(), "%+v", tc) + } +} diff --git a/src/webserver.go b/src/webserver.go index 419bdc0..1fa4331 100644 --- a/src/webserver.go +++ b/src/webserver.go @@ -14,6 +14,7 @@ import ( "reflect" "strconv" "strings" + "syscall" "time" _ "github.com/mantyr/go-charset/data" @@ -25,6 +26,7 @@ import ( "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" "github.com/go-resty/resty/v2" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) var ( @@ -79,6 +81,12 @@ func initTibiaDataClient() { // Disable redirection of client (so we skip parsing maintenance page) tibiaDataClient.SetRedirectPolicy(resty.NoRedirectPolicy()) + + // Trace outbound requests and propagate trace context when telemetry is enabled + if tibiaDataTelemetryActive() { + httpClient := tibiaDataClient.GetClient() + httpClient.Transport = otelhttp.NewTransport(httpClient.Transport) + } } // DebugOutInformation wraps OutInformation with some debug info @@ -125,6 +133,19 @@ type TibiaDataRequestStruct struct { UserAgent string `json:"-"` // User-Agent for the request (falls back to client default when empty) } +// tibiaDataRequestContext returns the context of the incoming request, or +// context.Background() when unavailable. +func tibiaDataRequestContext(c *gin.Context) context.Context { + if c == nil || c.Request == nil { + return context.Background() + } + return c.Request.Context() +} + +// serverShutdownTimeout bounds how long in-flight requests may take to finish +// after a shutdown signal. It stays below Kubernetes' default 30s grace period. +const serverShutdownTimeout = 20 * time.Second + // RunWebServer starts the gin server // It blocks the code and will only finish execution on shutdown func runWebServer() { @@ -141,6 +162,9 @@ func runWebServer() { slog.Info("TibiaData API gin-mode", "mode", gin.Mode()) router := gin.New() + if tibiaDataTelemetryActive() { + router.Use(ginTelemetryMiddleware()) + } router.Use(ginAccessLogMiddleware()) router.Use(ginRecoveryMiddleware()) router.Use(gzip.Gzip(gzip.DefaultCompression)) @@ -226,16 +250,24 @@ func runWebServer() { // Prepare for a graceful shutdown quit := make(chan os.Signal, 1) - signal.Notify(quit, os.Interrupt) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) - // Run a go routine that will receive the shutdown input + // Run a go routine that will receive the shutdown input and drain + // in-flight requests before the server returns + shutdownDone := make(chan struct{}) go func() { + defer close(shutdownDone) <-quit slog.Info("TibiaData API received shutdown input") - if err := server.Close(); err != nil { - tibiaDataLogFatal("TibiaData API server close error", err) + isReady.Store(false) + ctx, cancel := context.WithTimeout(context.Background(), serverShutdownTimeout) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + slog.Warn("TibiaData API graceful shutdown timed out, closing connections", "error", err) + _ = server.Close() } }() + defer shutdownTibiaDataTelemetry() // setting readyz endpoint to true isReady.Store(true) @@ -244,6 +276,7 @@ func runWebServer() { if err := server.ListenAndServe(); err != nil { if err == http.ErrServerClosed { + <-shutdownDone slog.Info("TibiaData API server gracefully shut down") } else { tibiaDataLogFatal("TibiaData API server closed unexpectedly", err) @@ -1330,9 +1363,9 @@ func tibiaDataRequestHandler(c *gin.Context, tibiaDataRequest TibiaDataRequestSt tibiaDataRequest.UserAgent = tibiaDataUserAgentForRequest(c) } if tibiaDataRequest.UseFansiteAPI { - boxContent, err = TibiaDataJSONDataCollector(tibiaDataRequest) + boxContent, err = TibiaDataJSONDataCollector(tibiaDataRequestContext(c), tibiaDataRequest) } else { - boxContent, err = TibiaDataHTMLDataCollector(tibiaDataRequest) + boxContent, err = TibiaDataHTMLDataCollector(tibiaDataRequestContext(c), tibiaDataRequest) } // return error (e.g. for maintenance mode) @@ -1355,12 +1388,12 @@ func tibiaDataRequestHandler(c *gin.Context, tibiaDataRequest TibiaDataRequestSt TibiaDataAPIHandleResponse(c, handlerName, jsonData) } -func TibiaDataJSONDataCollector(TibiaDataRequest TibiaDataRequestStruct) (string, error) { +func TibiaDataJSONDataCollector(ctx context.Context, TibiaDataRequest TibiaDataRequestStruct) (string, error) { if TibiaFansiteToken == "" { return "", errors.New("missing TibiaFansiteToken for fansiteapi.tibia.com request") } - req := tibiaDataClient.R(). + req := tibiaDataClient.R().SetContext(ctx). SetHeader("Authorization", "Bearer "+TibiaFansiteToken) if TibiaDataRequest.UserAgent != "" { req.SetHeader(userAgentHeader, TibiaDataRequest.UserAgent) @@ -1512,7 +1545,7 @@ func TibiaDataUserAgentGenerator(version int) string { } // TibiaDataHTMLDataCollector func -func TibiaDataHTMLDataCollector(TibiaDataRequest TibiaDataRequestStruct) (string, error) { +func TibiaDataHTMLDataCollector(ctx context.Context, TibiaDataRequest TibiaDataRequestStruct) (string, error) { // Replace domain with proxy if env TIBIADATA_PROXY set if TibiaDataProxyDomain != "" { TibiaDataRequest.URL = strings.ReplaceAll(TibiaDataRequest.URL, "https://www.tibia.com/", TibiaDataProxyDomain) @@ -1525,7 +1558,7 @@ func TibiaDataHTMLDataCollector(TibiaDataRequest TibiaDataRequestStruct) (string LogMessage string ) - req := tibiaDataClient.R() + req := tibiaDataClient.R().SetContext(ctx) if TibiaDataRequest.UserAgent != "" { req.SetHeader(userAgentHeader, TibiaDataRequest.UserAgent) } diff --git a/src/webserver_test.go b/src/webserver_test.go index 1736bcb..5461863 100644 --- a/src/webserver_test.go +++ b/src/webserver_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "errors" "fmt" @@ -439,7 +440,7 @@ func TestTibiaDataJSONDataCollector(t *testing.T) { assert := assert.New(t) TibiaFansiteToken = "" - body, err := TibiaDataJSONDataCollector(TibiaDataRequestStruct{URL: "https://example.invalid"}) + body, err := TibiaDataJSONDataCollector(context.Background(), TibiaDataRequestStruct{URL: "https://example.invalid"}) assert.Empty(body) if assert.Error(err) { assert.Contains(err.Error(), "missing TibiaFansiteToken") @@ -459,7 +460,7 @@ func TestTibiaDataJSONDataCollector(t *testing.T) { defer server.Close() TibiaFansiteToken = "test-token" - body, err := TibiaDataJSONDataCollector(TibiaDataRequestStruct{URL: server.URL}) + body, err := TibiaDataJSONDataCollector(context.Background(), TibiaDataRequestStruct{URL: server.URL}) assert.NoError(err) assert.Equal(`{"characterGameInformation":{"characterName":"Test"}}`, body) assert.Equal("Bearer test-token", gotAuth) @@ -474,7 +475,7 @@ func TestTibiaDataJSONDataCollector(t *testing.T) { defer server.Close() TibiaFansiteToken = "test-token" - body, err := TibiaDataJSONDataCollector(TibiaDataRequestStruct{URL: server.URL}) + body, err := TibiaDataJSONDataCollector(context.Background(), TibiaDataRequestStruct{URL: server.URL}) assert.Empty(body) assert.ErrorIs(err, validation.ErrStatusForbidden) }) @@ -488,7 +489,7 @@ func TestTibiaDataJSONDataCollector(t *testing.T) { defer server.Close() TibiaFansiteToken = "test-token" - body, err := TibiaDataJSONDataCollector(TibiaDataRequestStruct{URL: server.URL}) + body, err := TibiaDataJSONDataCollector(context.Background(), TibiaDataRequestStruct{URL: server.URL}) assert.Empty(body) assert.ErrorIs(err, validation.ErrorCharacterNotFound) }) @@ -502,7 +503,7 @@ func TestTibiaDataJSONDataCollector(t *testing.T) { defer server.Close() TibiaFansiteToken = "test-token" - body, err := TibiaDataJSONDataCollector(TibiaDataRequestStruct{URL: server.URL}) + body, err := TibiaDataJSONDataCollector(context.Background(), TibiaDataRequestStruct{URL: server.URL}) assert.Empty(body) assert.ErrorIs(err, validation.ErrStatusUnknown) }) @@ -511,7 +512,7 @@ func TestTibiaDataJSONDataCollector(t *testing.T) { assert := assert.New(t) TibiaFansiteToken = "test-token" - body, err := TibiaDataJSONDataCollector(TibiaDataRequestStruct{URL: "http://127.0.0.1:0"}) + body, err := TibiaDataJSONDataCollector(context.Background(), TibiaDataRequestStruct{URL: "http://127.0.0.1:0"}) assert.Empty(body) assert.Error(err) }) @@ -561,7 +562,7 @@ func TestTibiaDataHTMLDataCollectorReturnsBody(t *testing.T) { })) defer server.Close() - body, err := TibiaDataHTMLDataCollector(TibiaDataRequestStruct{ + body, err := TibiaDataHTMLDataCollector(context.Background(), TibiaDataRequestStruct{ Method: http.MethodGet, URL: server.URL, RawBody: true, @@ -982,7 +983,7 @@ func TestTibiaDataCollectorsUserAgent(t *testing.T) { for _, test := range []struct { name string method string - collector func(TibiaDataRequestStruct) (string, error) + collector func(context.Context, TibiaDataRequestStruct) (string, error) }{ {"HTML GET", http.MethodGet, TibiaDataHTMLDataCollector}, {"HTML POST", http.MethodPost, TibiaDataHTMLDataCollector}, @@ -997,7 +998,7 @@ func TestTibiaDataCollectorsUserAgent(t *testing.T) { defer server.Close() for _, userAgent := range []string{TibiaDataUserAgentGenerator(5), TibiaDataUserAgentGenerator(4), ""} { - body, err := test.collector(TibiaDataRequestStruct{ + body, err := test.collector(context.Background(), TibiaDataRequestStruct{ URL: server.URL, Method: test.method, RawBody: true, UserAgent: userAgent, }) if !assert.NoError(t, err) { @@ -1016,6 +1017,36 @@ func TestTibiaDataCollectorsUserAgent(t *testing.T) { } } +func TestTibiaDataCollectorsRespectContextCancellation(t *testing.T) { + previousClient, previousToken := tibiaDataClient, TibiaFansiteToken + t.Cleanup(func() { + tibiaDataClient, TibiaFansiteToken = previousClient, previousToken + }) + TibiaFansiteToken = "test-token" + initTibiaDataClient() + tibiaDataClient.SetRetryCount(0) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("cancelled request reached upstream") + })) + defer server.Close() + + for _, test := range []struct { + name string + collector func(context.Context, TibiaDataRequestStruct) (string, error) + }{ + {"HTML", TibiaDataHTMLDataCollector}, + {"JSON", TibiaDataJSONDataCollector}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := test.collector(ctx, TibiaDataRequestStruct{URL: server.URL, RawBody: true}) + assert.ErrorIs(t, err, context.Canceled) + }) + } +} + func TestTibiaDataRequestHandlerUserAgent(t *testing.T) { previousClient, previousToken, previousUserAgents := tibiaDataClient, TibiaFansiteToken, tibiaDataUserAgents t.Cleanup(func() {