From 038d94b1d17f795ffb3548c47fcfaf883149f838 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Mon, 15 Jun 2026 17:30:52 +0300 Subject: [PATCH 1/2] feat(observability): add Prometheus metrics and Grafana dashboards Exposes a /metrics endpoint from the Go backend using promhttp, instruments all HTTP routes with request count and latency histograms, and exports DB connection pool stats via a custom prometheus.Collector. Prometheus and Grafana are wired into Docker Compose (ports 9090 and 3001) with a provisioned data source and starter dashboard covering request rate, p50/p95 latency, and DB pool connections. Closes #17. --- backend/.env.example | 6 +- backend/docker-compose.yml | 25 +++ backend/docs/environment.md | 2 + backend/docs/observability.md | 2 +- backend/docs/swagger/docs.go | 19 +++ backend/docs/swagger/swagger.json | 19 +++ backend/docs/swagger/swagger.yaml | 12 ++ backend/go.mod | 14 +- backend/go.sum | 24 +++ .../provisioning/dashboards/backend.json | 143 ++++++++++++++++++ .../provisioning/dashboards/provider.yml | 9 ++ .../provisioning/datasources/prometheus.yml | 9 ++ .../database/postgres/db_metrics.go | 98 ++++++++++++ backend/internal/server/server.go | 13 ++ .../transport/handlers/metrics_handler.go | 13 ++ .../handlers/metrics_handler_test.go | 26 ++++ backend/internal/transport/handlers/routes.go | 3 + .../internal/transport/middleware/metrics.go | 50 ++++++ .../transport/middleware/metrics_test.go | 68 +++++++++ backend/prometheus.yml | 9 ++ 20 files changed, 559 insertions(+), 5 deletions(-) create mode 100644 backend/grafana/provisioning/dashboards/backend.json create mode 100644 backend/grafana/provisioning/dashboards/provider.yml create mode 100644 backend/grafana/provisioning/datasources/prometheus.yml create mode 100644 backend/internal/infrastructure/database/postgres/db_metrics.go create mode 100644 backend/internal/transport/handlers/metrics_handler.go create mode 100644 backend/internal/transport/handlers/metrics_handler_test.go create mode 100644 backend/internal/transport/middleware/metrics.go create mode 100644 backend/internal/transport/middleware/metrics_test.go create mode 100644 backend/prometheus.yml diff --git a/backend/.env.example b/backend/.env.example index 2a7ac38..9c31cdd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,4 +18,8 @@ FIREBASE_SERVICE_ACCOUNT_JSON= SENTRY_DSN= # WebSocket allowed origin in staging/production — e.g. https://example.com # Leave empty to deny all cross-origin WebSocket connections in non-debug mode. -BLUEPRINT_WS_ALLOWED_ORIGIN=http://localhost:3000 \ No newline at end of file +BLUEPRINT_WS_ALLOWED_ORIGIN=http://localhost:3000 +# Grafana admin credentials (Docker Compose only; defaults to admin/admin locally) +# Override in production via environment variables — never commit real passwords. +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=admin \ No newline at end of file diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index d399ae2..24a286f 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -11,5 +11,30 @@ services: volumes: - psql_volume_bp:/var/lib/postgresql/data + prometheus: + image: prom/prometheus:v3.4.0 + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + extra_hosts: + - "host.docker.internal:host-gateway" + + grafana: + image: grafana/grafana:11.6.0 + restart: unless-stopped + ports: + - "3001:3000" + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + depends_on: + - prometheus + volumes: psql_volume_bp: + grafana_data: diff --git a/backend/docs/environment.md b/backend/docs/environment.md index 0cd36a7..26f7373 100644 --- a/backend/docs/environment.md +++ b/backend/docs/environment.md @@ -34,6 +34,8 @@ This runs on package init before any env var is read — no explicit `godotenv.L | `RATE_LIMIT_BURST` | `bootstrap.go` | `int(RPS) * 5`, min 1 | Token-bucket burst capacity. Derived as `int(RPS)*5` when omitted; clamped to 1 so fractional RPS values never block all traffic. | | `FIREBASE_PROJECT_ID` | `bootstrap.go`, `pkg/firebase/admin.go` | — | Firebase project ID. When omitted the Firebase Admin client is not initialised and `FirebaseAuth` middleware is skipped (auth disabled). | | `FIREBASE_SERVICE_ACCOUNT_JSON` | `bootstrap.go`, `pkg/firebase/admin.go` | — | Raw JSON content of a Firebase service account key file. When omitted the SDK falls back to Application Default Credentials (ADC) — appropriate for GCP-hosted deployments. Only relevant when `FIREBASE_PROJECT_ID` is set. | +| `REDIS_URL` | `bootstrap.go` | — | Redis connection URL. When omitted or empty, cache/Redis initialization is skipped and the app runs without Redis. | +| `BLUEPRINT_WS_ALLOWED_ORIGIN` | `internal/transport/handlers/ws_handler.go` | — | Allowed origin for WebSocket CORS checks in staging/production. When omitted, WebSocket origin validation is skipped (local dev). | Variables marked **required** are validated by `bootstrap.validateConfig` at startup — the process exits before attempting a DB connection if any are missing. diff --git a/backend/docs/observability.md b/backend/docs/observability.md index d904e83..3395f2d 100644 --- a/backend/docs/observability.md +++ b/backend/docs/observability.md @@ -41,7 +41,7 @@ Behavior: `RegisterRoutes` signature: ```go -func (h *Handler) RegisterRoutes(rps float64, burst int, verifier usecase.FirebaseTokenVerifier, sentryDSN string) http.Handler +func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http.Handler ``` The `sentryDSN` parameter is forwarded directly from `Config.SentryDSN`. diff --git a/backend/docs/swagger/docs.go b/backend/docs/swagger/docs.go index 2d1b049..9a74018 100644 --- a/backend/docs/swagger/docs.go +++ b/backend/docs/swagger/docs.go @@ -98,6 +98,25 @@ const docTemplate = `{ } } }, + "/metrics": { + "get": { + "produces": [ + "text/plain" + ], + "tags": [ + "observability" + ], + "summary": "Prometheus metrics", + "responses": { + "200": { + "description": "Prometheus exposition format", + "schema": { + "type": "string" + } + } + } + } + }, "/ws": { "get": { "description": "Upgrades HTTP to WebSocket. Pass a Firebase ID token as ` + "`" + `?token=\u003ctoken\u003e` + "`" + `. Returns 401 when the token is missing or invalid.", diff --git a/backend/docs/swagger/swagger.json b/backend/docs/swagger/swagger.json index 6af64f7..40a1319 100644 --- a/backend/docs/swagger/swagger.json +++ b/backend/docs/swagger/swagger.json @@ -92,6 +92,25 @@ } } }, + "/metrics": { + "get": { + "produces": [ + "text/plain" + ], + "tags": [ + "observability" + ], + "summary": "Prometheus metrics", + "responses": { + "200": { + "description": "Prometheus exposition format", + "schema": { + "type": "string" + } + } + } + } + }, "/ws": { "get": { "description": "Upgrades HTTP to WebSocket. Pass a Firebase ID token as `?token=\u003ctoken\u003e`. Returns 401 when the token is missing or invalid.", diff --git a/backend/docs/swagger/swagger.yaml b/backend/docs/swagger/swagger.yaml index 8c5a43a..5789ec5 100644 --- a/backend/docs/swagger/swagger.yaml +++ b/backend/docs/swagger/swagger.yaml @@ -97,6 +97,18 @@ paths: summary: Health check tags: - ops + /metrics: + get: + produces: + - text/plain + responses: + "200": + description: Prometheus exposition format + schema: + type: string + summary: Prometheus metrics + tags: + - observability /ws: get: description: Upgrades HTTP to WebSocket. Pass a Firebase ID token as `?token=`. diff --git a/backend/go.mod b/backend/go.mod index b5a6056..5bad526 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,11 +8,15 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.32.25 github.com/aws/aws-sdk-go-v2/credentials v1.19.24 github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 + github.com/getsentry/sentry-go v0.46.2 + github.com/getsentry/sentry-go/gin v0.46.2 github.com/gin-contrib/cors v1.7.7 github.com/gin-gonic/gin v1.12.0 + github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 github.com/pressly/goose/v3 v3.27.1 + github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.20.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 @@ -58,6 +62,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect github.com/aws/smithy-go v1.27.1 // 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 @@ -79,8 +84,6 @@ require ( github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect - github.com/getsentry/sentry-go v0.46.2 // indirect - github.com/getsentry/sentry-go/gin v0.46.2 // indirect github.com/gin-contrib/sse v1.1.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -101,7 +104,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -109,6 +111,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect @@ -126,12 +129,16 @@ require ( github.com/moby/term v0.5.2 // 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/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect @@ -156,6 +163,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/arch v0.28.0 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/mod v0.36.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 95314cd..43058d2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -84,6 +84,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUY github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -153,6 +155,8 @@ 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-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -232,6 +236,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -273,6 +279,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w 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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -282,6 +290,10 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -291,6 +303,14 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= 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= @@ -373,10 +393,14 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +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.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ= golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/backend/grafana/provisioning/dashboards/backend.json b/backend/grafana/provisioning/dashboards/backend.json new file mode 100644 index 0000000..9a2acea --- /dev/null +++ b/backend/grafana/provisioning/dashboards/backend.json @@ -0,0 +1,143 @@ +{ + "__inputs": [], + "__requires": [], + "annotations": { + "list": [] + }, + "description": "Backend service overview: request rate, latency, and DB pool stats.", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 1 } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "id": 1, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "single" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum by (method, path, status) (rate(http_requests_total[1m]))", + "legendFormat": "{{method}} {{path}} {{status}}", + "refId": "A" + } + ], + "title": "Request Rate", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 1 }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "id": 2, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "single" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.50, sum by (le, method, path) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p50 {{method}} {{path}}", + "refId": "A" + } + ], + "title": "p50 Latency", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 1 }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "id": 3, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "single" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.95, sum by (le, method, path) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p95 {{method}} {{path}}", + "refId": "A" + } + ], + "title": "p95 Latency", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 1 }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "id": 4, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "multi" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "db_pool_open_connections", + "legendFormat": "open", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "db_pool_in_use_connections", + "legendFormat": "in-use", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "db_pool_idle_connections", + "legendFormat": "idle", + "refId": "C" + } + ], + "title": "DB Pool Connections", + "type": "timeseries" + } + ], + "schemaVersion": 39, + "tags": ["backend", "observability"], + "templating": { "list": [] }, + "time": { "from": "now-1h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "Backend Overview", + "uid": "backend-overview", + "version": 1 +} diff --git a/backend/grafana/provisioning/dashboards/provider.yml b/backend/grafana/provisioning/dashboards/provider.yml new file mode 100644 index 0000000..0704e60 --- /dev/null +++ b/backend/grafana/provisioning/dashboards/provider.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +providers: + - name: backend + type: file + disableDeletion: true + updateIntervalSeconds: 30 + options: + path: /etc/grafana/provisioning/dashboards diff --git a/backend/grafana/provisioning/datasources/prometheus.yml b/backend/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..bb009bb --- /dev/null +++ b/backend/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/backend/internal/infrastructure/database/postgres/db_metrics.go b/backend/internal/infrastructure/database/postgres/db_metrics.go new file mode 100644 index 0000000..feb662b --- /dev/null +++ b/backend/internal/infrastructure/database/postgres/db_metrics.go @@ -0,0 +1,98 @@ +package postgres + +import ( + "database/sql" + + "github.com/prometheus/client_golang/prometheus" +) + +type dbStatsCollector struct { + db *sql.DB + + maxOpenConns *prometheus.Desc + openConns *prometheus.Desc + inUse *prometheus.Desc + idle *prometheus.Desc + waitCount *prometheus.Desc + waitDuration *prometheus.Desc + maxIdleClosed *prometheus.Desc + maxIdleTimeClosed *prometheus.Desc + maxLifetimeClosed *prometheus.Desc +} + +// NewDBStatsCollector returns a prometheus.Collector that exports sql.DBStats. +func NewDBStatsCollector(db *sql.DB) prometheus.Collector { + return &dbStatsCollector{ + db: db, + maxOpenConns: prometheus.NewDesc( + "db_pool_max_open_connections", + "Maximum number of open connections to the database.", + nil, nil, + ), + openConns: prometheus.NewDesc( + "db_pool_open_connections", + "Current number of open connections including in-use and idle.", + nil, nil, + ), + inUse: prometheus.NewDesc( + "db_pool_in_use_connections", + "Current number of connections in use.", + nil, nil, + ), + idle: prometheus.NewDesc( + "db_pool_idle_connections", + "Current number of idle connections.", + nil, nil, + ), + waitCount: prometheus.NewDesc( + "db_pool_wait_count_total", + "Total number of connections waited for.", + nil, nil, + ), + waitDuration: prometheus.NewDesc( + "db_pool_wait_duration_seconds_total", + "Total time blocked waiting for a new connection.", + nil, nil, + ), + maxIdleClosed: prometheus.NewDesc( + "db_pool_max_idle_closed_total", + "Total connections closed due to SetMaxIdleConns.", + nil, nil, + ), + maxIdleTimeClosed: prometheus.NewDesc( + "db_pool_max_idle_time_closed_total", + "Total connections closed due to SetConnMaxIdleTime.", + nil, nil, + ), + maxLifetimeClosed: prometheus.NewDesc( + "db_pool_max_lifetime_closed_total", + "Total connections closed due to SetConnMaxLifetime.", + nil, nil, + ), + } +} + +func (c *dbStatsCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.maxOpenConns + ch <- c.openConns + ch <- c.inUse + ch <- c.idle + ch <- c.waitCount + ch <- c.waitDuration + ch <- c.maxIdleClosed + ch <- c.maxIdleTimeClosed + ch <- c.maxLifetimeClosed +} + +func (c *dbStatsCollector) Collect(ch chan<- prometheus.Metric) { + s := c.db.Stats() + ch <- prometheus.MustNewConstMetric(c.maxOpenConns, prometheus.GaugeValue, float64(s.MaxOpenConnections)) + ch <- prometheus.MustNewConstMetric(c.openConns, prometheus.GaugeValue, float64(s.OpenConnections)) + ch <- prometheus.MustNewConstMetric(c.inUse, prometheus.GaugeValue, float64(s.InUse)) + ch <- prometheus.MustNewConstMetric(c.idle, prometheus.GaugeValue, float64(s.Idle)) + ch <- prometheus.MustNewConstMetric(c.waitCount, prometheus.CounterValue, float64(s.WaitCount)) + ch <- prometheus.MustNewConstMetric(c.waitDuration, prometheus.CounterValue, s.WaitDuration.Seconds()) + ch <- prometheus.MustNewConstMetric(c.maxIdleClosed, prometheus.CounterValue, float64(s.MaxIdleClosed)) + ch <- prometheus.MustNewConstMetric(c.maxIdleTimeClosed, prometheus.CounterValue, float64(s.MaxIdleTimeClosed)) + ch <- prometheus.MustNewConstMetric(c.maxLifetimeClosed, prometheus.CounterValue, float64(s.MaxLifetimeClosed)) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 68a53c0..3876539 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -1,11 +1,13 @@ package server import ( + "errors" "fmt" "net/http" "time" "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" "backend/internal/bootstrap" "backend/internal/infrastructure/database/postgres" @@ -31,6 +33,17 @@ func NewServer(app *bootstrap.App, hub *ws.Hub) *http.Server { healthUC := usecase.NewHealthUseCase(healthRepo) h := handlers.NewHandler(healthUC, app.Firebase, hub) + // Register DB pool metrics collector. + // AlreadyRegisteredError is silenced — only the first registration wins + // (safe for test suites that call NewServer more than once). + dbCollector := postgres.NewDBStatsCollector(app.DB) + if err := prometheus.Register(dbCollector); err != nil { + var are prometheus.AlreadyRegisteredError + if !errors.As(err, &are) { + app.Log.Warn("server: failed to register db metrics collector", "err", err) + } + } + return &http.Server{ Addr: fmt.Sprintf(":%d", app.Config.Port), Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst, app.Config.SentryDSN), diff --git a/backend/internal/transport/handlers/metrics_handler.go b/backend/internal/transport/handlers/metrics_handler.go new file mode 100644 index 0000000..b7e94c6 --- /dev/null +++ b/backend/internal/transport/handlers/metrics_handler.go @@ -0,0 +1,13 @@ +package handlers + +import "github.com/gin-gonic/gin" + +// MetricsHandler serves Prometheus metrics. +// Actual scraping logic is delegated to promhttp.Handler() registered in routes.go. +// +// @Summary Prometheus metrics +// @Tags observability +// @Produce plain +// @Success 200 {string} string "Prometheus exposition format" +// @Router /metrics [get] +func (h *Handler) MetricsHandler(_ *gin.Context) {} diff --git a/backend/internal/transport/handlers/metrics_handler_test.go b/backend/internal/transport/handlers/metrics_handler_test.go new file mode 100644 index 0000000..6fbbfd1 --- /dev/null +++ b/backend/internal/transport/handlers/metrics_handler_test.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestMetricsEndpoint_Returns200(t *testing.T) { + h := NewHandler(nil, nil, nil) + handler := h.RegisterRoutes(0, 0, "") + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "text/plain") { + t.Errorf("expected Content-Type to contain text/plain, got %q", ct) + } +} diff --git a/backend/internal/transport/handlers/routes.go b/backend/internal/transport/handlers/routes.go index efe88d4..75d09ea 100644 --- a/backend/internal/transport/handlers/routes.go +++ b/backend/internal/transport/handlers/routes.go @@ -5,6 +5,7 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus/promhttp" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" @@ -28,6 +29,7 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http. r.Use(gin.Recovery(), middleware.Logger()) } + r.Use(middleware.PrometheusMiddleware()) r.Use(middleware.RateLimit(rps, burst)) r.Use(cors.New(cors.Config{ @@ -40,6 +42,7 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http. r.GET("/", h.HelloWorldHandler) r.GET("/health", h.HealthHandler) r.GET("/ws", h.WsHandler) + r.GET("/metrics", gin.WrapH(promhttp.Handler())) r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) diff --git a/backend/internal/transport/middleware/metrics.go b/backend/internal/transport/middleware/metrics.go new file mode 100644 index 0000000..10c7c79 --- /dev/null +++ b/backend/internal/transport/middleware/metrics.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + httpRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Total number of HTTP requests by method, path, and status code.", + }, []string{"method", "path", "status"}) + + httpRequestDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "HTTP request latency in seconds.", + Buckets: prometheus.DefBuckets, + }, []string{"method", "path"}) +) + +// PrometheusMiddleware records HTTP request count and latency metrics. +// The /metrics endpoint itself is excluded from instrumentation. +func PrometheusMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.URL.Path == "/metrics" { + c.Next() + return + } + + start := time.Now() + c.Next() + + path := c.FullPath() + if path == "" { + path = "unmatched" + } + + httpRequestsTotal.WithLabelValues( + c.Request.Method, + path, + strconv.Itoa(c.Writer.Status()), + ).Inc() + httpRequestDurationSeconds.WithLabelValues(c.Request.Method, path). + Observe(time.Since(start).Seconds()) + } +} diff --git a/backend/internal/transport/middleware/metrics_test.go b/backend/internal/transport/middleware/metrics_test.go new file mode 100644 index 0000000..af9eb45 --- /dev/null +++ b/backend/internal/transport/middleware/metrics_test.go @@ -0,0 +1,68 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func init() { gin.SetMode(gin.TestMode) } + +func TestPrometheusMiddleware_RecordsMetrics(t *testing.T) { + r := gin.New() + r.Use(PrometheusMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + count := testutil.ToFloat64(httpRequestsTotal.WithLabelValues("GET", "/test", "200")) + if count != 1 { + t.Errorf("expected counter to be 1, got %v", count) + } +} + +func TestPrometheusMiddleware_SkipsMetricsEndpoint(t *testing.T) { + r := gin.New() + r.Use(PrometheusMiddleware()) + r.GET("/metrics", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + before := testutil.ToFloat64(httpRequestsTotal.WithLabelValues("GET", "/metrics", "200")) + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + after := testutil.ToFloat64(httpRequestsTotal.WithLabelValues("GET", "/metrics", "200")) + if before != after { + t.Errorf("metrics endpoint should not be instrumented: before=%v after=%v", before, after) + } +} + +func TestPrometheusMiddleware_UnmatchedRoute(t *testing.T) { + r := gin.New() + r.Use(PrometheusMiddleware()) + // no routes registered — any request is unmatched + + req := httptest.NewRequest(http.MethodGet, "/does-not-exist", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Should be labelled "unmatched", not panic + count := testutil.ToFloat64(httpRequestsTotal.WithLabelValues("GET", "unmatched", "404")) + if count < 1 { + t.Errorf("expected unmatched counter >= 1, got %v", count) + } +} diff --git a/backend/prometheus.yml b/backend/prometheus.yml new file mode 100644 index 0000000..e9efc6a --- /dev/null +++ b/backend/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: backend + static_configs: + - targets: + - host.docker.internal:8080 From 7c081ac7cb55ec47c7c4cbfac7fd8911cdb61859 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Mon, 15 Jun 2026 17:46:18 +0300 Subject: [PATCH 2/2] fix(observability): address CodeRabbit review findings - NewServer now returns error so collector registration failures bubble up to main rather than being silently swallowed - /metrics is restricted to loopback/RFC-1918 IPs in staging/production via a new LocalNetworkOnly middleware; unrestricted in debug mode - metrics handler test uses &Handler{} zero value instead of nil deps, consistent with the existing hello_handler_test.go pattern Co-Authored-By: Claude Sonnet 4.6 --- backend/cmd/api/main.go | 6 ++++- backend/internal/server/server.go | 6 ++--- .../handlers/metrics_handler_test.go | 2 +- backend/internal/transport/handlers/routes.go | 9 +++++++- .../transport/middleware/local_network.go | 22 +++++++++++++++++++ 5 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 backend/internal/transport/middleware/local_network.go diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 777c6fe..4350a20 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -60,7 +60,11 @@ func main() { hub := ws.NewHub() go hub.Run(hubCtx) - srv := server.NewServer(app, hub) + srv, err := server.NewServer(app, hub) + if err != nil { + fmt.Fprintf(os.Stderr, "startup failed: %v\n", err) + os.Exit(1) + } slog.Info("API docs", "url", fmt.Sprintf("http://localhost%s/swagger/index.html", srv.Addr)) done := make(chan bool, 1) diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 3876539..f619ddb 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -17,7 +17,7 @@ import ( ) // NewServer wires all layers and returns a configured *http.Server. -func NewServer(app *bootstrap.App, hub *ws.Hub) *http.Server { +func NewServer(app *bootstrap.App, hub *ws.Hub) (*http.Server, error) { switch app.Config.Env { case "staging", "production": gin.SetMode(gin.ReleaseMode) @@ -40,7 +40,7 @@ func NewServer(app *bootstrap.App, hub *ws.Hub) *http.Server { if err := prometheus.Register(dbCollector); err != nil { var are prometheus.AlreadyRegisteredError if !errors.As(err, &are) { - app.Log.Warn("server: failed to register db metrics collector", "err", err) + return nil, fmt.Errorf("server: register db metrics collector: %w", err) } } @@ -50,5 +50,5 @@ func NewServer(app *bootstrap.App, hub *ws.Hub) *http.Server { IdleTimeout: time.Minute, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, - } + }, nil } diff --git a/backend/internal/transport/handlers/metrics_handler_test.go b/backend/internal/transport/handlers/metrics_handler_test.go index 6fbbfd1..1c36423 100644 --- a/backend/internal/transport/handlers/metrics_handler_test.go +++ b/backend/internal/transport/handlers/metrics_handler_test.go @@ -8,7 +8,7 @@ import ( ) func TestMetricsEndpoint_Returns200(t *testing.T) { - h := NewHandler(nil, nil, nil) + h := &Handler{} handler := h.RegisterRoutes(0, 0, "") req := httptest.NewRequest(http.MethodGet, "/metrics", nil) diff --git a/backend/internal/transport/handlers/routes.go b/backend/internal/transport/handlers/routes.go index 75d09ea..7bf57c1 100644 --- a/backend/internal/transport/handlers/routes.go +++ b/backend/internal/transport/handlers/routes.go @@ -42,7 +42,14 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http. r.GET("/", h.HelloWorldHandler) r.GET("/health", h.HealthHandler) r.GET("/ws", h.WsHandler) - r.GET("/metrics", gin.WrapH(promhttp.Handler())) + + // In staging/production, restrict /metrics to loopback and RFC 1918 addresses + // so Prometheus can scrape from the internal network but external clients cannot. + if gin.Mode() == gin.ReleaseMode { + r.GET("/metrics", middleware.LocalNetworkOnly(), gin.WrapH(promhttp.Handler())) + } else { + r.GET("/metrics", gin.WrapH(promhttp.Handler())) + } r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) diff --git a/backend/internal/transport/middleware/local_network.go b/backend/internal/transport/middleware/local_network.go new file mode 100644 index 0000000..3793a5f --- /dev/null +++ b/backend/internal/transport/middleware/local_network.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "net" + "net/http" + + "github.com/gin-gonic/gin" +) + +// LocalNetworkOnly rejects requests that originate outside loopback or RFC 1918 +// private address space. Use this to restrict internal-only endpoints (e.g. +// /metrics) from being reachable by external clients in production. +func LocalNetworkOnly() gin.HandlerFunc { + return func(c *gin.Context) { + ip := net.ParseIP(c.ClientIP()) + if ip == nil || (!ip.IsLoopback() && !ip.IsPrivate()) { + c.AbortWithStatus(http.StatusForbidden) + return + } + c.Next() + } +}