Stage 7 · Master
Phase 5 — API Gateway
Logging
Extend the shared AccessLog middleware with the gateway-specific fields an incident actually needs, redact the Authorization header without exception, and keep noisy health checks from drowning out real traffic in the logs.
The Gateway's Access Log Carries More Than Any Backend Service's
Every backend service already logs via the shared httpmw.AccessLog established in the User module's bootstrap — method, path, status, duration, request ID. gateway-service sees strictly more per request than any single backend does: it knows which backend a request was routed to, and it is the one place a correlation ID, an org_id, and an Authorization header all pass through in a single log line if logging is done carelessly.
package httpmw
import (
"net/http"
"time"
"github.com/rs/zerolog"
)
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// AccessLog logs every request the gateway handles, including the upstream
// route it was dispatched to and its correlation ID — but never the
// Authorization header's value under any circumstance.
func AccessLog(logger zerolog.Logger, routeFor func(*http.Request) string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
if isHealthCheck(r) && rec.status == http.StatusOK {
return // sampled out entirely below, see health-check discussion
}
logger.Info().
Str("method", r.Method).
Str("path", r.URL.Path).
Int("status", rec.status).
Int64("duration_ms", time.Since(start).Milliseconds()).
Str("correlation_id", r.Header.Get(CorrelationHeader)).
Str("upstream_route", routeFor(r)).
Bool("has_authorization", r.Header.Get("Authorization") != ""). // presence only, never the value
Msg("request")
})
}
}
func isHealthCheck(r *http.Request) bool {
return r.URL.Path == "/healthz"
}has_authorization logs a boolean, never r.Header.Get("Authorization") itself — that header carries a live bearer token, and a leaked access log (shipped to a third-party aggregator, say) containing raw tokens would hand out working credentials to anyone with log read access, entirely defeating every defense built in the Authentication module.
engine.Use(gin.Recovery())
engine.Use(gatewaymw.RequestID())
engine.Use(sharedhttpmw.Adapt(gatewaymw.Correlation))
engine.Use(sharedhttpmw.Adapt(gatewaymw.AccessLog(logger, proxy.UpstreamRoute)))
engine.Use(sharedhttpmw.Adapt(sharedhttpmw.RequireAuth(tokenVerifier)))
engine.Use(sharedhttpmw.Adapt(ratelimit.PerOrgFairLimiter(rl)))
proxy.BuildRouter(engine, authURL, userURL, logger)Gateway's net/http-shaped logger receives proxy.UpstreamRoute explicitly, then the shared Adapt bridge mounts it in Gin. This replaces sharedhttpmw.AccessLog; mounting both would duplicate every request log.
A common mistake is logging the full header map and trusting a log-shipping pipeline's redaction rules to strip sensitive fields before storage. Any misconfiguration in that pipeline — a rule that doesn't match a header's exact casing, a new aggregator added without the same rules — silently reverts to logging raw tokens. This platform's rule is stricter: the Authorization header's value is never constructed into a log line in the first place, so there is nothing downstream tooling could fail to redact.
Why /healthz Success Never Appears in the Log
A Kubernetes liveness and readiness probe hits /healthz every few seconds, per pod, forever. Logging every single one at full volume would mean a healthy gateway with ten pods produces thousands of identical, uninformative log lines per hour, burying genuinely interesting requests in noise an on-call engineer has to scroll past during an incident. The isHealthCheck short-circuit above only suppresses successful health checks — a failing one (status != 200) still logs, since a failing health check is itself exactly the kind of event worth seeing.
Why upstream_route Earns Its Own Field
proxy.UpstreamRoute uses the same public path ownership encoded by BuildRouter's Gin groups to record which backend (user-service or auth-service) handles a request. Without this field, an incident investigator staring at a slow /api/v1/users/42 request in the gateway's log cannot tell, from that log alone, whether the slowness originated in the gateway or in user-service.
Confirming Redaction and Sampling Behavior
Applied exercise
Decide what to do when a query string itself contains sensitive data
A future search endpoint proxied through the gateway accepts a query parameter that could, in a misconfigured client, end up containing something sensitive (e.g., a mistakenly-passed token in a URL rather than a header) — and r.URL.Path in the current AccessLog implementation doesn't include query parameters at all yet, but a future change to add them would need this exact judgment call.
- State whether the gateway should ever log full query strings by default, given this platform's Authorization-redaction precedent.
- Propose a rule for which query parameters (if any) are safe to log by default versus which require an explicit allowlist.
- Explain why an allowlist-based approach (only log parameters explicitly marked safe) is more consistent with this lesson's redaction philosophy than a denylist-based approach (log everything except parameters explicitly marked sensitive).
Deliverable
A written logging policy for query parameters, consistent with the Authorization-header redaction precedent in this lesson.
Completion checks
- The answer chooses an allowlist approach and explicitly ties the reasoning back to the Authorization-header precedent (never log by default, only log what's proven safe).
Why does AccessLog log has_authorization as a boolean rather than logging the Authorization header's actual value?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.