Repository navigation
Conversation
The access log measured only total request duration, which for a proxy that streams multi-GB objects is dominated by transfer time: a client pulling 27GB over a slow link was indistinguishable from a stalled backend. Response headers only go out once the upstream store has answered, so the time to http.response.start separates what x2s3 and its backend cost from the body moving at whatever rate the client can take it. The middleware already intercepts that message, so recording it costs one timestamp; it is reported as labels.ttfb_ms in JSON mode (ECS has no time-to-first-byte field) and appended to the text access line. scripts/kibana_dashboard.py probes the log index and builds the production overview dashboard from that measurement set: traffic, latency, throughput, per-target and per-client tables, client classification, and the failing-request stream. It creates saved objects via _bulk_create rather than _import, because _import replays Kibana's pre-8 Lens migrations against hand-written state and returns a bare HTTP 500. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| # nests or keyword-suffixes them only needs fixing here. | ||
| F = { | ||
| "status": "http.response.status_code", | ||
| "duration": "event.duration_ms", |
There was a problem hiding this comment.
event.duration_ms is not a field anything writes. _log_request emits "event.duration": int(duration_s * 1_000_000_000) — ECS nanoseconds — and docs/StructuredLogging.md documents it under that name too. event.duration_ms appears exactly once in the repo: this line. There is no ingest pipeline or Filebeat processor in the tree that could derive it (the documented shipper config is pass-through json.keys_under_root: true), and F is a plain module-level dict with no env override, so this default is what actually runs.
Everything backed by F["duration"] therefore aggregates an unmapped field and renders empty rather than erroring:
- the
probep95 aggregation at L174-L176 - the
percentile()Lens column at L210-L212, which feeds the p95 metric tile, the three-line "Latency percentiles" panel, and the p95 column of the "Targets: traffic and latency" table
Not leaving a committable suggestion because the fix needs a second edit: pointing at event.duration gives nanosecond magnitudes, and the formatter at L213-L214 hard-codes "suffix": " ms", so a 12 ms request would display as 12,000,000.0 ms. Either switch that format to Kibana's duration formatter ({"id": "duration", "params": {"inputFormat": "nanoseconds", "outputFormat": "asMilliseconds"}}) or convert ns→ms; the latency ms: print in probe needs the same treatment.
| "level": "log.level", | ||
| "trace": "trace.id", | ||
| "host": "host.name", | ||
| "referrer": "http.request.referrer", |
There was a problem hiding this comment.
Same class of mismatch as duration above: nothing logs a referrer. The header→field loop in _log_request is exactly two entries — user-agent and host — and a repo-wide search for refer(r)er hits only this file. docs/StructuredLogging.md's field table has no referrer row, and docker/nginx.conf sets no log_format/access_log, so nothing else in the tree supplies it either.
That makes the client-type split deterministic rather than descriptive. referred at L237-L239 is http.request.referrer: *, which compiles to an exists query and matches zero docs on an unmapped field, so "Browser, referred" is permanently 0 and every browser request lands in "Browser, no referrer" — a positive false claim on the panel that exists to identify embedded/web-app traffic. The buckets stay mutually exclusive and still sum to total traffic, so the blast radius is that one dead series plus the mislabeled one.
The cheap fix is to make the field real by extending the header loop in log.py:
for header, field in ((b"user-agent", "user_agent.original"), (b"host", "url.domain"), (b"referer", "http.request.referrer")):(note the single-r wire spelling of the header vs. the double-r ECS field name). Otherwise, drop the referred split and collapse the two browser classes into one.
Why
The access log measured only total request duration. For a proxy that streams multi-GB objects that number is dominated by transfer time, so a client pulling 27 GB over a slow link looked exactly like a stalled backend. Investigating a latency spike in production meant inferring the difference from the shape of the data rather than reading it.
Response headers only go out once the upstream store has answered, so the time to
http.response.startseparates what x2s3 and its backend cost from the body moving at whatever rate the client can take it.What
TTFB in the access log.
AccessLogMiddlewarealready interceptshttp.response.start, so recording this costs one timestamp:labels.ttfb_ms. ECS has no time-to-first-byte field, so it goes underlabelsalongside the other custom dimensions.ttfb=12.34msappended to the access line.event.duration, and the text line includes it.scripts/kibana_dashboard.pybuilds the production overview dashboard from that measurement set — traffic and error rate, latency percentiles, throughput, per-node balance, per-target and per-client tables, user agents ranked by unique client IPs, and the failing-request stream.probereports what the shipper actually indexed before anything is built;pushis idempotent;dumpprints the saved objects without touching Kibana.Two things worth knowing if you ever touch this script:
_bulk_create, not_import._importreplays Kibana's pre-8 Lens migrations against hand-written state and fails with a bare HTTP 500 and no message.var/kibana.env, not a repo-root.env— pydantic-settings loads that one intoSettings, which forbids unknown keys, so an unrelated variable there stops the app and every test from starting.Testing
53 passedin the pixi test environment (tests/test_log.py,test_file.py,test_base.py,test_xss_prevention.py). The AWS-backed tests were not run — they need outbound access to real S3, which this environment does not have.🤖 Generated with Claude Code