Add SDKConfigBenchmark: legacy vs config endpoint benchmark suite#7174
Draft
facumenzella wants to merge 35 commits into
Draft
Add SDKConfigBenchmark: legacy vs config endpoint benchmark suite#7174facumenzella wants to merge 35 commits into
facumenzella wants to merge 35 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Async chunked delivery with per-class (api vs cdn) latency, seeded loss model, etag 304 and manifest 204 warm paths, and a config kill-switch 4xx mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
legacy = OfferingsManager with no remote config manager, config modes = the full RemoteConfigManager stack with an injected simulated blob downloader. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
…output Embeds an info-plist section in the CLI binary so the SDK's bundle-id-derived disk caches (etags, offerings, remote config) work outside an app container. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
--transport live re-issues requests against the real backend through a recording passthrough (same per-request metrics as simulated runs), defaults to the Stress Test Config Endpoint project's public test store key, and rejects the simulation-only knobs (profiles, loss, forced kill-switch 4xx). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
- request network plans keyed by (seed, iteration, url, attempt) so sampling is independent of scheduler-driven arrival order; same-seed loss-free rows are byte-identical across processes - ui_config blobs no longer prefetched, so no downloads straddle the measured offerings completion and per-iteration accounting stays exact - warmup discard is by iteration index, rows carry measured_iterations and post_warmup_error_count, and compare.py flags rows with post-warmup errors - every measured warm iteration must revalidate (offerings 304, config 204, zero blob traffic) instead of one loose whole-run check - each process isolates all SDK disk caches under a temp root via a gated DirectoryHelper override (container dirs ignore HOME), fixing concurrent-run corruption and real-Library pollution; tests are hermetic the same way - benchmark swift conditions no longer clobbered by TUIST_SWIFT_CONDITIONS Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
…elper reuse One RequestKind classifier stamped on every TransportEvent now drives fixture routing, RTT class, live connection pooling, phase attribution, and warm validation, so those rules can no longer drift apart. Reuses the SDK's RemoteConfigBlobRefHelpers, littleEndianData, and ETag header constants; precomputes fixture responses; computes request plans outside the global lock with no-copy body slices; drops dead code and dedups test scaffolding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
- warm kill-switch iterations must actually pay the config 4xx (with negative tests), so the mode can never silently degrade into measuring pure legacy - compare.py keys rows by transport/paywalls/workflows/seed too and rejects duplicate rows instead of silently overwriting them - live blob passthrough re-issues on URLSession.shared, matching the production blob downloader exactly - shared LockedValue test helper replaces three private copies; README benchmark command is copy-pastable again Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
- legacy Documents-directory migrations (ETagManager, DeviceCache) are gated off while the benchmark disk-root override is active, so runs can never touch or delete real user files outside their sandbox - rows with post-warmup iteration errors exit nonzero (matrix runner counts them and fails at the end), and compare.py exits nonzero on invalid or duplicate rows instead of just warning - comparison keys include iterations and warmup_discarded so different sample-size runs can never be compared as equivalents Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
- launch order matches Purchases.updateAllCaches exactly (offerings enqueued before the remote config refresh), so config-mode serialization measures the production path instead of an inverted one - fixture 204 additionally requires the request to replay the full prefetched blob set, so a regression in prefetched_blobs reporting surfaces as warm 200s; verified against real SDK behavior by the end-to-end warm test - compare.py fails on baseline/candidate key-set mismatches (opt out with --allow-missing), so truncated result files can't pass a regression gate - live-run user nonce is a UUID, immune to same-second or parallel launches Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
- launch drives OfferingsManager.updateOfferingsCache directly, the exact call (and synchronous enqueue order) Purchases.updateAllCaches uses, so the config request can never race ahead of offerings through the main actor hop - transport events carry the iteration that started them and the runner waits for transport quiescence before draining, so trailing requests (the warm config 204 that legitimately finishes after offerings delivery) are counted in the right row and stragglers from failed launches are filtered out - failed launches close their remote config manager to stop residual churn - live rows label paywalls/workflows as 0 (payloads come from the pinned project, not fixture-size knobs) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
…numbers Transport idleness alone can race the gap between kicking the refresh and its request reaching the transport (seen against the real backend), so event collection also waits for the iteration's config request in config modes. Sample tables regenerated with the production-order launch semantics; warm config delivery now measures at parity with legacy, matching production. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
- cancelled simulated requests record their terminal event from stopLoading (record-once), so the in-flight counter can never leak and stall later iterations after an HTTP-client timeout - annotation keys that collide with reserved row fields are rejected, so a row can never be relabeled into the wrong comparison group Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
The stress project now publishes one workflow; khepri inlines its blob in the config container, so the full config-to-blob-store chain runs live with no CDN fetches yet. CDN fan-out starts once enough content is published to stop inlining. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
PROJECT_ID=<id> on the matrix resolves the project's key via mafdet (test-store app preferred); the binary takes --project-id, labels every live row with it, requires it alongside a custom --api-key, and compare.py keys on it, so results from different projects can never be compared as equivalents. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
…urce Live runs read the key from --api-key or SDK_CONFIG_BENCHMARK_API_KEY, and run-matrix.sh resolves it via mafdet for the (default or PROJECT_ID-swapped) target project. No keys live in the repository. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Generated by 🚫 Danger |
facumenzella
force-pushed
the
facu/sdk-config-benchmark-v2
branch
from
July 9, 2026 08:03
f6d810f to
9597957
Compare
A minimal iOS app that links the stock RevenueCat/RevenueCatUI products and measures a real Purchases.configure launch end to end (configure, first customer info, offerings, paywall appeared), exposing the sample as JSON in an accessibility element for an XCUITest runner to collect. API key and app user ID arrive via the launch environment; --wipe-state deletes all SDK disk state for true cold launches. Own Tuist project, local/CI only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Relaunches the benchmark app once per iteration (true process cold starts), collects each launch's LaunchSample, and aggregates one compare.py-compatible JSONL row per scenario (cold and warm), printed as BENCHMARK_ROW and attached to the test result. AppLaunchMetrics is shared into the macOS benchmark unit test target so the aggregation (key fields, nearest-rank percentiles, error accounting) is covered without a simulator. Tests skip without BENCH_API_KEY and BENCH_MODE_LABEL so bare scheme runs never emit mislabeled rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
run-app-launch.sh builds SDKConfigBenchmarkApp once per SDK variant by rewriting SWIFT_ACTIVE_COMPILATION_CONDITIONS in Local.xcconfig (restored on exit), forces an SPM manifest re-evaluation so a cached manifest can never build the wrong variant, verifies from the build log that the flag reached the SDK compile, runs the XCUITest loop against the live project, and greps the BENCHMARK_ROW lines into JSONL. Nonzero exit when any variant fails, mislabels, or produces fewer rows than expected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
…environment Lets the benchmark app be launched straight from Xcode: TUIST_BENCH_API_KEY=<key> tuist generate SDKConfigBenchmarkApp. The key lands only in the generated (gitignored) scheme, never in source. Without it, a direct run still reports the missing key by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Both tiers now record how many blobs each launch stored, split inline vs CDN-downloaded, with byte totals and size extremes (largest inline, smallest downloaded) that empirically bracket the backend's inline-size budget (currently ~3MB). The CLI attributes via its blob store plus transport events; the app observes the stock SDK's log stream with a parser pinned to RemoteConfigStrings by unit tests. The app additionally stamps config_persisted and last_blob_stored phases. Also applies three review findings: UITests build and run under Release (the rows claim shipping-SDK numbers), the headline percentile moves from the PaywallView wrapper's onAppear to the SDK's content-appeared event (paywall_impression, or workflows_step_started for workflow paywalls, via the internal events-listener SPI), and every measured launch now proves at runtime which SDK variant is in the binary (the config refresh log fires on every config-variant launch; rows whose configPathActive contradicts their label fail the run, cached builds included). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
…eproj sync check The SDKConfigBenchmark CLI and SDKConfigBenchmarkApp files live only in their Tuist projects and are intentionally absent from RevenueCat.xcodeproj, same as Tests/APITesters. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
…rived data Two fixes found by running the Release configuration end to end. SwiftPM caches Package.swift evaluations by content hash (mtime is ignored), so the previous touch-based invalidation silently reused the prior variant's flags whenever a derived data path was reused; the script now maintains a variant marker comment in Package.swift (removed on exit) so the manifest re-evaluates exactly when the conditions change. And both variants now compile BYPASS_SIMULATED_STORE_RELEASE_CHECK, the SDK's designed opt-out for the guard that crashes Release builds configured with a Test Store key, which live runs use. First Release numbers recorded in the doc, including a real finding from the new blob registration: a 65-byte blob arrived via CDN instead of inline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
…ailed-run rows Two adversarial-review findings. configPathActive only proved the config refresh started; a transient refresh failure falls back to legacy delivery and would have passed as a clean config-variant row measuring the wrong system. Launches now record the refresh's terminal outcome (persisted, not_modified, failed) from the log stream, and config-variant runs require persisted on cold and persisted or not_modified on warm. And the runner script no longer extracts BENCHMARK_ROW lines from a failed xcodebuild invocation, since the tests print rows before their validity assertions run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Move the cold-launch state wipe before the clock anchor (harness cleanup I/O was counted inside every cold phase), centralize the SDK-copied literals (content-appeared event types, UserDefaults suite name) into a shared SDKObservedValues pinned by unit tests against the real SDK events and suite, extract the mafdet key resolution shared by both tiers into resolve-api-key.sh, and fold the runner script's four copy-pasted failure blocks and mirrored flag greps into one helper and one polarity check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Convergence-pass review findings, all measurement-validity gates. The CLI now validates cold iterations too: config mode requires a successful config request (a failed refresh delivers offerings via legacy fallback and would pass as a config row), kill-switch mode requires its 4xx. The app tier's warm scenario fails when the priming launch errors (its samples would be cold starts labeled warm) and accepts only the not_modified outcome on measured warm launches, matching the CLI warm validation. The runner script validates the row count before emitting, so a partial scenario can no longer leak rows into the captured JSONL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
…pplied keys Final review-pass finding: SDK_CONFIG_BENCHMARK_API_KEY (or --api-key) with no project id let rows carry the pinned default project label with another project's key, so live rows from different projects could compare as equivalents. The shared script helper and the CLI binary now both require the explicit label. Also corrects the observer-overhead comment: the verbose-log observer is not perfectly even across variants (config logs more lines), but its per-line cost is microseconds against hundreds-of-ms deltas. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Per team sync, the metric that gates release decisions is Purchases.configure + getOfferings with and without workflows compiled in. The row's percentile statistics now aggregate the offerings phase, matching the CLI tier's total (offerings delivered), so both tiers answer the same question. The paywall render marks (wrapper, impression) stay in the row as secondary phase means, and the launch flow is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Rows now say WHAT was fetched, not just how much: the CLI runner joins each iteration's newly stored blob refs against the persisted topic index and emits blobs_by_topic (per-topic count and byte means). First live run attributes all 3 inlined blobs in the macOS request to the workflows topic, 159.5KB total. Groundwork for phase 2 of the measurement campaign, where the stress project gains more workflows and offerings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
…sible budget At hundreds of iterations, a ~1% harness flake (the result element never appearing within 90s) is expected, and the zero-error gate was voiding whole 35-minute rounds over 2 censored launches out of 250. A launch that produces nothing is now relaunched once (a fresh, unbiased sample); the retry count lands in the row as launch_retries so censored launches stay visible, and the runner fails the round when retries exceed ~2% of iterations or a retry also fails. Samples that report a real error are still never retried. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Visual companion (SDK wiring): https://claude.ai/code/artifact/ba8a51b9-d976-487a-b833-c5313a690347
Visual companion (app-launch tier): https://claude.ai/code/artifact/7ceb8f6d-fb74-4b53-a6b0-cd0d430535ac
We need reproducible, SDK-side numbers to compare the current offerings system against the config endpoint system (and its kill switch) before releasing config + workflows, and a way to keep monitoring both systems afterwards.
Description
Adds
SDKConfigBenchmark, a macOS command-line benchmark (own Tuist project, compilesSources/directly) that measures time-to-offerings-delivered through the real manager-level SDK code:OfferingsManager.updateOfferingsCache+RemoteConfigManagerrefresh, called exactly likePurchases.updateAllCachesdoes. Three modes (legacy,config,config-killswitch), two scenarios (cold,warmwith real 304/204 revalidation enforced per iteration), two transports:simulated(default): deterministic in-process fixtures with network profiles (ideal/wifi/lte) and a seeded packet-loss model. Same-seed loss-free rows are byte-identical across processes.live: real requests against the pinned stress-test project (5f07e7e3), through a recording passthrough so live rows carry the same per-request metrics.PROJECT_ID=<id>swaps the target project (key resolved via mafdet, rows labeled so cross-project results never compare as equivalents).Output is JSONL (percentiles, phase timings, request/byte counts, error accounting);
compare.pydiffs two runs and fails on invalid, duplicate, missing, or mislabeled rows, so it works directly as a regression gate.run-matrix.shruns the curated matrix. Methodology, determinism contract, sample numbers, and known limitations are inTests/Benchmarks/SDKConfigBenchmark/CONFIG_ENDPOINT_BENCHMARKS.md.Headline numbers so far: config cold on LTE is ~10x legacy purely because all fixture workflows are prefetch-gated (prefetch scope is the lever to sweep next); config warm delivers at parity with legacy (the manifest 204 revalidates after delivery, matching production); the kill switch costs ~1 API round trip per launch. Live runs against the stress project confirm the full config→blob-store→warm-proof chain, with blobs currently delivered inline by khepri.
SDK sources are untouched except four benchmark-gated hooks (
#if SDK_CONFIG_BENCHMARK): the transport injection inHTTPClient, a disk-root override inDirectoryHelper(container dirs ignore$HOME, so runs sandbox all caches under a temp root), and two guards that disable the legacy Documents migrations while sandboxed. No API keys live in source: live runs resolve the target project's key at run time (run-matrix.shvia mafdet, or--api-key/SDK_CONFIG_BENCHMARK_API_KEYfor direct binary runs).App-launch tier. On top of the CLI,
SDKConfigBenchmarkApp(own Tuist project underTests/TestingApps/) measures what the CLI can't: a real iOS app linking the stockRevenueCat/RevenueCatUIproducts (built Release) that callsPurchases.configureand times configure → first customer info → offerings → paywall content on screen. The headline percentile isconfigure + getOfferings, with or without workflows compiled in (the team's release-gating metric, matching the CLI tier's total); the paywall marks (wrapper mount, and the SDK's content-appeared event) stay in the row as secondary phases. An XCUITest relaunches the app once per iteration (each a true process cold start;coldwipes SDK state per launch,warmretains it), andrun-app-launch.shbuilds the two SDK variants by rewritingSWIFT_ACTIVE_COMPILATION_CONDITIONSinLocal.xcconfig(whichPackage.swiftfeeds into the SPM-built SDK). Variant labels are proven twice: the build log must show the flag reached the SDK compile, and every measured launch must report at runtime whether the config path actually ran (mismatch fails the run, cached builds included).Every launch also registers blobs: counts split inline vs CDN-downloaded, byte totals, and size extremes (
max_inline_blob_bytes/min_downloaded_blob_bytes) that empirically bracket the backend's inline-size budget (blobs under ~3MB should arrive inline; a small blob arriving via CDN is a regression the rows catch). The app observes this through the stock SDK's log stream with a parser pinned toRemoteConfigStringsby unit tests, plusconfig_persisted/last_blob_storedphase marks; the CLI registers the same fields via its blob store and transport events. The registration caught a real signal on its first Release run: alongside the 2 inline workflow blobs (max 70.8KB), 4 blobs were CDN-fetched, the smallest just 65 bytes — far under the inline budget, worth raising with the config team.The goal of this tier: run the real app N times through any automation that can drive
xcodebuild test(CI, the baguette CLI, a cron box) and collect the same comparable, gateable JSONL the CLI matrix produces, so both systems get measured end to end exactly as a customer app experiences them. Rows arecompare.py-compatible (app-launch-legacy/app-launch-config,profile= simulator/device). First Release numbers (simulator, live network, p50 over configure + getOfferings): config cold 1354ms vs legacy 1221ms (+11%), warm 1094ms vs 888ms (+23%).How to run
One-time setup:
tuist install && tuist generate SDKConfigBenchmarkFull simulated matrix (all modes x scenarios x profiles + loss sweep, ~10 min) and a results table:
bash Tests/Benchmarks/SDKConfigBenchmark/run-matrix.sh > results.jsonl python3 Tests/Benchmarks/SDKConfigBenchmark/compare.py results.jsonlAgainst the real backend (stress-test project by default; key resolved via mafdet):
Regression comparison between two branches (nonzero exit on invalid/duplicate/missing rows):
Single configuration:
App-launch tier (real app, live network; builds both SDK variants, ~15-25 min):
bash Tests/TestingApps/SDKConfigBenchmarkApp/run-app-launch.sh > app-launch.jsonl python3 Tests/Benchmarks/SDKConfigBenchmark/compare.py app-launch.jsonlPass
DESTINATION="platform=iOS,id=<udid>"for a physical device,ITERATIONS=25for real numbers. To poke at the app from Xcode:TUIST_BENCH_API_KEY=<key> tuist generate SDKConfigBenchmarkAppinjects the key into the (gitignored) scheme.Unit tests (86):
xcodebuild -workspace RevenueCat-Tuist.xcworkspace -scheme SDKConfigBenchmarkTests \ -destination platform=macOS testEvery run sandboxes the SDK's disk caches under a temp root; simulated runs cannot touch the network. Full docs:
Tests/Benchmarks/SDKConfigBenchmark/README.md.AI session context
AI Context
Metadata
facu/sdk-config-benchmark-v2Goal
A repeatable benchmark suite that measures the legacy offerings system vs the config endpoint system through real SDK code, producing numbers trustworthy enough for release decisions and ongoing regression monitoring.
Initial Prompt
Review an earlier codex-authored benchmark harness ("what do you think?"), then: "Let's plan for it. Once you're ready, implement it. Then run it through /codex-review-fix-loop." The plan replaced the earlier harness's hand-rolled
/v2/configcalls (an endpoint that doesn't exist on main) with real manager-level SDK flows.Important Follow-up Prompts
livetransport against production instead of captured fixtures.PROJECT_IDsupport.config_persisted,last_blob_stored) to the app tier via the SDK's log stream.Agent Contribution
Tests/Benchmarks/SDKConfigBenchmark/,Projects/SDKConfigBenchmark/, and the four gated SDK hooks; 65 unit tests; matrix runner; compare script; methodology doc.Tests/TestingApps/SDKConfigBenchmarkApp/andProjects/SDKConfigBenchmarkApp/, 6 additional unit tests, the variant runner script, and doc sections; verified end to end on simulator against the live project (4 rows, zero errors).Human Decisions
Key Implementation Decisions
Sources/**into the benchmark target and driveOfferingsManager.updateOfferingsCache+RemoteConfigManager.refreshRemoteConfigdirectly, in production enqueue order.offerings(appUserID:)adds a main-actor hop production's launch path doesn't have.prefetched_blobsproof, kill-switch all-4xx, zero blob re-downloads) with nonzero exits on violation.DirectoryHelperoverride, with legacy Documents migrations disabled while sandboxed.$HOME; without this, concurrent runs corrupted each other (observed) and migrations could move/delete real~/Documents/RevenueCatfiles.RevenueCat/RevenueCatUIproducts and switch variants via theENABLE_REMOTE_CONFIGcompile condition inLocal.xcconfig, with a build-log check (-DENABLE_REMOTE_CONFIGpresent/absent) refusing mislabeled rows.@objc(RC...)classes).BENCH_API_KEY/BENCH_MODE_LABELso bare scheme runs can never emit mislabeled rows.paywall_impression/workflows_step_started, via the@_spi(Internal) EventsListenerdebug API), applied from an adversarial-review finding that the wrapper'sonAppearcan precede visible content.onAppearas the headline (kept as a secondarypaywall_appearedphase).Purchases.verboseLogHandler, with the parser pinned toRemoteConfigStringsby unit tests so log-copy changes fail tests instead of silently reporting zeros.BENCH_EXPECT_CONFIG_PATH(review finding: cached builds bypassed the build-log flag check); each measured launch reports whether the config refresh ran, and a mismatch with the row's label fails the run.Files / Symbols Touched
Tests/Benchmarks/SDKConfigBenchmark/Sources/— benchmark coreBenchmarkRunner,BenchmarkSDKStack,SimulatedTransportURLProtocol(+Passthrough/Plans),FixtureServer,BenchmarkPayloadFactory,RCContainerEncoder,BenchmarkMetrics,BenchmarkCommandBenchmarkRunner.launch/collectEventsandvalidateWarmMeasurement.Sources/Networking/HTTPClient/HTTPClient.swift,Sources/Caching/DirectoryHelper.swift,Sources/Caching/DeviceCache.swift,Sources/Networking/HTTPClient/ETagManager.swift— gated hooks only#if SDK_CONFIG_BENCHMARKand inert in shipping builds.Projects/SDKConfigBenchmark/Project.swift,Workspace.swift— Tuist wiring.run-matrix.sh,compare.py,README.md,CONFIG_ENDPOINT_BENCHMARKS.md.Tests/TestingApps/SDKConfigBenchmarkApp/— app-launch tierSDKConfigBenchmarkApp/LaunchModel(measuring app),LaunchSample/LaunchClock,AppLaunchBenchmarkUITests(relaunch loop),AppLaunchMetrics(row aggregation, unit-tested),run-app-launch.shLaunchModel, variant-verification and xcconfig restore in the script.Projects/SDKConfigBenchmarkApp/Project.swift— app + UITest targets;Tuist/ProjectDescriptionHelpers/Environment.swift—TUIST_BENCH_API_KEYscheme injection.Dependencies / Config / Migrations
SDK_CONFIG_BENCHMARK+ENABLE_REMOTE_CONFIGcompilation conditions; no SPM/CocoaPods changes; no public API changes.Validation
xcodebuild ... -scheme SDKConfigBenchmarkTests test: 86/86 pass (incl. blob accounting, log-parser + event-type + UserDefaults-suite pins against the real SDK, impression-based row statistics, and cold-config validation)blobs_inline_mean2,blobs_downloaded_mean0,max_inline_blob_bytes70,774 (khepri inlining confirmed); simulated run attributes the mirror image (all downloaded)config_persisted_ms_mean~1219, same 2 inline blobs / 102KB as the CLIxcodebuild ... -scheme SDKConfigBenchmark -configuration Release build: succeedsswift build(plain SPM): succeeds, hooks inertswiftlint: cleanrun-matrix.sh(simulated, 18 rows) andTRANSPORT=live(4 rows): zero errorsrun-app-launch.sh(both variants, simulator, live network): exit 0, 4 rows, zero errors,Local.xcconfigrestored byte-identically, flag verification ran on real SDK compilesprefetched_blobsproof); app tier verified rendering the project's paywall on simulator.Validation Gaps
DESTINATION); app rows don't carrysdk_commityet. Release end-to-end verified (4 rows, zero errors, both variant proofs passing) after fixing two real failures the Release run exposed: the SDK's intentional Release+Test-Store-key crash (fixed via its designedBYPASS_SIMULATED_STORE_RELEASE_CHECKopt-out) and SwiftPM's content-hash manifest cache silently reusing the previous variant's flags on cached derived data (fixed via a variant marker comment inPackage.swift, removed on exit).not_modified, failed-run row quarantine, and explicit project labels for externally supplied keys. Rejected: 1 false positive (TEST_RUNNER_env forwarding, verified empirically); 1 partial (observer-overhead bias re-architecture skipped, microseconds vs hundreds-of-ms deltas, comment corrected instead). Plus a 4-agent simplify pass (cold wipe moved out of the measured window, SDK-copied literals pinned by tests, shared key resolution). Final standard review: clean.min_downloaded_blob_bytespin the boundary from above.Review Focus
SDK_CONFIG_BENCHMARK?BenchmarkRunner.launchfaithfully mirrorPurchases.updateAllCachesordering?Local.xcconfigrewrite + build-log flag check a sound variant switch, and is the xcconfig backup/restore safe for developers' local files?Risks / Reviewer Notes
benchmark-user-<uuid>-N).