Skip to content

fix(proxy): keep backend latency for streaming sessions - #545

Open
dviejokfs wants to merge 2 commits into
mainfrom
fix/proxy-streaming-metrics-followup
Open

fix(proxy): keep backend latency for streaming sessions#545
dviejokfs wants to merge 2 commits into
mainfrom
fix/proxy-streaming-metrics-followup

Conversation

@dviejokfs

Copy link
Copy Markdown
Contributor

Follow-up to #514, addressing the review findings on that PR.

1. Backend latency was over-excluded (the substantive fix)

#514's carve-out returned from record before the upstream histogram was touched, so a WebSocket or SSE session contributed no backend-latency sample. That was too broad. upstream_ms is measured at the first upstream response header — the 101, or the SSE headers — before the tunnel starts carrying traffic, so it is a genuine latency even though the session's total duration is not. As merged, a slow WebSocket handshake was invisible in proxy.upstream_duration_*.

Streaming sessions now contribute to the backend histogram again.

This required splitting the denominator. proxy.self_duration_* divided by upstream_count. Once streaming sessions land in the upstream histogram, reusing that count would drag the self-time mean toward zero — the same class of distortion #514 existed to fix, pointing the other way. The self histogram now carries its own self_count.

A regression test pins it. Reverting just the denominator gives:

test test_self_avg_denominator_excludes_streaming_sessions ... FAILED
assertion `left == right` failed
  left: 5.0       <- diluted by 3 streaming sessions
 right: 20.0

2. Documented what the carve-out does not catch

#514 read as if it closed the whole class. It does not. Still landing in the latency histograms:

  • HTTP/2 WebSockets — RFC 8441 extended CONNECT answers 200, not 101
  • gRPC streaming (application/grpc), chunked long-poll / NDJSON feeds
  • Large or slow downloadselapsed covers the whole body transfer

Widening detection would mean classifying on response duration rather than shape, which needs a threshold nobody can pick correctly for every deploy. The two covered cases are the ones that produce hour-long sessions by design; the rest are bounded by the upstream read timeout. That reasoning is now in the code rather than implied.

3. Per-series observation sets + operator note

The gauges do not all divide by the same number of requests, and nothing said so. Added a table to the module docs:

series observation set
proxy.requests*, proxy.error_rate_percent every request
proxy.request_duration_* every request except streaming sessions
proxy.upstream_duration_* every request that reached an upstream, streaming included
proxy.self_duration_* proxied requests except streaming sessions
proxy.streaming_* streaming sessions only

Plus an upgrade note: the duration series drop wherever streaming traffic exists, so alert thresholds tuned before #514 should be re-checked — one that used to fire may now sit permanently below its trigger.

4. Nits

  • Unstuck a comment that rustfmt had glued to the trailing comment of the line above, making it read as if it belonged to skip_tracking.
  • test_record_classifies_status_codes recorded a 101 with is_streaming: false — a state the proxy cannot produce.
  • Corrected the "Streaming sessions" panel description: the read path averages per collection interval, so the plotted value is not a bucket total.
  • Noted on the backend-latency panel that it now includes streaming handshakes.

Test plan

  • cargo test --lib -p temps-proxy449 passed, 0 failed.
  • cargo clippy -p temps-proxy --all-targets -- -D warnings — clean.
  • tsc --noEmit — clean.
  • New test verified to fail against the un-split denominator (output above).

Not re-run: the live-proxy WebSocket/SSE exercise from #514. This change alters which histogram a sample lands in, not whether the session is detected, and the denominator split is covered by the unit test above.

The streaming carve-out returned from `record` before the upstream
histogram was touched, so WebSocket and SSE sessions contributed no
backend-latency sample at all. That was too broad: `upstream_ms` is
measured at the first upstream response header — the `101`, or the SSE
headers — before the tunnel starts carrying traffic, so it is a real
latency even though the session's total duration is not. As written, a
slow WebSocket handshake was invisible in `proxy.upstream_duration_*`.

Streaming sessions now contribute to the backend histogram again. That
required splitting the denominator: `proxy.self_duration_*` was dividing
by `upstream_count`, and reusing it once streaming sessions land in the
upstream histogram would drag the self-time mean toward zero — the same
class of distortion this series was fixed to avoid, in the other
direction. The self histogram gets its own `self_count`, and its gauges
are emitted on that instead.

