From 84093393ae3621b12f0c8e866f39929c2e68cca0 Mon Sep 17 00:00:00 2001 From: tobiasehlert Date: Wed, 21 Oct 2026 14:42:24 +0200 Subject: [PATCH 1/5] feat(telemetry): add OpenTelemetry traces and metrics Instrument Gin requests and upstream calls with OpenTelemetry, gated by TIBIADATA_OTEL_ENABLED. Exporters are selected via the standard OTEL_* env vars (OTLP by default, Prometheus and console supported). Probe endpoints are kept in metrics but not traced. Request logs now include trace_id and span_id for correlation. Closes #749 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 25 +++++ go.mod | 71 ++++++++++--- go.sum | 161 +++++++++++++++++++++++------ src/TibiaHousesOverview.go | 6 +- src/logging.go | 9 +- src/logging_test.go | 16 +++ src/main.go | 1 + src/telemetry.go | 170 ++++++++++++++++++++++++++++++ src/telemetry_test.go | 205 +++++++++++++++++++++++++++++++++++++ src/webserver.go | 28 +++++ 10 files changed, 643 insertions(+), 49 deletions(-) create mode 100644 src/telemetry.go create mode 100644 src/telemetry_test.go diff --git a/README.md b/README.md index 5e9545ef..98d1d1a9 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,30 @@ 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. | + ### 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 21e46ac0..8aa36dc6 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 e4af1d07..c4db5670 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 2a5c6423..d7610a2c 100644 --- a/src/TibiaHousesOverview.go +++ b/src/TibiaHousesOverview.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "net/http" "regexp" @@ -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(TibiaDataRequestStruct) (string, error)) ([]HousesHouse, string, error) { // Creating an empty var var output []HousesHouse @@ -101,6 +102,7 @@ func makeHouseRequest(HouseType, world, town, userAgent string, htmlDataCollecto Method: resty.MethodGet, URL: "https://www.tibia.com/community/?subtopic=houses&world=" + TibiaDataQueryEscapeString(world) + "&town=" + TibiaDataQueryEscapeString(town) + "&type=" + TibiaDataQueryEscapeString(HouseType), UserAgent: userAgent, + Ctx: ctx, } BoxContentHTML, err := htmlDataCollector(tibiadataRequest) diff --git a/src/logging.go b/src/logging.go index bf477d3d..aecc6f82 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 ffc35c78..2366ceab 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 d4c609c4..eb454d4e 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 00000000..b931b046 --- /dev/null +++ b/src/telemetry.go @@ -0,0 +1,170 @@ +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 + 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) + if !tibiaDataTelemetryEnabled { + slog.Info("TibiaData API opentelemetry", "enabled", false) + return noopShutdown, nil + } + + res, err := tibiaDataTelemetryResource(ctx) + if err != nil { + return noopShutdown, err + } + + spanExporter, err := autoexport.NewSpanExporter(ctx) + if err != nil { + return noopShutdown, err + } + tracerProvider := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(spanExporter), + sdktrace.WithResource(res), + ) + + metricReader, err := autoexport.NewMetricReader(ctx) + if err != nil { + return noopShutdown, errors.Join(err, tracerProvider.Shutdown(ctx)) + } + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(metricReader), + sdkmetric.WithResource(res), + ) + + otel.SetTracerProvider(tracerProvider) + otel.SetMeterProvider(meterProvider) + 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 func(ctx context.Context) error { + return errors.Join(tracerProvider.Shutdown(ctx), meterProvider.Shutdown(ctx)) + }, 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{} + +// 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()}), + ) + 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 00000000..3b147702 --- /dev/null +++ b/src/telemetry_test.go @@ -0,0 +1,205 @@ +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 := tibiaDataTelemetryEnabled + + otel.SetTracerProvider(tracerProvider) + otel.SetMeterProvider(meterProvider) + otel.SetTextMapPropagator(propagation.TraceContext{}) + tibiaDataTelemetryEnabled = true + + t.Cleanup(func() { + otel.SetTracerProvider(prevTP) + otel.SetMeterProvider(prevMP) + otel.SetTextMapPropagator(prevProp) + tibiaDataTelemetryEnabled = prevEnabled + _ = 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) { + t.Setenv(telemetryEnabledEnv, "true") + t.Setenv("OTEL_TRACES_EXPORTER", "none") + t.Setenv("OTEL_METRICS_EXPORTER", "none") + prevTP, prevMP, prevProp := otel.GetTracerProvider(), otel.GetMeterProvider(), otel.GetTextMapPropagator() + t.Cleanup(func() { + otel.SetTracerProvider(prevTP) + otel.SetMeterProvider(prevMP) + otel.SetTextMapPropagator(prevProp) + tibiaDataTelemetryEnabled = false + }) + + shutdown, err := initTibiaDataTelemetry(context.Background()) + + require.NoError(t, err) + assert.True(t, tibiaDataTelemetryEnabled) + _, isSDK := otel.GetTracerProvider().(*sdktrace.TracerProvider) + assert.True(t, isSDK) + 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(TibiaDataRequestStruct{ + Method: http.MethodGet, + URL: server.URL, + RawBody: true, + Ctx: ctx, + }) + 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()) +} diff --git a/src/webserver.go b/src/webserver.go index 419bdc06..1c1f0230 100644 --- a/src/webserver.go +++ b/src/webserver.go @@ -25,6 +25,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 +80,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 tibiaDataTelemetryEnabled { + httpClient := tibiaDataClient.GetClient() + httpClient.Transport = otelhttp.NewTransport(httpClient.Transport) + } } // DebugOutInformation wraps OutInformation with some debug info @@ -123,6 +130,16 @@ type TibiaDataRequestStruct struct { UseFansiteAPI bool `json:"use_fansite_api"` // If true, fetch data from Fansite API JSON endpoint CacheMaxAge time.Duration `json:"-"` UserAgent string `json:"-"` // User-Agent for the request (falls back to client default when empty) + Ctx context.Context `json:"-"` // Context of the incoming request, used for cancellation and trace propagation +} + +// 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() } // RunWebServer starts the gin server @@ -141,6 +158,7 @@ func runWebServer() { slog.Info("TibiaData API gin-mode", "mode", gin.Mode()) router := gin.New() + router.Use(ginTelemetryMiddleware()) router.Use(ginAccessLogMiddleware()) router.Use(ginRecoveryMiddleware()) router.Use(gzip.Gzip(gzip.DefaultCompression)) @@ -236,6 +254,7 @@ func runWebServer() { tibiaDataLogFatal("TibiaData API server close error", err) } }() + defer shutdownTibiaDataTelemetry() // setting readyz endpoint to true isReady.Store(true) @@ -1329,6 +1348,9 @@ func tibiaDataRequestHandler(c *gin.Context, tibiaDataRequest TibiaDataRequestSt if tibiaDataRequest.UserAgent == "" { tibiaDataRequest.UserAgent = tibiaDataUserAgentForRequest(c) } + if tibiaDataRequest.Ctx == nil { + tibiaDataRequest.Ctx = tibiaDataRequestContext(c) + } if tibiaDataRequest.UseFansiteAPI { boxContent, err = TibiaDataJSONDataCollector(tibiaDataRequest) } else { @@ -1362,6 +1384,9 @@ func TibiaDataJSONDataCollector(TibiaDataRequest TibiaDataRequestStruct) (string req := tibiaDataClient.R(). SetHeader("Authorization", "Bearer "+TibiaFansiteToken) + if TibiaDataRequest.Ctx != nil { + req.SetContext(TibiaDataRequest.Ctx) + } if TibiaDataRequest.UserAgent != "" { req.SetHeader(userAgentHeader, TibiaDataRequest.UserAgent) } @@ -1526,6 +1551,9 @@ func TibiaDataHTMLDataCollector(TibiaDataRequest TibiaDataRequestStruct) (string ) req := tibiaDataClient.R() + if TibiaDataRequest.Ctx != nil { + req.SetContext(TibiaDataRequest.Ctx) + } if TibiaDataRequest.UserAgent != "" { req.SetHeader(userAgentHeader, TibiaDataRequest.UserAgent) } From 9d051126a5dbd7d6e121b662ca2ba2674fa8bacd Mon Sep 17 00:00:00 2001 From: tobiasehlert Date: Wed, 21 Oct 2026 14:45:27 +0200 Subject: [PATCH 2/5] refactor(telemetry): pass context directly to collectors Remove context from request data and propagate it as the first parameter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/TibiaHousesOverview.go | 7 ++--- src/TibiaHousesOverview_test.go | 13 ++++++--- src/telemetry_test.go | 3 +- src/webserver.go | 22 ++++----------- src/webserver_test.go | 49 +++++++++++++++++++++++++++------ 5 files changed, 59 insertions(+), 35 deletions(-) diff --git a/src/TibiaHousesOverview.go b/src/TibiaHousesOverview.go index d7610a2c..0bcf5a34 100644 --- a/src/TibiaHousesOverview.go +++ b/src/TibiaHousesOverview.go @@ -50,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 @@ -94,7 +94,7 @@ func TibiaHousesOverviewImpl(c *gin.Context, world, town string, htmlDataCollect }, nil } -func makeHouseRequest(ctx context.Context, 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 @@ -102,10 +102,9 @@ func makeHouseRequest(ctx context.Context, HouseType, world, town, userAgent str Method: resty.MethodGet, URL: "https://www.tibia.com/community/?subtopic=houses&world=" + TibiaDataQueryEscapeString(world) + "&town=" + TibiaDataQueryEscapeString(town) + "&type=" + TibiaDataQueryEscapeString(HouseType), UserAgent: userAgent, - Ctx: ctx, } - 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 bea466e7..3620057c 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/telemetry_test.go b/src/telemetry_test.go index 3b147702..7d3e8dd4 100644 --- a/src/telemetry_test.go +++ b/src/telemetry_test.go @@ -183,11 +183,10 @@ func TestTibiaDataHTMLDataCollectorPropagatesTraceContext(t *testing.T) { defer server.Close() ctx, parent := otel.Tracer("test").Start(context.Background(), "parent") - _, err := TibiaDataHTMLDataCollector(TibiaDataRequestStruct{ + _, err := TibiaDataHTMLDataCollector(ctx, TibiaDataRequestStruct{ Method: http.MethodGet, URL: server.URL, RawBody: true, - Ctx: ctx, }) parent.End() require.NoError(t, err) diff --git a/src/webserver.go b/src/webserver.go index 1c1f0230..4c892b39 100644 --- a/src/webserver.go +++ b/src/webserver.go @@ -130,7 +130,6 @@ type TibiaDataRequestStruct struct { UseFansiteAPI bool `json:"use_fansite_api"` // If true, fetch data from Fansite API JSON endpoint CacheMaxAge time.Duration `json:"-"` UserAgent string `json:"-"` // User-Agent for the request (falls back to client default when empty) - Ctx context.Context `json:"-"` // Context of the incoming request, used for cancellation and trace propagation } // tibiaDataRequestContext returns the context of the incoming request, or @@ -1348,13 +1347,10 @@ func tibiaDataRequestHandler(c *gin.Context, tibiaDataRequest TibiaDataRequestSt if tibiaDataRequest.UserAgent == "" { tibiaDataRequest.UserAgent = tibiaDataUserAgentForRequest(c) } - if tibiaDataRequest.Ctx == nil { - tibiaDataRequest.Ctx = tibiaDataRequestContext(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) @@ -1377,16 +1373,13 @@ 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.Ctx != nil { - req.SetContext(TibiaDataRequest.Ctx) - } if TibiaDataRequest.UserAgent != "" { req.SetHeader(userAgentHeader, TibiaDataRequest.UserAgent) } @@ -1537,7 +1530,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) @@ -1550,10 +1543,7 @@ func TibiaDataHTMLDataCollector(TibiaDataRequest TibiaDataRequestStruct) (string LogMessage string ) - req := tibiaDataClient.R() - if TibiaDataRequest.Ctx != nil { - req.SetContext(TibiaDataRequest.Ctx) - } + 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 1736bcbf..5461863e 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() { From dd526dc77259fe9489718b0c4208c8d82961e1fd Mon Sep 17 00:00:00 2001 From: tobiasehlert Date: Wed, 21 Oct 2026 15:16:05 +0200 Subject: [PATCH 3/5] perf(telemetry): skip SDK providers for disabled exporters Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 9 ++++++++ src/telemetry.go | 43 ++++++++++++++++++++++++------------- src/telemetry_test.go | 49 +++++++++++++++++++++++++++---------------- 3 files changed, 69 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 98d1d1a9..9a25e9c2 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,15 @@ All other settings use the standard [OpenTelemetry environment variables](https: | `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` is fully disabled, so it adds no per-request work. 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/src/telemetry.go b/src/telemetry.go index b931b046..8795cd04 100644 --- a/src/telemetry.go +++ b/src/telemetry.go @@ -48,26 +48,43 @@ func initTibiaDataTelemetry(ctx context.Context) (func(context.Context) error, e 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 } - tracerProvider := sdktrace.NewTracerProvider( - sdktrace.WithBatcher(spanExporter), - sdktrace.WithResource(res), - ) + if !autoexport.IsNoneSpanExporter(spanExporter) { + tracerProvider := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(spanExporter), + sdktrace.WithResource(res), + ) + otel.SetTracerProvider(tracerProvider) + shutdowns = append(shutdowns, tracerProvider.Shutdown) + } metricReader, err := autoexport.NewMetricReader(ctx) if err != nil { - return noopShutdown, errors.Join(err, tracerProvider.Shutdown(ctx)) + return noopShutdown, errors.Join(err, shutdown(ctx)) + } + if !autoexport.IsNoneMetricReader(metricReader) { + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(metricReader), + sdkmetric.WithResource(res), + ) + otel.SetMeterProvider(meterProvider) + shutdowns = append(shutdowns, meterProvider.Shutdown) } - meterProvider := sdkmetric.NewMeterProvider( - sdkmetric.WithReader(metricReader), - sdkmetric.WithResource(res), - ) - otel.SetTracerProvider(tracerProvider) - otel.SetMeterProvider(meterProvider) otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}, @@ -82,9 +99,7 @@ func initTibiaDataTelemetry(ctx context.Context) (func(context.Context) error, e "metrics_exporter", envOrDefault("OTEL_METRICS_EXPORTER", "otlp"), ) - return func(ctx context.Context) error { - return errors.Join(tracerProvider.Shutdown(ctx), meterProvider.Shutdown(ctx)) - }, nil + return shutdown, nil } func tibiaDataTelemetryResource(ctx context.Context) (*resource.Resource, error) { diff --git a/src/telemetry_test.go b/src/telemetry_test.go index 7d3e8dd4..71b235dc 100644 --- a/src/telemetry_test.go +++ b/src/telemetry_test.go @@ -86,24 +86,37 @@ func TestInitTibiaDataTelemetryDisabledByDefault(t *testing.T) { } func TestInitTibiaDataTelemetryEnabled(t *testing.T) { - t.Setenv(telemetryEnabledEnv, "true") - t.Setenv("OTEL_TRACES_EXPORTER", "none") - t.Setenv("OTEL_METRICS_EXPORTER", "none") - prevTP, prevMP, prevProp := otel.GetTracerProvider(), otel.GetMeterProvider(), otel.GetTextMapPropagator() - t.Cleanup(func() { - otel.SetTracerProvider(prevTP) - otel.SetMeterProvider(prevMP) - otel.SetTextMapPropagator(prevProp) - tibiaDataTelemetryEnabled = false - }) - - shutdown, err := initTibiaDataTelemetry(context.Background()) - - require.NoError(t, err) - assert.True(t, tibiaDataTelemetryEnabled) - _, isSDK := otel.GetTracerProvider().(*sdktrace.TracerProvider) - assert.True(t, isSDK) - assert.NoError(t, shutdown(context.Background())) + 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 = false + }) + + shutdown, err := initTibiaDataTelemetry(context.Background()) + + require.NoError(t, err) + assert.True(t, tibiaDataTelemetryEnabled) + _, 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) { From a0f4267b5929ea20e42c491a8419102204555993 Mon Sep 17 00:00:00 2001 From: tobiasehlert Date: Wed, 21 Oct 2026 15:16:05 +0200 Subject: [PATCH 4/5] fix(server): handle SIGTERM for graceful shutdown Kubernetes stops pods with SIGTERM; without handling it the server exited without draining requests or flushing telemetry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/webserver.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/webserver.go b/src/webserver.go index 4c892b39..87d319c8 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" @@ -243,7 +244,7 @@ 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 go func() { From 6acdba0be14fa8d35611041a254653669634fd54 Mon Sep 17 00:00:00 2001 From: tobiasehlert Date: Wed, 21 Oct 2026 15:26:41 +0200 Subject: [PATCH 5/5] fix(server): drain requests on shutdown and gate telemetry hooks Use http.Server.Shutdown with a 20s bound on SIGTERM/interrupt, mark the server not ready, and wait for draining to finish before flushing telemetry. Only install otelgin and otelhttp instrumentation when at least one OpenTelemetry signal is exported, and skip the probe-filter context when tracing is off. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- src/telemetry.go | 14 ++++++++++++++ src/telemetry_test.go | 29 +++++++++++++++++++++++++---- src/webserver.go | 24 +++++++++++++++++++----- 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9a25e9c2..575ce678 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ All other settings use the standard [OpenTelemetry environment variables](https: | `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` is fully disabled, so it adds no per-request work. For example, to collect only metrics: +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 diff --git a/src/telemetry.go b/src/telemetry.go index 8795cd04..2341d09b 100644 --- a/src/telemetry.go +++ b/src/telemetry.go @@ -28,6 +28,8 @@ const ( var ( tibiaDataTelemetryEnabled bool + tibiaDataTracingEnabled bool + tibiaDataMetricsEnabled bool tibiaDataTelemetryStop = func(context.Context) error { return nil } ) @@ -38,6 +40,7 @@ func initTibiaDataTelemetry(ctx context.Context) (func(context.Context) error, e 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 @@ -69,6 +72,7 @@ func initTibiaDataTelemetry(ctx context.Context) (func(context.Context) error, e sdktrace.WithResource(res), ) otel.SetTracerProvider(tracerProvider) + tibiaDataTracingEnabled = true shutdowns = append(shutdowns, tracerProvider.Shutdown) } @@ -82,6 +86,7 @@ func initTibiaDataTelemetry(ctx context.Context) (func(context.Context) error, e sdkmetric.WithResource(res), ) otel.SetMeterProvider(meterProvider) + tibiaDataMetricsEnabled = true shutdowns = append(shutdowns, meterProvider.Shutdown) } @@ -127,6 +132,12 @@ func envOrDefault(key, fallback string) string { 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 { @@ -134,6 +145,9 @@ func ginTelemetryMiddleware() gin.HandlerFunc { 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)) diff --git a/src/telemetry_test.go b/src/telemetry_test.go index 71b235dc..9a3af4a9 100644 --- a/src/telemetry_test.go +++ b/src/telemetry_test.go @@ -30,18 +30,18 @@ func setupTestTelemetry(t *testing.T) (*tracetest.SpanRecorder, *sdkmetric.Manua meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) prevTP, prevMP, prevProp := otel.GetTracerProvider(), otel.GetMeterProvider(), otel.GetTextMapPropagator() - prevEnabled := tibiaDataTelemetryEnabled + prevEnabled, prevTracing, prevMetrics := tibiaDataTelemetryEnabled, tibiaDataTracingEnabled, tibiaDataMetricsEnabled otel.SetTracerProvider(tracerProvider) otel.SetMeterProvider(meterProvider) otel.SetTextMapPropagator(propagation.TraceContext{}) - tibiaDataTelemetryEnabled = true + tibiaDataTelemetryEnabled, tibiaDataTracingEnabled, tibiaDataMetricsEnabled = true, true, true t.Cleanup(func() { otel.SetTracerProvider(prevTP) otel.SetMeterProvider(prevMP) otel.SetTextMapPropagator(prevProp) - tibiaDataTelemetryEnabled = prevEnabled + tibiaDataTelemetryEnabled, tibiaDataTracingEnabled, tibiaDataMetricsEnabled = prevEnabled, prevTracing, prevMetrics _ = tracerProvider.Shutdown(context.Background()) _ = meterProvider.Shutdown(context.Background()) }) @@ -103,13 +103,15 @@ func TestInitTibiaDataTelemetryEnabled(t *testing.T) { otel.SetTracerProvider(prevTP) otel.SetMeterProvider(prevMP) otel.SetTextMapPropagator(prevProp) - tibiaDataTelemetryEnabled = false + 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) @@ -215,3 +217,22 @@ func TestTibiaDataHTMLDataCollectorPropagatesTraceContext(t *testing.T) { 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 87d319c8..1fa43310 100644 --- a/src/webserver.go +++ b/src/webserver.go @@ -83,7 +83,7 @@ func initTibiaDataClient() { tibiaDataClient.SetRedirectPolicy(resty.NoRedirectPolicy()) // Trace outbound requests and propagate trace context when telemetry is enabled - if tibiaDataTelemetryEnabled { + if tibiaDataTelemetryActive() { httpClient := tibiaDataClient.GetClient() httpClient.Transport = otelhttp.NewTransport(httpClient.Transport) } @@ -142,6 +142,10 @@ func tibiaDataRequestContext(c *gin.Context) context.Context { 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() { @@ -158,7 +162,9 @@ func runWebServer() { slog.Info("TibiaData API gin-mode", "mode", gin.Mode()) router := gin.New() - router.Use(ginTelemetryMiddleware()) + if tibiaDataTelemetryActive() { + router.Use(ginTelemetryMiddleware()) + } router.Use(ginAccessLogMiddleware()) router.Use(ginRecoveryMiddleware()) router.Use(gzip.Gzip(gzip.DefaultCompression)) @@ -246,12 +252,19 @@ func runWebServer() { quit := make(chan os.Signal, 1) 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() @@ -263,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)