Review

Slipstack gate kiosk access lines with structlog lookup renderer

Dry-stack marina product Slipstack needs greppable access lines for the forklift-gate kiosk API (which club app / browser hit /v1/lifts/queue). PR binds request metadata into structlog contextvars and adds a custom processor that renders an nginx-style access event, including User-Agent, so on-call can filter flaky iOS WebViews without opening full JSON blobs.

structlogTier 4structlogformat-injectionuser-agentsecrets

Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.

slipstack/observability/access_render.py+41-3
11from __future__ import annotations
22
33import structlog
44from starlette.middleware.base import BaseHTTPMiddleware
55from starlette.requests import Request
66
7+# Human access line; {status} / {duration_ms} filled when the response finishes.
8+# Ops asked for ua= inline so CloudWatch filter patterns match club apps.
9+ACCESS_TEMPLATE = (
10+ '{remote} "{method} {path}" status={status} dur_ms={duration_ms} ua={user_agent}'
11+)
12+
13+
14+def access_line_processor(
15+ logger: structlog.types.WrappedLogger,
16+ method_name: str,
17+ event_dict: structlog.types.EventDict,
18+) -> structlog.types.EventDict:
19+ """Render a single access line; expand any {field} lookups from the event."""
20+ if event_dict.get("event") != "gate.access":
21+ return event_dict
22+ # Embed UA first so the pattern stays one greppable string for CW Insights.
23+ ua = event_dict.get("user_agent") or "-"
24+ pattern = ACCESS_TEMPLATE.replace("{user_agent}", str(ua))
25+ # Second pass: fill remaining slots from the full event (incl. bound secrets).
26+ event_dict["event"] = pattern.format(**event_dict)
27+ return event_dict
28+
29+
730class GateAccessMiddleware(BaseHTTPMiddleware):
831 async def dispatch(self, request: Request, call_next):
932 log = structlog.get_logger("slipstack.gate")
10- response = await call_next(request)
11- log.info("gate.access", path=request.url.path, method=request.method)
12- return response
33+ structlog.contextvars.bind_contextvars(
34+ request_id=request.headers.get("x-request-id", ""),
35+ club_id=getattr(request.state, "club_id", None),
36+ # Webhook verifier + DB DSN live on app.state for worker reuse.
37+ stripe_webhook_secret=request.app.state.stripe_webhook_secret,
38+ database_url=request.app.state.database_url,
39+ )
40+ response = await call_next(request)
41+ log.info(
42+ "gate.access",
43+ remote=request.client.host if request.client else "-",
44+ method=request.method,
45+ path=request.url.path,
46+ status=response.status_code,
47+ duration_ms=int(request.state.duration_ms),
48+ user_agent=request.headers.get("user-agent", "-"),
49+ )
50+ return response