127.0.0.1:6060 by default and therefore never reachable from outside the pod.
Don’t expose the admin port without a token.
GATEWAY_ADMIN_ADDR binds to loopback by default. If you genuinely need direct (non-port-forward) access, e.g. non-k8s deploys, or from a corporate VPN, you MUST also set GATEWAY_ADMIN_AUTH_TOKEN. The gateway refuses to start otherwise. See Helm → Admin listener for the three deployment postures. kubectl port-forward remains the simplest option for k8s, it tunnels through the API server and is auditable in Kubernetes audit logs.Prerequisites
One-time setup on your operator laptop:go tool pprof commands target http://localhost:6060.
Enabling pprof via Helm
The LangWatch Helm chart exposes the admin listener via a top-leveladmin stanza on the gateway sub-chart. The loopback-bound default is what you want in production, if you widen it to 0.0.0.0, also set admin.existingAuthSecretName so the built-in bearer-token guard protects pprof. The gateway refuses to start in the bind-non-loopback-without-token configuration.
gateway.env.GATEWAY_ADMIN_ADDR.
Recipe 1: p99 latency spike
Symptom:histogram_quantile(0.99, sum by (le) (rate(gateway_http_request_duration_seconds_bucket[5m]))) jumps from ~300 ms to several seconds. Traces show no single slow upstream.
Diagnose:
- A single function using > 50% of CPU that’s not one of:
tls.conn.Handshake,net/http.(*conn).serve,json.Decoder.Decode. Those are expected under load. - Lock contention on
internal/authorinternal/fallback, hot insync.(*Mutex).Lock. Usually means the L1 cache is evicting faster than it’s filling; consider raisingLW_GATEWAY_AUTH_CACHE_L1_SIZE. - RE2 compilation in
internal/blockedon every request, means the bundle isn’t caching compiled regexes. Check for frequent/changeschurn (revision bumps on every request ≠ normal).
Recipe 2: Goroutine leak
Symptom:go_goroutines climbs monotonically over hours, never GCs. Memory follows.
Diagnose:
- Hundreds of goroutines parked in
chan receiveinsideinternal/fallback.Walk, means a fallback attempt is hanging on a context that never cancels. CheckLW_GATEWAY_UPSTREAM_TIMEOUT_MS. - Goroutines stuck in
internal/guardrails.CheckChunk, likely a guardrail evaluator that never returns and exceeds the 50 ms budget. Check the evaluator service logs. - Streaming goroutines (
internal/dispatch.streamSSE) that outlive their request context, client disconnect withoutctx.Done()firing usually points at a missingclosesomewhere.
/tmp/goroutines.svg attached.
Recipe 3: Memory growth
Symptom: RSS climbs from 200 MB to > 1 GB over a day. OOM eventually follows. Diagnose:internal/auth.(*Cache).Putholding more than ~1 MB per cached bundle, unusual, a bundle should be ≤ 50 KB. Oversizedpolicy_rules.urls.allowwith thousands of entries can trigger this.internal/dispatchbuffered responses, if streaming responses are being accumulated instead of flushed per-chunk, every request consumes full response size. Check forbufio.NewWriterwrapping a streaming writer anywhere.- Cached bundles never being evicted, a very large tenant count with long
HARD_GRACEkeeps every bundle resident. Checkgateway_auth_cache_size{tier="l1"}against your expected VK count.
Recipe 4: Mutex or block profiling for contention
Symptom: CPU is low, request rate is low, but latency is up. Suggests blocking, not computation. Enable on a specific pod (requires a restart withLW_GATEWAY_PPROF_BLOCK_RATE=1 and LW_GATEWAY_PPROF_MUTEX_FRACTION=1 in the env, off by default because they have measurable overhead).
- Contention on
internal/ratelimit.(*Bucket).Allow, means a VK’s RPM is bursting past the token-bucket refill rate and every request is waiting. Raise RPM or investigate the caller. - Contention on the L1 auth cache, see Recipe 1.
Recipe 5: Allocation churn (GC pressure)
Symptom:go_gc_pause_seconds_sum is growing too fast; p99 spikes correlate with GC.
Diagnose:
- JSON encoding of large
/v1/messagesrequests, expected, but if it dominates consider enablingLW_GATEWAY_MAX_BODY_BYTESenforcement to reject pathologically large bodies earlier. - Per-request compilation of the same regex, should never happen; if it does, Lane A has a caching regression.
Recipe 6: Budget spend not accumulating
Symptom: Budget scopes never approach their limit even though traffic is flowing, orgateway_budget_blocks_total{scope} stays flat on a scope you expect to be capped.
The gateway does not send debits. Cost is captured as attributes on the OTel span the gateway emits; the control plane’s trace-fold reactor (gatewayBudgetSync.reactor.ts) reads finalised spans and writes the ClickHouse ledger rows. Budget enforcement on the gateway is a purely local precheck against the budget snapshot baked into the cached bundle, so spend only moves when the whole chain runs:
Operator signal:
gateway_budget_blocks_total{scope} is the counter that tells you enforcement actually fired. A scope configured with on_breach: block whose ledger is past the limit but whose block counter is flat means the gateway has not received a refreshed bundle yet.
Because the precheck is local and permissive, a stale snapshot allows requests through rather than rejecting them. Overspend during a workers outage is expected and reconciles once the ledger catches up.
Recipe 7: Stuck drain
Symptom: A pod stays inTerminating for the full terminationGracePeriodSeconds, then gets SIGKILLed. In-flight requests ended abruptly. Alertmanager fires on the gateway_draining gauge being 1 for > grace.
The pod received SIGTERM but at least one request handler never returned before shutdown.timeout expired. The drain pipeline exposes this via two gauges:
Diagnose:
- Upstream dial hanging without a deadline. Streaming fallback into a dead region that never returns TLS handshake.
LW_GATEWAY_UPSTREAM_TIMEOUT_MSshould be <shutdown.timeout; if it is, the handler should cancel on its own. - Guardrail evaluator hanging past its budget.
pre/posthave aguardrail.preTimeout,postTimeoutof 1500 ms, but a misconfigured evaluator can still hang if it doesn’t respect context cancellation. Check the evaluator service’s own SLO. - A breaker with
openstate but no surrounding deadline. Rare; closed previously, but worth ruling out if the stack showsinternal/circuitwaiting. - Slow custom middleware. If you’ve forked the gateway and added middleware that does I/O without context propagation, that’s where to look first.
Temporary workaround: bump
shutdown.timeout + terminationGracePeriodSeconds to give the hanging request time to complete. Only appropriate while you diagnose the root cause, long grace periods slow down rolling deploys and make HPA scale-downs feel sluggish.
Recipe 8: Control-plane outage, stale-while-error
Symptom: The LangWatch control plane is unreachable (deployment incident, DNS hiccup, network partition). The gateway’s L1 auth cache is full of valid resolved-key bundles, but/api/internal/gateway/resolve-key is returning errors. Operator wants to know: are customers being rejected, or is the gateway riding through?
The gateway’s auth resolver runs stale-while-error by default: when the cached entry’s JWT crosses its natural expiry AND the control-plane refresh fails for transport-class reasons (network error, dial timeout, 5xx, connection refused, malformed/unparseable response, JWT verify failure), it bumps the soft expiry by LW_GATEWAY_AUTH_CACHE_SOFT_BUMP (default 5m) and serves the cached bundle. This continues every refresh attempt up to the hard cap of LW_GATEWAY_AUTH_CACHE_HARD_GRACE past the JWT exp (default 6h). The hard cap is deliberately generous, the soft-bump path runs on every refresh attempt without a successful response, so the hard cap is the true outage backstop, not a steady-state knob.
Auth-class rejections: explicit 401, 403, 404 from /resolve-key, bypass the grace window entirely and evict immediately. A revoked credential never gets stale-served.
Diagnose:
Customer-facing behaviour during the grace window:
- Requests against any VK that resolved successfully before the outage continue to work transparently.
- Requests against any VK never seen by this pod (cold) still fail, the gateway has no bundle to fall back to. Today’s mitigation is Redis L2 (
GATEWAY_REDIS_URL): HPA-scaled pods inherit the warm set from L2 even while the control plane is unreachable. (GATEWAY_CACHE_BOOTSTRAP_ALL_KEYS=trueis a planned v1.1 enhancement to also pre-warm L1 from a/bootstrapsnapshot on startup; the flag is reserved in env wiring but has no Go-side implementation today, so setting it is currently a no-op.) - Requests against any VK whose JWT was revoked just before the outage but the revocation
/changesevent hadn’t propagated yet, these stay served until the cache entry crosses its hard cap. Acceptable trade-off for the grace; auth rejections from a healthy CP still evict instantly viaauth_cache_hard_evict reason=auth_rejection.
Alert pattern (log-based, no metric infra required):
auth_cache_refresh_transport_failure) is informational while customers are still being served.
See Config → Auth cache for the env-var contract.
Graceful degradation: what survives what
The gateway is a cache of the control plane, so a surprising amount continues to work when pieces go down. Quick reference:
For multi-region deployments with shared control plane: a gateway region down is handled by Route53 latency-based failover. See Scaling → Regional placement.
Writing findings back
When you find something worth filing:- Grab the
.pb.gzwithgo tool pprof -symbolize=remote -proto http://localhost:6060/debug/pprof/heap > /tmp/heap.pb.gz. - Attach it to the issue along with:
- Pod name + image digest (
kubectl get pod -n langwatch -o yaml | grep image). X-LangWatch-Request-Idfrom one exemplar bad request.kubectl top podoutput around the spike.
- Pod name + image digest (
- For urgent escalation, post in
#ai-gateway-supportwith the request id.
See also
- Config → Admin, operator endpoints: env-var reference.
- Health Checks:
/readyzoutput to interpret alongside pprof. - Troubleshooting: symptom-first diagnostic index for non-pprof issues (401, 403, 429, 502).
- Prometheus alerts: alert rules that tell you when to reach for this runbook.