Files
scrabble-game/backend/internal/telemetry/middleware.go
T
Ilia Denisov ff55d5de83
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
fix(gateway): forward the real client IP to the backend on every call
The backend recorded 172.19.0.9 — the gateway's own docker connection address — as
the client IP for all users: the account's last-login IP shown in the admin console,
and it never reached the backend access log. The gateway forwarded the client IP as
X-Forwarded-For only on chat/feedback calls; every other backend call (including the
profile fetch that stamps last_login_ip) sent none, so the backend fell back to the
peer address.

Carry the client IP on the request context (WithClientIP, mirroring WithPlatform) and
set it once per request in the Connect edge, so the backend client injects
X-Forwarded-For on every downstream REST call. Also add the resolved client IP to the
backend access log.

Test: WithClientIP rides a non-chat call (Profile) as X-Forwarded-For, and is absent
when no IP is set. Docs updated (ARCHITECTURE gateway↔backend + edge).
2026-07-14 11:23:32 +02:00

78 lines
2.3 KiB
Go

package telemetry
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.uber.org/zap"
)
// tracerName names the instrumentation scope for backend HTTP spans.
const tracerName = "scrabble/backend/server"
// Middleware returns gin middleware that, for every request, opens a server
// span, measures server-side latency, and emits a structured access log
// correlated with the active trace. It uses the globally-registered tracer, so
// spans are exported only when an exporter is configured, while the timing log
// is always emitted. Probe paths (/healthz, /readyz) log at debug level to keep
// the default log clean.
func Middleware(logger *zap.Logger) gin.HandlerFunc {
if logger == nil {
logger = zap.NewNop()
}
tracer := otel.Tracer(tracerName)
return func(c *gin.Context) {
start := time.Now()
route := c.FullPath()
if route == "" {
route = c.Request.URL.Path
}
ctx, span := tracer.Start(c.Request.Context(), c.Request.Method+" "+route)
c.Request = c.Request.WithContext(ctx)
c.Next()
status := c.Writer.Status()
elapsed := time.Since(start)
span.SetAttributes(
attribute.String("http.request.method", c.Request.Method),
attribute.String("http.route", route),
attribute.Int("http.response.status_code", status),
)
if status >= http.StatusInternalServerError {
span.SetStatus(codes.Error, http.StatusText(status))
}
span.End()
fields := []zap.Field{
zap.String("method", c.Request.Method),
zap.String("path", route),
zap.Int("status", status),
zap.Duration("latency", elapsed),
// The gateway forwards the real caller as X-Forwarded-For (and Caddy does for /_gm), which
// gin resolves here — so the access log carries the client IP, not the gateway's connection.
zap.String("client_ip", c.ClientIP()),
}
fields = append(fields, TraceFieldsFromContext(ctx)...)
if isProbePath(c.Request.URL.Path) {
logger.Debug("http request", fields...)
return
}
logger.Info("http request", fields...)
}
}
// isProbePath reports whether path is one of the unauthenticated infrastructure
// probes, whose access logs are demoted to debug level.
func isProbePath(path string) bool {
return path == "/healthz" || path == "/readyz"
}