September 11, 2026
3 pull requests merged across 1 repo
Offline-Protocol/offline-protocol-sdk
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 -- --checkpasses -
cargo clippy --workspace -- -D warningspasses (CI's exact invocation, run locally) -
cargo test --workspacepasses โ rancargo test --workspace --liblocally (2,728 passed across 13 suites, including the pipe suite's 106); doctests are left to CI -
cargo-denyis satisfied โ not run; no dependency change - Commits follow Conventional Commits (
<type>(<scope>): <subject>) - Docs /
CHANGELOG.mdupdated where relevant (docs/telemetry.md, its errors table,UPGRADING.mdยง20, the unreleased changelog section) - No new
unsafein 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_disabledis 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 indropped. - Adoption after the refusal.
enableTelemetrystarts the pipe in memory, and the worker adopts the durable queue onceinitialize_mlshands 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 nextenableTelemetryresent 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_disabledthat long after the toggle comes back on. A launch inside that window halts and discards until its nextenableTelemetry, and unlike a bad-key halt nothing is recovered at the launch after.docs/telemetry.mdstates 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
forbiddenall stay the ordinary halt, so deploy order between the SDK and the ingest does not matter. Pinned inonly_a_403_carrying_the_telemetry_disabled_code_disables. - Test harness.
CapturingClientnow answers a non-2xx with the ingest's error shape, witherror_codesettable. 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 telemetry client moves into the Rust core. enableTelemetry({ apiKey, appId, appVersion }) and the SDK collects, folds, queues and uploads accepted events to the ingest on its own background thread; nothing crosses the bridge per event. The sink API is removed on every binding. Plan record: .plans/telemetry-pipe.md (revision 3). Release 0.26.0, breaking.
Commits, in order:
1. feat(telemetry-wire): the wire crate, a copy of the ingest's with the raw reason field and the widened os enum; canonical fixture hash-pinned.
2. feat(protocol)!: the pipe (classify, rollup, pairer, ring, sealed durable queue, uploader, scheduler), enable_telemetry and the companion calls, the emit gate before scrub_event, the golden test against the frozen TypeScript client.
3. feat(uniffi,bindings)!: UDL, FFI, React Native, Kotlin, Swift, Python; host logger; privacy manifest; source guards.
4. docs(examples): the demo enables telemetry with a portal key.
5. docs: telemetry inventory, privacy disclosures, migration section 20, threat-model egress + R13/R14, bridges C12, licensing FAQ.
6. docs(license): the telemetry term of the commercial license.
Size (WP0)
Shipped artifact bytes, main 76b2e056 against this branch, release profile unless noted.
| Target | Before | After | Delta |
|---|---|---|---|
| iOS arm64 dylib | 8,693,992 | 10,651,480 | +1,957,488 |
| iOS arm64 dylib, minisize | 4,465,760 | 5,707,976 | +1,242,216 |
Android arm64 .so | 9,177,936 | 11,203,328 | +2,025,392 |
Almost all of it is the TLS stack (rustls, ring, the Mozilla roots); the pipe itself is tens of kilobytes. The plan's tripwire was 2 MB per arch for reconsidering the host-HTTP design; Android sits on the line (1.93 MiB, 2.03 MB). ureq's rustls feature hard-requires rustls tls12 and logging, so there is no cheap trim short of forking the feature set. Design A kept, per the plan's no-hidden-hooks rule; the number is here for the decision.
Divergences from the mesh-analytics oracle (plan 0.3), each a named test
- Backoff caps at 15 min (was 5).
- 413 is dropped (was split and requeued).
- 401/403 halt sending until the next
enableTelemetry(was drop and continue). Retry-Afterhonoured on 429.Idempotency-KeyandUser-Agentheaders sent; 15 s global and 10 s connect timeouts.- Interval and transport wakes deferred below 15% battery unless charging.
reasongoes up raw;reason_classis the ingest's to derive.
Everything else matches the frozen client batch for batch in pipe_matches_the_typescript_oracle.
Deviations from the plan text
- The FFI parity test cannot use a local HTTP server: the endpoint is a build-time constant and must be https. A
test-utilscargo feature (telemetry::pipe::testing::capture_uploads) routes uploads into a capturing client; enabled by dev-dependencies only, same arrangement as the transport crate's mock. - The lifecycle is owned natively (Kotlin host lifecycle listener; Swift
UIApplicationnotifications with a background task aroundflushTelemetryBlocking) rather than by a JSAppStatesubscription, so sessions close while JS is frozen.react_native_bridges_pass_platform_info_at_enable_and_own_the_lifecyclepins it. TelemetryConfigInvalidclassifies asinvalid_requestin the send-failure token map;config_invalidis not in the closed token list.- The wire crate declares only the three dependencies it uses.
Verified locally
cargo test --workspace (30 suites, 2781 passed), cargo clippy --workspace -- -D warnings plus the --no-default-features step, RUSTDOCFLAGS="-D warnings" cargo doc, cargo fmt --check, cargo deny check licenses, notices regenerated, license and README gates, bindings regenerated from the UDL, RN npm run build + npm run test:js (new telemetry-config.test.js), swift test (340), CI's iOS swiftc -typecheck plus the full-module typecheck with a negative control, the Android harness (479), the Python suite in a venv (257), the demo tsc.
Not run: the on-device acceptance protocol (plan 0.7) and the Criterion process_loop baselines; benches compile.