Add UDS/RPC bridge benchmark suite with standalone app#55
Add UDS/RPC bridge benchmark suite with standalone app#55gmaclennan wants to merge 10 commits intomainfrom
Conversation
Plans an opt-in, host-app-driven Sentry integration covering: - error capture across backend (Node), JS/RN, and native layers - RPC tracing via @comapeo/ipc onRequestHook (mirrors comapeo-mobile) - forwarding @comapeo/core OpenTelemetry spans (PR digidem/comapeo-core#1051) - app-specific gating so non-CoMapeo consumers ship no Sentry traffic https://claude.ai/code/session_01EcVXzczA1TVkhEkgUg9DKX
Closes the FGS-cold-start gap where the prior draft required RN to be alive before backend Sentry could initialize: - §4 reworked: Expo config plugin writes DSN/environment/release into Android manifest meta-data and iOS Info.plist at prebuild time. Native reads those at process start, no JS round-trip, before booting @sentry/node and @sentry/android. - §7.4 added: native telemetry data design mapped onto Sentry primitives (breadcrumbs for state transitions, transaction + spans for boot/shutdown phases, captureMessage for timeouts, tags/contexts for cross-process attribution). Categorizes captures as essential vs opt-in and documents a hard never-capture list for PII. - §9 added: persisted "capture application data" toggle with restart-to-activate semantics. Snapshot read at boot, embedded in the init frame; gates per-RPC spans, sync-session transactions, memory checkpoints, and storage-size sampling. Never unlocks the never-capture list. - §10 phasing and §13 file-change list updated. New open questions added for release tagging, plugin no-op behavior, toggle UI, and boot sample rate. https://claude.ai/code/session_01EcVXzczA1TVkhEkgUg9DKX
Adds a stripped bench backend (`backend/index.bench.js` + bench RPC server with echo / payload methods) and a sibling `apps/benchmark/` app that drives it through the same RN→native→Node UDS path as production, isolating the framing / IPC / RPC bridge from @comapeo/core init noise. Consumer isolation is enforced three ways: - the bench bundle lands at sibling paths (`android/src/bench/assets/`, `ios/nodejs-project-bench/`) the production flavor / podspec don't reference; - a new Android `bench` productFlavor + iOS `ENV['COMAPEO_BENCH']` podspec toggle is opt-in only; - `package.json` files array negates both bench paths so they cannot leak via `npm publish`. `apps/benchmark/` does not check in `android/` or `ios/` — the new `with-comapeo-bench` Expo config plugin re-applies the variant / env-var / Xcode rename build phase wiring on every `expo prebuild`. Standalone-runnable: NDJSON sink + on-screen p50/p95/p99 work without any host-side infrastructure. Optional HTTP toggle posts spans to the bundled `bench-receiver.ts` for orchestrated BrowserStack runs. Maestro flows (bench-rpc + per-payload-size variants) drive the bench end-to-end. See `docs/uds-rpc-bridge-benchmark-plan.md` for the full design. https://claude.ai/code/session_01SC1Sc9AvULHQkQSoQ2SMzJ
Three fixes surfaced when running the bench app end-to-end on a
Pixel 7a API 29 emulator:
- **Replace Android productFlavor with a project property.** The
`bench` / `production` flavor dimension on the lib triggered AGP /
Gradle 9 strict variant ambiguity in consuming Expo apps that don't
declare matching flavors of their own (apps/expo#18315 etc.):
`missingDimensionStrategy` + `matchingFallbacks` weren't enough to
disambiguate `benchDebugApiElements` vs. `productionDebugApiElements`.
The lib now reads `rootProject.findProperty('comapeoBench')` and
swaps `assets.srcDirs` with `=` (assignment, not `srcDirs '<...>'`
which AGP treats as additive). Also empties `src/debug/assets` when
bench is active so the production debug bundle doesn't overlay
bench in debug builds. The `with-comapeo-bench` config plugin
switches from `withAppBuildGradle` to `withGradleProperties` and
writes `comapeoBench=true` into the consuming app's
`android/gradle.properties`.
- **Pin Expo modules to SDK 55.** `expo-file-system@19.0.18` and
`expo-sharing@14.0.7` (the latest npm versions) are SDK-incompatible
with Expo 55 and crashed the JS app at launch with a
`NoClassDefFoundError: FilePermissionModuleInterface` autolinking
failure. `npx expo install` resolves them to `~55.0.17` /
`~55.0.18` which match the rest of the SDK.
- **Add `bench-rpc-ios.yaml` Maestro flow.** The Android flow's
`clearState: true` triggers a deep-link confirmation dialog on iOS
that blocks the rest of the run. The iOS flow drops `clearState`
and dismisses the dialog with a guarded `runFlow.when` block.
Validation results on Pixel 7a API 29 emulator (debug build, RN-thread
RTT in ms, 100 iterations after 10-iteration warmup):
size n p50 p95 p99
64B 100 1.65 2.56 7.34
1KB 100 1.68 2.76 4.45
64KB 100 2.48 4.70 6.29
iOS run blocked by a pre-existing lifecycle issue
(`AppLifecycleDelegate.applicationDidBecomeActive` doesn't fire under
scene-based app lifecycle, so `NodeJSService.start()` is never called)
— same code path the example app uses, so this is not a bench
regression. Tracked separately.
https://claude.ai/code/session_01SC1Sc9AvULHQkQSoQ2SMzJ
There was a problem hiding this comment.
Pull request overview
Adds an isolated UDS/RPC bridge benchmarking setup (standalone Expo app + minimal bench backend bundle) plus host-side span collection, with build-time gating to prevent benchmark artefacts from leaking into normal consumer apps/packages.
Changes:
- Exposes
benchMessagePortfrom@comapeo/core-react-nativeand adds a bench-only backend entrypoint (backend/index.bench.js) with minimal RPC + span instrumentation helpers. - Introduces a standalone Expo benchmark app (
apps/benchmark/) and Maestro flows to automate benchmark runs across payload sizes. - Adds build/packaging plumbing for a separate bench bundle output tree and a host-side HTTP receiver to collate NDJSON + CSV summaries.
Reviewed changes
Copilot reviewed 28 out of 34 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/index.ts | Re-exports benchMessagePort from the module surface. |
| src/ComapeoCoreModule.ts | Exposes the raw CoreMessagePort singleton as benchMessagePort. |
| scripts/lib/bench-receiver.ts | Adds a localhost HTTP receiver that persists spans and rewrites a CSV summary. |
| scripts/build-backend.ts | Adds --bench mode to build only the bench JS bundle into bench-specific output paths. |
| package.json | Updates files allowlist to exclude bench output paths from publishing. |
| ios/ComapeoCore.podspec | Adds ENV['COMAPEO_BENCH'] conditional resource selection for bench bundle. |
| e2e/.maestro/bench-rpc.yaml | Maestro flow for the default benchmark sweep on Android. |
| e2e/.maestro/bench-rpc-ios.yaml | iOS-specific Maestro flow variant (handles the “Open” dialog and avoids clearState). |
| e2e/.maestro/bench-payload-64KB.yaml | Maestro flow for a 64KB-only payload run. |
| e2e/.maestro/bench-payload-64B.yaml | Maestro flow for a 64B-only payload run. |
| e2e/.maestro/bench-payload-1MB.yaml | Maestro flow for a 1MB-only payload run. |
| e2e/.maestro/bench-payload-1KB.yaml | Maestro flow for a 1KB-only payload run. |
| docs/uds-rpc-bridge-benchmark-plan.md | Adds a design/verification plan for the benchmark suite and consumer isolation. |
| backend/rollup.config.ts | Adds BENCH=1 rollup mode and trims static assets for the bench bundle. |
| backend/lib/telemetry-sink.js | Adds pluggable telemetry sinks and span helpers (startSpan). |
| backend/lib/boot-spans.js | Adds startBootSpan helper with a fixed boot-phase taxonomy. |
| backend/lib/bench-rpc.js | Adds a minimal bench RPC server (echo/payload) with payload caching and span emission. |
| backend/index.bench.js | Adds bench-only node entrypoint reusing the lifecycle framing but skipping @comapeo/core. |
| apps/benchmark/tsconfig.json | Bench app TS config + local path mapping to the working tree module source. |
| apps/benchmark/plugins/with-comapeo-bench/index.js | Expo config plugin to opt an app into bench resources (Gradle property + Podfile env + Xcode rename script). |
| apps/benchmark/package.json | Benchmark app package manifest + dependencies and run scripts. |
| apps/benchmark/metro.config.js | Metro config (mirrors example) for monorepo-style dev and avoiding duplicate peers. |
| apps/benchmark/index.ts | Bench app entrypoint registering root component. |
| apps/benchmark/babel.config.js | Bench app Babel config. |
| apps/benchmark/assets/splash-icon.png | Bench app splash asset. |
| apps/benchmark/assets/icon.png | Bench app icon asset. |
| apps/benchmark/assets/favicon.png | Bench app favicon asset. |
| apps/benchmark/assets/adaptive-icon.png | Bench app adaptive icon asset. |
| apps/benchmark/app.json | Bench app Expo config + plugin wiring. |
| apps/benchmark/App.tsx | Bench UI + RPC client + NDJSON writing + optional POST-to-receiver flow. |
| apps/benchmark/.gitignore | Ignores generated native folders and local Expo/Metro artifacts for the bench app. |
| android/build.gradle | Adds comapeoBench property gate to swap module asset source dirs for bench bundle selection. |
| .gitignore | Ignores bench bundle output dirs alongside the existing production bundle outputs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } from "@comapeo/core-react-native"; | ||
| import { Directory, File, Paths } from "expo-file-system"; | ||
| import * as Sharing from "expo-sharing"; | ||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
There was a problem hiding this comment.
useMemo is imported but never used in this file. This will trip lint/TS unused import checks; please remove it or use it.
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| import React, { useCallback, useEffect, useRef, useState } from "react"; |
| // Linear interpolation between closest ranks. For our sample sizes | ||
| // (~100), `Math.floor((n-1) * p)` is good enough and avoids the | ||
| // off-by-one trap of `Math.floor(n * p)` (which would index past the | ||
| // end at p=1). | ||
| const idx = Math.floor((sortedAsc.length - 1) * p); | ||
| return sortedAsc[idx]!; |
There was a problem hiding this comment.
percentile() claims linear interpolation, but the implementation returns a nearest-rank element (Math.floor((n-1)*p)). Either implement the stated interpolation (so p95/p99 match the documented method) or adjust the comment/docs to match the actual behavior.
| // Linear interpolation between closest ranks. For our sample sizes | |
| // (~100), `Math.floor((n-1) * p)` is good enough and avoids the | |
| // off-by-one trap of `Math.floor(n * p)` (which would index past the | |
| // end at p=1). | |
| const idx = Math.floor((sortedAsc.length - 1) * p); | |
| return sortedAsc[idx]!; | |
| // Linear interpolation between closest ranks. | |
| const position = (sortedAsc.length - 1) * p; | |
| const lowerIdx = Math.floor(position); | |
| const upperIdx = Math.ceil(position); | |
| if (lowerIdx === upperIdx) return sortedAsc[lowerIdx]!; | |
| const lower = sortedAsc[lowerIdx]!; | |
| const upper = sortedAsc[upperIdx]!; | |
| const weight = position - lowerIdx; | |
| return lower + (upper - lower) * weight; |
| request(method: string, params?: unknown): Promise<{ result?: unknown; error?: { message: string } }> { | ||
| this.ensureListener(); | ||
| const id = `bench-${this.nextId++}`; | ||
| return new Promise((resolve) => { | ||
| this.pending.set(id, resolve); | ||
| benchMessagePort.postMessage({ id, method, params } as never); | ||
| }); |
There was a problem hiding this comment.
BenchClient.request() has no timeout or rejection path. If the backend never replies (crash, disconnect, lost frame), the promise will never resolve and the benchmark run can hang indefinitely with the pending map retaining entries. Consider adding a per-request timeout (and cleanup) and/or handling a messageerror/disconnect signal to fail fast.
| function percentile(sortedAsc: number[], p: number): number { | ||
| if (sortedAsc.length === 0) return Number.NaN; | ||
| return sortedAsc[Math.floor((sortedAsc.length - 1) * p)]!; | ||
| } |
There was a problem hiding this comment.
percentile() currently uses a nearest-rank lookup (Math.floor((n-1)*p)). The PR description/plan mention linear interpolation for p50/p95/p99; if that's the intended definition, this summary CSV will not match it. Either implement the intended interpolation here or document that the receiver uses nearest-rank percentiles.
| - Android: the plugin uses `withAppBuildGradle` to append | ||
| `flavorDimensions += "comapeo"` and | ||
| `missingDimensionStrategy 'comapeo', 'bench'` to the bench app's | ||
| `android/app/build.gradle` `defaultConfig`. The module's own | ||
| `android/build.gradle` declares the `bench` flavor + sourceSet; | ||
| consumers that don't activate it (`apps/example/`, third-party | ||
| apps) get the default flavor and never see `src/bench/`. |
There was a problem hiding this comment.
This doc describes Android consumer isolation in terms of a bench productFlavor and missingDimensionStrategy, but the implementation in this PR uses a comapeoBench=true Gradle property (set via withGradleProperties) to swap assets.srcDirs instead. Please update this section so the plan matches what actually shipped (otherwise it's misleading for anyone following it).
| * stripped `backend/index.bench.js` (via the `bench` Android | ||
| * productFlavor / `ENV['COMAPEO_BENCH']` iOS opt-in) — so timings | ||
| * isolate the framing / IPC / JSON-RPC bridge from `@comapeo/core` init | ||
| * noise. See `docs/uds-rpc-bridge-benchmark-plan.md`. |
There was a problem hiding this comment.
The header comment says the bench backend is selected via an Android bench productFlavor, but the actual wiring in this PR uses the comapeoBench=true Gradle property (see android/build.gradle). Please update the comment so it matches the implementation.
| * stripped `backend/index.bench.js` (via the `bench` Android | |
| * productFlavor / `ENV['COMAPEO_BENCH']` iOS opt-in) — so timings | |
| * isolate the framing / IPC / JSON-RPC bridge from `@comapeo/core` init | |
| * noise. See `docs/uds-rpc-bridge-benchmark-plan.md`. | |
| * stripped `backend/index.bench.js` (via the `comapeoBench=true` | |
| * Gradle property on Android / `ENV['COMAPEO_BENCH']` iOS opt-in) — | |
| * so timings isolate the framing / IPC / JSON-RPC bridge from | |
| * `@comapeo/core` init noise. See | |
| * `docs/uds-rpc-bridge-benchmark-plan.md`. |
| * noise. See `docs/uds-rpc-bridge-benchmark-plan.md`. | ||
| * | ||
| * UI surface: | ||
| * - boot status (state observer): waits for "READY" before enabling |
There was a problem hiding this comment.
The UI comment says the app waits for "READY" before enabling the run button, but the code gates on serviceState === "STARTED" (and ComapeoState doesn't include "READY"). Please update the comment to avoid confusion about which state is required for RPC.
| * - boot status (state observer): waits for "READY" before enabling | |
| * - boot status (state observer): waits for "STARTED" before enabling |
| // Bench bundle output. Lives under `src/bench/assets/` so AGP's | ||
| // per-flavor sourceSet merging picks it up only when the consuming app | ||
| // has activated the `bench` productFlavor (see android/build.gradle — | ||
| // `apps/benchmark/` activates this; `apps/example/` does not). |
There was a problem hiding this comment.
This comment says the bench Android assets are picked up via a bench productFlavor/sourceSet merge, but the module now switches assets via the comapeoBench Gradle property (see android/build.gradle). Please update the comment so it matches the current mechanism.
| * - `android/src/bench/assets/nodejs-project/` (overlaid by the | ||
| * `bench` Android productFlavor — see android/build.gradle) |
There was a problem hiding this comment.
The bench-mode comment refers to the Android bench productFlavor for asset overlay, but Android selection is now controlled by the comapeoBench Gradle property (not flavors). Please update the comment to avoid sending readers to a mechanism that no longer exists.
| * - `android/src/bench/assets/nodejs-project/` (overlaid by the | |
| * `bench` Android productFlavor — see android/build.gradle) | |
| * - `android/src/bench/assets/nodejs-project/` (selected by the | |
| * Android build when the `comapeoBench` Gradle property is enabled; | |
| * this is no longer controlled by an Android productFlavor) |
BLOCKER (iOS rename ordering): the previous design added an Xcode Run Script build phase via the config plugin's `withXcodeProject`, but CocoaPods 1.x doesn't reliably position user script phases after `[CP] Copy Pods Resources` — the rename ran before the bench files were on disk and silently no-op'd, leaving bench builds with no `<App>.app/nodejs-project/` and a non-bootable runtime. Switch to pod-install-time staging in `ComapeoCore.podspec`: when COMAPEO_BENCH=1 the podspec stages a copy of `nodejs-project-bench/` to `.bench-staging/nodejs-project/` and adds it to `s.resources` ALONGSIDE the production `nodejs-project/`. CocoaPods rsyncs both into `<App>.app/nodejs-project/` in declaration order, with the bench overlay landing on top — no script phase, no ordering footgun. MAJOR (iOS resource fallback): previous design REPLACED `nodejs-project` with `nodejs-project-bench`, so any rename failure left the app non-bootable. New shape ships both: bench overlays prod, but if the bench bundle is missing (forgot to run `--bench`) the prod bundle remains as fallback. MAJOR (shutdown race): an in-flight `SocketMessagePort.postMessage` landing in streamx's deferred microtask after the AF_UNIX socket has been ended raises `ERR_STREAM_WRITE_AFTER_END` past every listener. The race is benign (the message was already destined for a torn-down peer). Add a state-check + underlying-socket error listener in `message-port.js`, and a targeted `uncaughtException` / `unhandledRejection` filter in `index.bench.js` that swallows the specific code while a graceful shutdown is in progress. Smoke test now exits 0 with all spans + responses recorded; previous run hit `fatal during runtime` and exit 1. Copilot review feedback addressed: - App.tsx: drop unused `useMemo`; replace nearest-rank percentile with linear-interpolation (matches PR description); add 30s per-request timeout + pending-map cleanup so a lost frame doesn't hang the run; update stale "READY" comment to "STARTED". - bench-receiver.ts: same linear-interpolation fix so on-device and host-side numbers agree. - Stale productFlavor / withXcodeProject references in App.tsx, scripts/build-backend.ts, backend/rollup.config.ts, and the plan doc updated to describe the actual `comapeoBench` Gradle property + podspec staging mechanism. https://claude.ai/code/session_01SC1Sc9AvULHQkQSoQ2SMzJ
…rpc-bridge-1Zahz * origin/main: fix(android): fold waitForFile into connect retry loop (#52)
47024a2 to
5acd807
Compare
Adds a generic config knob for consumers that ship their own backend JS bundle: `comapeoBackendDir` Gradle property → BuildConfig field on Android, `ComapeoBackendDir` Info.plist key on iOS. Default is `nodejs-project` so behavior is unchanged for current consumers. This unblocks moving bench-specific wiring out of the module: the bench app can now ship its bundle in a sibling directory and just flip this override, instead of relying on an in-module `comapeoBench=true` toggle that swaps Android sourceSets and runs an iOS pod-install staging copy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves all bench-only backend source (`index.bench.js`, `bench-rpc.js`, `boot-spans.js`, `telemetry-sink.js`) and its rollup config out of the production module and into `apps/benchmark/backend/`. The bench bundle is built from there with its own simplified rollup config: one ESM output, no per-platform split, no native-addon banner (the bench code imports no addons). Shared framing helpers (server-helper.js, simple-rpc.js, message-port.js) stay in the module's `backend/lib/` and are path-imported from the bench source so wire framing stays bit-identical to production. Rewrites `with-comapeo-bench` plugin against the new `comapeoBackendDir` override hook: drops `comapeoBench=true` Gradle toggle, drops `ENV['COMAPEO_BENCH']` Podfile mutation, drops the iOS `.bench-staging` rsync trick. Now sets the override property/Info.plist key and copies the bench bundle into the consumer app's own native asset/resource trees (Android assets dir + iOS folder reference). Same shape as `expo-asset`'s plugin, minus its file-extension allowlist and flat-structure constraints which don't fit a JS bundle. Strips `BENCH=1` mode from the module's rollup.config.ts and `--bench` mode from scripts/build-backend.ts. Dead bench wiring still in the module (`android/src/bench/`, `ios/nodejs-project-bench/`, podspec env branch) is removed in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With the bench app moved to apps/benchmark/ and using the new comapeoBackendDir override hook, the production module no longer needs: - comapeoBench Gradle property + conditional sourceSet swap in android/build.gradle (sourceSets revert to AGP defaults) - ENV['COMAPEO_BENCH'] branch + .bench-staging rsync in ios/ComapeoCore.podspec (s.resources is just ['nodejs-project']) - !android/src/bench/ and !ios/nodejs-project-bench/ exclusions in package.json files (those dirs no longer exist in the module) - Bench-specific .gitignore entries Also removes the (build-artifact, gitignored) android/src/bench/ and ios/nodejs-project-bench/ directories, and updates two stale comments in retained source files plus a header note in the planning doc pointing at the v2 implementation in apps/benchmark/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The export was always misnamed: it isn't a benchmark-specific API, it's the raw `MessagePort`-shaped escape hatch one level below the `comapeo` client. Anything paired with a custom backend bundle (the bench app being the canonical example) goes through this port. `unstable_` matches React's `unstable_batchedUpdates` / `unstable_setExceptionDecorator` convention — signals "may change without notice" without burning the API on a name like `INTERNAL_messagePort` that implies stronger guarantees about internal-only access. Lowercase because it's an instance, not a class. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Introduces a comprehensive benchmarking suite for measuring UDS/RPC bridge performance in isolation from
@comapeo/core. This includes a new standalone benchmark app (apps/benchmark/), a stripped-down bench-only backend (backend/index.bench.js), and supporting infrastructure for collecting and analyzing telemetry data.Key Changes
New benchmark app (
apps/benchmark/): A slim React Native app with UI for selecting payload sizes, running benchmark sweeps, and viewing results. Includes Maestro flows for automated testing across payload sizes (64B, 1KB, 64KB, 1MB).Bench-only backend (
backend/index.bench.js): Minimal Node.js entry point that reuses the production state machine but registers onlyechoandpayload(sizeBytes)RPC methods, excluding@comapeo/coreentirely to isolate bridge performance from application-layer noise.Telemetry sink abstraction (
backend/lib/telemetry-sink.js): Pluggable span recording interface with three implementations:NoopSink: Zero-overhead defaultJsonFileSink: NDJSON-to-disk for on-device resultsHttpSink: Fire-and-forget POST for orchestrated runsBoot span instrumentation (
backend/lib/boot-spans.js): Wraps initialization phases with Sentry-shaped span taxonomy (boot.listen-control,boot.init,boot.construct) for consistent observability.RPC server (
backend/lib/bench-rpc.js): Minimal request/response handler that emitsop:"rpc"spans with per-call metadata (payload size, duration).Host-side receiver (
scripts/lib/bench-receiver.ts): HTTP server that collects spans posted by the bench app over BrowserStack Local tunnel, generates per-run NDJSON files, and maintains a CSV summary for cross-device comparison.Consumer isolation via Expo config plugin (
apps/benchmark/plugins/with-comapeo-bench/): Idempotent mutations to Android Gradle and iOS Podfile that activate thebenchflavor/resources only for the benchmark app, preventing bench artefacts from leaking into production consumer APKs/IPAs.Build system updates:
backend/rollup.config.ts: AddedBENCH=1mode to emit bench bundle to sibling paths (android/src/bench/assets/nodejs-project/,ios/nodejs-project-bench/)scripts/build-backend.ts: Added--benchflag to build bench-only bundle without re-packaging native binariesandroid/build.gradle: Addedbenchproduct flavor with matching fallbacks for build typesios/ComapeoCore.podspec: Conditional resource inclusion based onENV['COMAPEO_BENCH']Module exports: Exposed
benchMessagePortfrom@comapeo/core-react-nativefor the benchmark app to communicate with the bench backend.Notable Implementation Details
Sentry taxonomy reuse: Span shape and naming follow the Sentry integration plan (§7.4.2) so dashboards and analysis tools work for both transports; the plan's Phase 3 can adopt these helpers without call-site changes.
Idempotent Expo plugin: Uses sentinel-tag include checks to ensure
expo prebuildre-runs don't duplicate mutations.Fire-and-forget HTTP sink: Intentionally swallows receiver failures so the on-device experience remains unaffected when no host receiver is available.
Payload caching: Pre-allocates and caches synthesized payloads (up to 4 MiB) to avoid spending benchmark time in string generation.
Percentile calculation: Uses linear interpolation between closest ranks for p50/p95/p99 statistics over ~100 steady-state samples.
https://claude.ai/code/session_01SC1Sc9AvULHQkQSoQ2SMzJ