Skip to content

feat(protocol): drop the telemetry queue when the portal toggle is off - #437

Merged
bahdotsh merged 7 commits into
mainfrom
feat/drop-on-telemetry-disabled
Sep 11, 2026
Merged

feat(protocol): drop the telemetry queue when the portal toggle is off#437
bahdotsh merged 7 commits into
mainfrom
feat/drop-on-telemetry-disabled

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Sep 11, 2026

Copy link
Copy Markdown
Member

Summary

While an application's telemetry toggle is off in the developer portal, the ingest now answers 403 with the error code telemetry_disabled instead of the forbidden it shares with a revoked key or a key for another app (companion ingest PR: https://github.com/Offline-Protocol/telemetry/pull/37).

The pipe treated every 401/403 the same way: keep the refused batch, halt until the next enableTelemetry, keep queuing. That is right for a bad key, which the developer fixes and re-enables through. It is wrong for the toggle: once it came back on, a relaunch resent up to six days of the off period, which is exactly the backfill the toggle promises not to make.

Now the uploader reads the 403 body's error code. On telemetry_disabled it drops the refused batch and everything queued behind it, halts, and reports it to the pipe, which discards the ring at every later cycle instead of cutting batches, and empties any durable queue it adopts afterwards, until the next enableTelemetry. Every other 401/403, and a 403 from an ingest that predates the code, keeps the queue exactly as before.

Related issues

Refs Offline-Protocol/telemetry#36 (the toggle-gap note in its description). Companion ingest change: https://github.com/Offline-Protocol/telemetry/pull/37.

Type of change

  • feat — new feature

Checklist

  • cargo fmt --all -- --check passes
  • cargo clippy --workspace -- -D warnings passes (CI's exact invocation, run locally)
  • cargo test --workspace passes — ran cargo test --workspace --lib locally (2,728 passed across 13 suites, including the pipe suite's 106); doctests are left to CI
  • cargo-deny is satisfied — not run; no dependency change
  • Commits follow Conventional Commits (<type>(<scope>): <subject>)
  • Docs / CHANGELOG.md updated where relevant (docs/telemetry.md, its errors table, UPGRADING.md §20, the unreleased changelog section)
  • No new unsafe in core crates
  • UniFFI UDL unchanged; no bindings to regenerate

Breaking changes

None. Two observable differences, both additive: telemetryStats().dropped gains one more source (events discarded while the toggle is off), and lastError reads ingest responded 403 (telemetry_disabled) for this case. The ingest responded 401 / 403 strings for every other refusal are unchanged.

Notes for reviewers

  • Hot path untouched. PipeShared::server_disabled is read on the flush cycle, never on the emit path, so an emit still costs one atomic load. The ring keeps filling between cycles and is thrown away at the next one; nothing is persisted or sent, and the discarded events are counted in dropped.
  • Adoption after the refusal. enableTelemetry starts the pipe in memory, and the worker adopts the durable queue once initialize_mls hands the store over, so a refusal can arrive before adoption. Adoption then loads whatever an earlier launch persisted. The halted uploader never reads the queue again, so without a sweep those batches would sit on disk until the next enableTelemetry resent them. Adoption empties the queue it loads while the flag is set. It is the one way a batch can enter the store after the refusal.
  • What it costs. Everything queued when the refusal arrives is dropped, not only the batch in flight. On a device that was online that is about one flush interval of events from just before the switch. On one that was offline it can be the whole queue, up to 64 batches or six days, including events from before the switch. So the gap starts early rather than late, sometimes by more than one interval.
  • The re-enable window. The ingest caches a rejection for 30 seconds, so it can keep answering telemetry_disabled that long after the toggle comes back on. A launch inside that window halts and discards until its next enableTelemetry, and unlike a bad-key halt nothing is recovered at the launch after. docs/telemetry.md states this in its errors table. Shortening that window is an ingest change, not an SDK one.
  • Only a 403 with the code disables. The same code on a 401, an empty body, junk, or forbidden all stay the ordinary halt, so deploy order between the SDK and the ingest does not matter. Pinned in only_a_403_carrying_the_telemetry_disabled_code_disables.
  • Test harness. CapturingClient now answers a non-2xx with the ingest's error shape, with error_code settable. Every existing scenario only looked at the status, so nothing else moved.
  • Tests added. Two uploader unit tests (queue drop and halt; the status-and-code matrix) and three end-to-end scenarios over a durable store. The first proves a fresh pipe after re-enable has nothing to backfill and new events flow again. The second follows the engine's startup order, a memory pipe refused before it adopts an earlier launch's queue, and fails with the adoption sweep removed. The third drops the queue on a refusal during the final flush.

The uploader treated every 401 and 403 alike: keep the refused batch,
halt until the next enable_telemetry, keep queuing. Right for a bad key,
which the developer fixes and re-enables through. Wrong for the
application's telemetry toggle in the developer portal: once it came back
on, a relaunch resent up to six days of the off period, the backfill the
toggle promises not to make.

The ingest now answers that refusal with the error code
telemetry_disabled. On a 403 carrying it the uploader drops the refused
batch and everything queued behind it, halts, and reports it to the pipe,
which discards the ring at every later cycle instead of cutting batches
until the next enable_telemetry. The flag is read on the cycle, never on
the emit path. Every other 401 or 403, and a 403 from an ingest that
predates the code, keeps the queue as before.
The telemetry_disabled scenario ends by building a fresh pipe over
the same sealed store and asserting that nothing is resent. That is
a negative assertion, and it passes just as happily when the fresh
pipe never adopted the store and quietly ran in memory. An empty
in-memory queue has nothing to backfill either.

So the day someone breaks release-then-adopt, this test keeps
saying "no backfill" for entirely the wrong reason.

Assert the fresh pipe is durable before trusting the count. The
bad-key sibling never needed this, because its assertion is
positive: the refused batch shows up again, which it can only do
from the store.
Classifying a 403 parsed the body, cloned the error code into a
fresh String, and compared that against a constant. The String
existed only to be compared and thrown away.

Ask the question directly instead: does the body name this code.
Same parse, and junk or a missing field still lands on the ordinary
halt. It only runs on a 403, so this is tidiness, not a hot-path
win, and I won't pretend otherwise.
The telemetry_disabled change updated the uploader's answer table
and added an errors row, and left three other places describing the
old world. The stats table listed four sources for dropped, the
privacy page named three things that clear the queue, and the
threat model said persisted batches are kept for a later enable.
All three are wrong for an app whose portal toggle is off.

Say it in each place. dropped also counts what was queued or
collected after the ingest reported the toggle off, and the queue
is cleared when that happens rather than held for the toggle coming
back. The privacy page is the one integrators quote to their users,
so it is not the place to be out of date.

While at it, document the one sharp edge. The ingest caches a
rejection for 30 seconds, so a launch just after the toggle is
turned back on can still be refused, and that process then keeps
nothing until its next enableTelemetry. Before this change the next
launch would have backfilled it. Now it doesn't, which is the whole
point, but a developer checking the dashboard right after flipping
the switch deserves to know why that launch went missing.
The telemetry_disabled halt promised that nothing from the off
period is left on disk for a later pipe to resend. It kept that
promise only for a pipe that was already durable when the refusal
arrived.

It turns out the engine never starts one that way.
enable_telemetry starts every pipe in memory and hands the
protocol-state store over for the worker to adopt, which on the
mobile bindings means after initialize_mls. A flush that reaches
the ingest first takes the halt with an empty in-memory queue.
Adoption then loads whatever an earlier launch persisted, and
nothing touches it again: the cycle has stopped cutting batches,
and the halted uploader returns before it reads the queue. A
re-enable that waits for the old pipe to let go of the queue gets
there the same way.

So the next enable_telemetry, with the toggle back on, sends the
off period. Which is exactly the backfill this change exists to
prevent.

Sweep the store at adoption when the flag is set. Adoption is the
one way a batch can still enter the store after the refusal, so the
check belongs there rather than in the uploader, which both a
battery-deferred cycle and the worker's adoption-only wake skip.
The swept batches count as dropped, in events, like the rest of the
halt.

The new scenario runs three launches over one store and fails with
the sweep removed.
stop() runs the same cycle as any other flush, so a toggle-off
refusal on the final flush already drops the queue. Nothing pinned
it, though, and a batch left on disk there would be resent by the
next enable_telemetry like any other.

Pin it: a sealed pipe whose only send is the final flush, then a
fresh pipe over the same store with nothing to resend.
The telemetry_disabled change taught the Rust doc and
docs/telemetry.md that dropped also counts what the pipe discards
once the ingest reports the portal toggle off. The same sentence is
hand-copied into four more places: the UDL, the TypeScript type and
method docs, and the Python wrapper. All four still listed the old
sources.

Say it in all four. The UDL edit is a plain comment, which
uniffi-bindgen ignores: regenerating all three bindings from it
changes no generated file, so there is nothing to regenerate here.
@bahdotsh
bahdotsh merged commit ccba344 into main Sep 11, 2026
19 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 11, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant