Skip to content

metrics: emit session goodput for all sessions, not just >=1MB (fix false experiment starvation) - #678

Merged
reflog merged 3 commits into
mainfrom
reflog/goodput-emission-audit
Jul 17, 2026
Merged

metrics: emit session goodput for all sessions, not just >=1MB (fix false experiment starvation)#678
reflog merged 3 commits into
mainfrom
reflog/goodput-emission-audit

Conversation

@reflog

@reflog reflog commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What & why

proxy.session.goodput is recorded once per session at connection close, but only for sessions that moved ≥ 1 MB in the receive direction:

const goodputMinBytes = 1_000_000
func (ins *defaultInstrument) SessionGoodput(ctx, recvBytes int, duration, clientIP) {
    if recvBytes < goodputMinBytes || duration <= 0 { return }  // < 1 MB → no sample
    ...
}

The bandit experiment evaluator (lantern-cloud) reads the count of goodput samples per (track, country) as a starvation signal: a challenger with < 10 samples in 48h is retired ("starved / infra-broken", 0 samples → aborted). On prod ~79% of challengers were being false-retired this way while serving large real traffic.

Root cause (confirmed): the 1 MB per-session floor is applied to the receive direction, which is the small (client→proxy / upload) side of a session, and the floor is ~1–2 orders of magnitude larger than a typical session moves. Small-but-real sessions — probes, connectivity checks, blocked-then-retry, small pages, which dominate censored markets — record a bandit.callback but no goodput sample, so the evaluator counts ≈0 goodput and kills healthy challengers.

This PR removes the byte floor: goodput is now emitted for any session with recvBytes > 0 and duration > 0, so the sample count tracks real traffic. Direction, (track, country) point-attribute slicing (#675), and the histogram definition are unchanged.

Prod evidence (SigNoz, 48h)

Two proxy services emit goodput: http-proxy-lantern (the cloud http-proxy fleet, no deployment.environment tag) and vps-proxy (env=prod; runs both http-proxy and the sing-box/lantern-box binary). bandit.callbacks is emitted by the api service. Figures below are across both proxy services (no env filter) — a deployment.environment='prod' filter silently drops the larger http-proxy-lantern fleet.

Note: the evaluator's goodput query (GoodputByStratum) filters only track IN [...] AND network.io.direction='receive'no env/service filter — so the missing env tag does not blind the evaluator; it only affects human prod-scoped dashboards/alerts. The floor is the single active cause here.

proxy.io bytes by direction (both services combined):

  • transmit (proxy→client, download) = 51.9 TB
  • receive (client→proxy, upload) = 4.81 TB → transmit:receive ≈ 10.8 : 1 (http-proxy-lantern alone 12.4:1)

Average bytes per connection (http-proxy-lantern, the only service with proxy.connections; 146.5 M sessions/48h):

  • receive (upload, the direction goodput floors on): ≈ 22.2 KB/session
  • transmit (download): ≈ 274 KB/session

Both averages are far below the 1 MB floor; the floored direction (upload) is ~45× under it, and even download is ~3.6× under. Only 0.042% of connections (61,903 / 146.5 M) ever cleared the floor. Overall goodput samples (166,208) are 0.43% of bandit.callbacks (38.6 M).

Callbacks vs goodput samples, the false-starved challengers (all on service.name=http-proxy-lantern, i.e. the http-proxy binary this PR fixes):

track protocol bandit.callbacks proxy.session.goodput.count goodput / callbacks
exp-7-cn-l5-p21 tls_1.0.0 451,666 375 0.083%
exp-30-ir-l5-p26 starbridge_1.0.0 196,282 4,633 2.36%
exp-50-cn-l5-p24 tlsmasq_1.1.0 6,636 2 0.030%
exp-29-ir-l5-p25 shadowsocks_1.0.0 1,809 0 0%
exp-28-ir-l5-p24 tlsmasq_1.1.0 907 0 0%

(Matches the original prod diagnosis table.)

Audit answers

Q1 — Is the 1 MB floor the dominant cause? Every close path checked.

  • Single call site: reporting.go proxiedBytesReporter, on final=true (connection close), before the zero-delta early return, with cumulative stats.RecvTotal / stats.Duration. It fires once for every closed measured connection.
  • The measured wrapper (bwReporting.wrapper) is added via srv.AddListenerWrappers and applied by server.serve() to every accepted connection, for every protocol listener in getProtoListenersArgs (https/tls, https_multiplex, tlsmasq, starbridge, broflake, algeneva, kcp, quic_ietf, shadowsocks, shadowsocks_multiplex, water, vmess) and in both the multipath and non-multipath serve paths. For multiplexed transports the measured wrapper is outermost and wraps the per-session logical stream, so Duration is per-session and final fires per session.
  • CONNECT tunnels and early/panic closes all close the measured conn → final fires.
  • So within http-proxy the 1 MB floor is the only filter dropping samples. Confirmed by the prod averages above.
  • One gap noted: ListenAndServeENHTTP (encapsulated-HTTP mode) is a mutually-exclusive alternate serve path that uses a plain http.Server and does not apply the measured wrapper, so it emits neither proxy.io nor goodput. Niche deployment; flagged, not addressed here.

Q2 — Image / protocol coverage (which binaries emit goodput):

Serving binary is decided by a track's docker_image_ref, not the wire protocol name (shadowsocks/vmess/algeneva/water have listeners on both binaries). In lantern-cloud (origin/main): cmd/api/pcfg/pcfg.go generateLaunchConfig (pcfg.go:136) builds a typed launch config; cmd/api/proxyini/proxyini.go Build (proxyini.go:73) renders it as an http-proxy INI, or rejects a sing-box config (proxyini.go:208-212) → runs on lantern-box. Sing-box membership: IsSingboxProtocol (pcfg/singbox.go:28-37). Per-track image classification: track.go:180-201; SQL split rule proxy_infrastructure.sql:1692-1712.

  • http-proxy binary (service.name http-proxy-lantern + part of vps-proxy): tls, tlsmasq, shadowsocks (protocol 1), vmess, starbridge, algeneva, water, broflake. Emits goodput, track visible as a point attr (metrics: emit goodput track as a point attribute so the bandit evaluator can slice it #675), 1 MB floor. Fixed by this PR.
  • lantern-box / sing-box binary (service.name=vps-proxy): reflex, samizdat, meek, unbounded, wireguard, hysteria2, vless, trojan, amnezia, sing-box-native ss/vmess. Also emits proxy.session.goodput (tracker/metrics/metrics.go:66-71; recorded at tracker/metrics/tracker.go:142-155) with the identical goodputMinBytes = 1_000_000 (tracker.go:28,143). In prod its tracks under-emit the same way (e.g. samizdat-pro-alicloud, hysteria2-*, vless-*). Needs the same floor fix — separate PR in lantern-box.
  • radiance: not a provisioned proxy image (git grep radiance over lantern-cloud cmd/api, cmd/phost, tf/ = 0 hits) and emits zero proxy.session.goodput — its only "goodput" is a local KB/s CLI probe in cmd/residential-urltest (main.go:237,262); serving telemetry records connectionDuration only. Not part of the experiment fleet.
  • Old getlantern/http-proxy-lantern repo: not deployed — absent from the CI/deploy allowlist [automation, flashlight, http-proxy, lantern-cloud, lantern-box] (tf/_modules/foundation/lanternet/cicd.tf:35-41); the deployed image is http-proxy:latest (cmd/phost/main.go:50). It is a byte-identical mirror of this repo (same HEAD SHA), so no divergent goodput path.
  • No total (non-emitting-binary) gap among live serving binaries — both http-proxy and lantern-box emit. (I initially suspected lantern-box tagged track resource-only / reader-invisible; prod refutes thistrack resolves as a queryable label on vps-proxy goodput, and GoodputByStratum has no service filter, so it matches both. The floor is the sole active cause.)

The 5 false-starved tracks are all http-proxy (Class A) — proven empirically: three show non-zero goodput (375 / 4,633 / 2), which a resource-only-track binary could never do (the reader's point-attr track filter would read exactly 0 in every stratum). The two 0-rows are the floor on low-volume http-proxy tracks, same mechanism as the tlsmasq row that yielded 2 from 6,636 callbacks.

Q3 — Are recvBytes / duration populated for all protocols?

  • Yes, uniformly: the measured listener sits above all protocol listeners at the server level and is protocol-agnostic (measured.Conn wrapping the client socket), so there's no per-protocol silent-zero within http-proxy.
  • Semantic note: recvBytes = stats.RecvTotal is the client→proxy (upload) direction (tagged receive), the smaller side — even though the code/histogram describe it as "download goodput". This is internally consistent across both emitters and the reader (all pinned to receive), so it's a fair relative signal and I preserved it. But it does mean the metric measures upload throughput, not download; if the team wants download as the quality signal that's a coordinated emitter+reader change (see follow-ups).

Changes

  • instrument/instrument.go: remove goodputMinBytes; emit goodput for any session with recvBytes > 0 && duration > 0; rewrite the doc comment with the rationale + prod numbers.
  • instrument/goodput_test.go: add TestSessionGoodputSmallSession (20 KB session now records — the core fix) and TestSessionGoodputZeroBytes; keep the ≥1 MB and zero-duration cases.
  • instrument/otelinstrument/otelinstrument.go: add ResetForTest() — fixes a latent, order-dependent test-harness leak (the sync.Once in Initialize() bound the goodput histogram to the first test's meter provider, so only the first positive-emission test in the process ever observed samples).

Test evidence

$ go test ./instrument/...
ok  github.com/getlantern/http-proxy-lantern/v2/instrument
--- PASS: TestSessionGoodput
--- PASS: TestSessionGoodputSmallSession
--- PASS: TestSessionGoodputZeroBytes
--- PASS: TestSessionGoodputZeroDuration

All four pass in any order (verified standalone). go vet ./instrument/... ./instrument/otelinstrument/... clean. go build ./... fails only inside the unrelated CGO dep github.com/anacrolix/go-libutp (typedef uint8 bool, a C-toolchain issue) on both this branch and origin/main; the changed packages build clean.

Volume / cardinality

Removing the floor raises the number of histogram records (~846/s fleet-wide, same as proxy.connections) but not series cardinality — the label set (track, geo.country.iso_code, network.io.direction) is unchanged and low-cardinality. Distinct from the measurement-table volume concern in getlantern/engineering#3691.

Cross-repo follow-ups (NOT in this PR — coordinate)

  1. lantern-box (tracker/metrics/tracker.go:28,143): apply the identical floor removal — same goodputMinBytes = 1_000_000, and it serves the sing-box experiment protocols (reflex, samizdat, hysteria2, vless, trojan, amnezia, wireguard, meek, sing-box-native ss/vmess), all under-emitting. (Also worth confirming lantern-box carries a track point attribute — its code sets track on the OTEL resource, though in prod SigNoz exposes it as a queryable label today.)
  2. lantern-cloud starvation check (cmd/api/jobs/experiment_evaluator_worker.go, settingExperimentStarvationMinSamples): gate starvation on bandit.callbacks/attempts rather than goodput sample count — a challenger with thousands of callbacks is not starved regardless of goodput emission. (A worktree for this already exists.)
  3. Direction/naming: decide whether goodput should measure download (transmit) instead of upload (receive). If so it's a coordinated change across both emitters and the reader's network.io.direction='receive' filter.

⚠️ Goodput drives real promote/retire decisions on prod — coordinating before merge.

proxy.session.goodput was recorded once per session at close, but only
for sessions that moved >= 1MB in the receive direction. The bandit
experiment evaluator counts goodput samples per (track, country) as a
starvation signal and retires challengers with < 10 samples in 48h. On
prod ~79% of challengers were being false-retired as "starved" while
serving large real traffic.

The 1MB floor is applied to the receive (client->proxy / upload) side,
which is the small direction of a session. Prod (48h) averages ~22 KB
received and ~275 KB sent per session across 146M sessions -- both far
below 1MB -- so small-but-real sessions (probes, connectivity checks,
blocked-then-retry, small pages, common in censored markets) recorded a
bandit.callback but no goodput sample, and the evaluator counted ~0.

Emit goodput for any session with recvBytes > 0 and duration > 0 so the
sample count tracks real traffic. Direction, the (track, country) point
attributes (#675), and the histogram definition are unchanged. The
per-second rate is noisier for tiny sessions, but the evaluator compares
per-stratum p50 medians which are robust to that tail.

Also add otelinstrument.ResetForTest to fix a latent order-dependent
leak: Initialize()'s sync.Once bound the goodput histogram to the first
test's meter provider, so only the first positive-emission test in the
process observed samples.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@reflog, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5bc2406b-fdfa-4baf-9bb3-617ed3fe948a

📥 Commits

Reviewing files that changed from the base of the PR and between 5be8c87 and ce0b877.

📒 Files selected for processing (1)
  • instrument/otelinstrument/otelinstrument.go
📝 Walkthrough

Walkthrough

Session goodput no longer requires a 1 MB minimum and now records positive-byte sessions. Tests rebind OTEL instrumentation between manual readers and cover small-session and zero-byte behavior.

Changes

Session goodput instrumentation

Layer / File(s) Summary
Record positive-byte sessions
instrument/instrument.go
SessionGoodput records sessions with positive received bytes and duration, removing the former minimum-byte threshold.
Rebind and validate metrics
instrument/otelinstrument/otelinstrument.go, instrument/goodput_test.go
Adds test-only OTEL reinitialization and verifies normal, small-session, and zero-byte histogram behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: removing the 1MB goodput threshold and fixing starvation from missing metrics.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch reflog/goodput-emission-audit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
instrument/otelinstrument/otelinstrument.go (1)

50-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document concurrency hazard in test reset.

ResetForTest mutates global variables (initOnce, meter, and instruments) without synchronization. While safe for sequential tests, this will cause data races if t.Parallel() is ever used in tests that rely on this package. Consider noting this limitation in the docstring to prevent future test flakiness.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@instrument/otelinstrument/otelinstrument.go` around lines 50 - 60, Update the
ResetForTest docstring to explicitly state that it mutates shared global
initialization state and instruments without synchronization, so it is only safe
for sequential tests and must not be used with t.Parallel().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@instrument/otelinstrument/otelinstrument.go`:
- Around line 50-60: Update the ResetForTest docstring to explicitly state that
it mutates shared global initialization state and instruments without
synchronization, so it is only safe for sequential tests and must not be used
with t.Parallel().

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e3aab0fc-8c3e-42ef-bbdc-1cc0dcb4d28d

📥 Commits

Reviewing files that changed from the base of the PR and between 2dd3c1b and 5be8c87.

📒 Files selected for processing (3)
  • instrument/goodput_test.go
  • instrument/instrument.go
  • instrument/otelinstrument/otelinstrument.go

Address CodeRabbit review: ResetForTest mutates package-global init
state without synchronization, so document that it is sequential-tests
only and must not be used with t.Parallel().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@reflog

reflog commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai addressed the ResetForTest nitpick in ea51dfe — documented that it mutates package-global init state without synchronization and is sequential-tests-only (not t.Parallel()-safe). Agreed it's the right caveat to record.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@reflog Thanks—this documents the package-global concurrency constraint clearly and makes the test-only contract explicit. Looks good. 🐇


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adjusts proxy.session.goodput emission so it’s recorded for all real sessions (any recvBytes > 0 and duration > 0) rather than only sessions exceeding a 1MB receive threshold, preventing the experiment evaluator from falsely treating healthy challengers as “starved” due to missing goodput samples.

Changes:

  • Remove the per-session 1MB goodput emission floor and emit goodput for any session with positive received bytes and duration.
  • Add/adjust tests to cover small sessions, zero bytes, and zero duration.
  • Add otelinstrument.ResetForTest() to rebind OTEL instruments to the active test meter provider, avoiding order-dependent test failures.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
instrument/instrument.go Removes the byte floor and updates SessionGoodput behavior and rationale documentation.
instrument/goodput_test.go Adds coverage for small-session emission and ensures tests can reliably observe OTEL metrics.
instrument/otelinstrument/otelinstrument.go Adds a reset hook to re-initialize OTEL instruments against the current global meter provider for tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread instrument/otelinstrument/otelinstrument.go
…t doc

Address Copilot review:
- ResetForTest now refuses to run outside a `go test` binary (flag.Lookup
  "test.v"), so a stray production call can't re-run initialization and
  race live metric use.
- Update the proxy.session.goodput histogram comment/description that
  still referenced the removed goodputMinBytes floor and mislabeled the
  metric as "download"; it now describes the receive (client->proxy)
  direction with no byte floor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@reflog
reflog merged commit fe93122 into main Jul 17, 2026
2 checks passed
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.

2 participants