Also documents what the carve-out does NOT catch — HTTP/2 WebSockets
(RFC 8441 answers `200`, not `101`), gRPC streaming, chunked long-poll,
and large or slow downloads all still land in the latency histograms —
plus a per-series table of which observation set each gauge divides by,
and a note that duration thresholds tuned before this series should be
re-checked now that the values drop wherever streaming traffic exists.

Follow-up to #514.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

📓 Changelog preview

This is what your commits will add to the generated CHANGELOG.md at release time (via git-cliff). Do not edit CHANGELOG.md by hand — it is generated from your Conventional Commit messages.

## [Unreleased]

### Fixed

- **proxy:** Keep backend latency for streaming sessions
- **proxy:** Bound streaming handshake samples and tighten SSE detection

Restoring the backend-latency sample for streaming sessions re-opened the
distortion this series exists to close, aimed at a different gauge.
`upstream_ms` is time-to-first-header, and `upstream_peer` grants a
WebSocket upgrade a 1h read timeout — 60x the 60s ordinary traffic gets —
chosen from the request's own `Upgrade` header. A hung upstream can
therefore report a single ~3_600_000ms observation, which drags
`proxy.upstream_duration_avg_ms` into the tens of seconds while every
percentile stays flat, because at 1-in-100 the outlier sits above p99.

Streaming handshakes are now clamped to `MAX_HANDSHAKE_OBSERVATION_MS`
(60s, the ordinary read timeout), so a streaming session can move the
backend mean no more than any other request already can. The observation
is kept rather than dropped, so `upstream_count` stays honest. A test
pins it: without the clamp the mean reads 35999.9ms instead of 609.9ms.

Also tightens SSE classification. The upstream `content-type` was matched
with `contains`, so `text/html; note=text/event-stream` qualified — and
that header comes from a tenant's own app, while the flag it sets removes
the request from the operator's latency histograms. Now compares the media
type essence. The request-side `Accept` check deliberately stays a
substring match, since `Accept` is a comma-separated list.

Corrects the `record` doc, which still claimed streaming sessions were
kept out of *every* duration histogram after they were added back to the
backend one, and the stale relaxed-atomic-add counts in the same comment.

Addresses the security-audit and review findings on this PR.
@dviejokfs

Copy link
Copy Markdown
Contributor Author

Review + security-audit fixes (9722826e)

All Major/Minor findings from the review and the security audit are addressed.

Major — restored upstream sample re-opened mean poisoning. upstream_ms is time-to-first-header, and upstream_peer grants a WebSocket upgrade a 1h read timeout (60× ordinary traffic) selected from the request's own Upgrade header. A hung upstream could report ~3_600_000ms and drag proxy.upstream_duration_avg_ms into the tens of seconds — the exact distortion this series exists to close, aimed at the backend gauge instead. Now clamped to MAX_HANDSHAKE_OBSERVATION_MS (60s, the ordinary read timeout), so a streaming handshake can move the mean no more than any other request already can. Kept rather than dropped, so upstream_count stays honest.

Regression test verified against the unclamped code:

test test_streaming_handshake_observation_is_clamped ... FAILED
  left: 35999.9      <- unclamped, one hung handshake in 100
 right: 609.9

The test also asserts p99 stays flat at 1-in-100 — documenting why bounding the mean is the only available defence: the percentiles never react to a lone tail observation however large it is.

Major — record doc contradicted the code. It still claimed streaming sessions were kept out of every duration histogram after this PR added them back to the backend one. Corrected, along with the stale relaxed-atomic-add counts in the same comment.

Minor — SSE classification was substring-matched. The upstream content-type used contains, so text/html; note=text/event-stream qualified. That header comes from a tenant's own app and the flag it sets removes the request from the operator's latency histograms. Now compares the media-type essence, with tests for parameters, case, whitespace, prefix (text/event-stream-x) and the smuggled-parameter case. The request-side Accept check deliberately stays a substring match — Accept is a comma-separated list, so substring is correct there.

Verification: cargo test --lib -p temps-proxy454 passed, 0 failed. Clippy --all-targets -D warnings clean. Both new regression tests verified to fail against the un-fixed code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant