Skip to content

Add SDKConfigBenchmark: legacy vs config endpoint benchmark suite#7174

Draft
facumenzella wants to merge 35 commits into
mainfrom
facu/sdk-config-benchmark-v2
Draft

Add SDKConfigBenchmark: legacy vs config endpoint benchmark suite#7174
facumenzella wants to merge 35 commits into
mainfrom
facu/sdk-config-benchmark-v2

Conversation

@facumenzella

@facumenzella facumenzella commented Jul 9, 2026

Copy link
Copy Markdown
Member

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, compiles Sources/ directly) that measures time-to-offerings-delivered through the real manager-level SDK code: OfferingsManager.updateOfferingsCache + RemoteConfigManager refresh, called exactly like Purchases.updateAllCaches does. Three modes (legacy, config, config-killswitch), two scenarios (cold, warm with 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.py diffs two runs and fails on invalid, duplicate, missing, or mislabeled rows, so it works directly as a regression gate. run-matrix.sh runs the curated matrix. Methodology, determinism contract, sample numbers, and known limitations are in Tests/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 in HTTPClient, a disk-root override in DirectoryHelper (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.sh via mafdet, or --api-key/SDK_CONFIG_BENCHMARK_API_KEY for direct binary runs).

App-launch tier. On top of the CLI, SDKConfigBenchmarkApp (own Tuist project under Tests/TestingApps/) measures what the CLI can't: a real iOS app linking the stock RevenueCat/RevenueCatUI products (built Release) that calls Purchases.configure and times configure → first customer info → offerings → paywall content on screen. The headline percentile is configure + 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; cold wipes SDK state per launch, warm retains it), and run-app-launch.sh builds the two SDK variants by rewriting SWIFT_ACTIVE_COMPILATION_CONDITIONS in Local.xcconfig (which Package.swift feeds 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 to RemoteConfigStrings by unit tests, plus config_persisted / last_blob_stored phase 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 are compare.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 SDKConfigBenchmark

Full 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.jsonl

Against the real backend (stress-test project by default; key resolved via mafdet):

TRANSPORT=live bash Tests/Benchmarks/SDKConfigBenchmark/run-matrix.sh > live.jsonl
PROJECT_ID=<other-project> TRANSPORT=live bash Tests/Benchmarks/SDKConfigBenchmark/run-matrix.sh > other.jsonl

Regression comparison between two branches (nonzero exit on invalid/duplicate/missing rows):

python3 Tests/Benchmarks/SDKConfigBenchmark/compare.py baseline.jsonl candidate.jsonl

Single configuration:

.build/sdk-config-benchmark-derived-data/Build/Products/Release/SDKConfigBenchmark \
  --mode config --scenario cold --profile lte --loss-percent 20 \
  --iterations 25 --warmup-iterations 3 --paywalls 50 --workflows 100

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.jsonl

Pass DESTINATION="platform=iOS,id=<udid>" for a physical device, ITERATIONS=25 for real numbers. To poke at the app from Xcode: TUIST_BENCH_API_KEY=<key> tuist generate SDKConfigBenchmarkApp injects the key into the (gitignored) scheme.

Unit tests (86):

xcodebuild -workspace RevenueCat-Tuist.xcworkspace -scheme SDKConfigBenchmarkTests \
  -destination platform=macOS test

Every 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

  • PR: this PR
  • Branch: facu/sdk-config-benchmark-v2
  • Author / human owner: facumenzella
  • Agent(s): Claude Code (Fable 5), Codex (review loop via codex-companion)
  • Session source: current conversation
  • Generated: 2026-07-09 (v3: blob registration, config-path phases, Release + impression + runtime-variant-proof review fixes)
  • Context document version: 3

Goal

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/config calls (an endpoint that doesn't exist on main) with real manager-level SDK flows.

Important Follow-up Prompts

  • "We should have it hardcoded to https://app.revenuecat.com/projects/5f07e7e3" then "I want this to hit the backend" — added the live transport against production instead of captured fixtures.
  • "Once you're done and you both agree, let's make sure this is the best regression test suite, and that it outputs real useful numbers for comparing and monitoring both systems" — framed the review loop's acceptance bar.
  • "but POST /v1/config/app does not get a workflow? we should test the whole path" — led to verifying live inline blob delivery end to end after workflows were published in the project.
  • "it should be cool to be able to swap the target project" — added PROJECT_ID support.
  • "Do you think there's a way to measure this with a real app?" then "I think we should build it around PaywallsTester, and run it only on CI / locally" and "we should mimic a real world scenario like configuring the SDK first" — added the app-launch tier (built as a sibling of PaywallsTester rather than inside it, to keep that project's side effects out).
  • "make the cli and the app register the #of blobs, if they are inline or not" and "the goal should be to also measure the blob size limit. Right now if it's smaller than 3MB it should be inlined" — added blob registration with size extremes to both tiers.
  • "we're only measuring the offerings path. Why can't we do both?" — added config-path phases (config_persisted, last_blob_stored) to the app tier via the SDK's log stream.
  • "the goal is to be able to run the app N times (maybe using baguette cli or any other automation) so we can measure it like we do with the CLI" — stated in the description, docs, and artifacts.

Agent Contribution

  • All code in Tests/Benchmarks/SDKConfigBenchmark/, Projects/SDKConfigBenchmark/, and the four gated SDK hooks; 65 unit tests; matrix runner; compare script; methodology doc.
  • Ran the full simulated matrix (18 rows) and live matrices against production; verified cross-process determinism empirically.
  • Drove a 6-pass adversarial review loop (Codex reviewer + adversarial reviewer + a 4-agent simplify pass), applying 16 findings and rejecting 3 with reasons.
  • App-launch tier: all code in Tests/TestingApps/SDKConfigBenchmarkApp/ and Projects/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

  • Decision: hit the real backend in live mode rather than capture fixtures (agent had recommended fixture capture; human overrode).
  • Decision: initially hardcode the stress-test project's public keys (human's explicit instruction, flagged by agent and reviewer); later reversed by the human — keys removed from source, branch history rewritten and force-pushed to scrub the literals, and resolution moved to run time (mafdet / env var).
  • Decision: run the review loop to convergence ("until you both agree").

Key Implementation Decisions

  • Decision: compile Sources/** into the benchmark target and drive OfferingsManager.updateOfferingsCache + RemoteConfigManager.refreshRemoteConfig directly, in production enqueue order.
    • Rationale: measure shipping code, not an imitation; offerings(appUserID:) adds a main-actor hop production's launch path doesn't have.
    • Rejected: hand-rolled API calls (the prior harness's approach).
  • Decision: derive each simulated request's network plan from a stable key (seed, iteration, URL, attempt) instead of a shared RNG.
    • Rationale: request arrival order is scheduler-dependent; order-dependent sampling made same-seed runs disagree across processes.
  • Decision: strict per-iteration warm validation (offerings all-304, config all-204 with full prefetched_blobs proof, kill-switch all-4xx, zero blob re-downloads) with nonzero exits on violation.
    • Rationale: a warm row silently measuring cold behavior is worse than no row.
  • Decision: per-process disk sandbox via a gated DirectoryHelper override, with legacy Documents migrations disabled while sandboxed.
    • Rationale: container directories ignore $HOME; without this, concurrent runs corrupted each other (observed) and migrations could move/delete real ~/Documents/RevenueCat files.
  • Decision (app tier): link the stock RevenueCat/RevenueCatUI products and switch variants via the ENABLE_REMOTE_CONFIG compile condition in Local.xcconfig, with a build-log check (-DENABLE_REMOTE_CONFIG present/absent) refusing mislabeled rows.
    • Rationale: measure the shipping binary; the SPM manifest caches evaluations and the xcconfig is not part of the cache key, so an unverified switch could silently build the wrong variant. Linking the benchmark Core into the app is impossible anyway (duplicate @objc(RC...) classes).
  • Decision (app tier): iterate via XCUITest terminate/relaunch, with the app exposing its sample through an accessibility element; tests skip (not fail) without BENCH_API_KEY/BENCH_MODE_LABEL so bare scheme runs can never emit mislabeled rows.
  • Decision (app tier): headline percentile is the SDK's content-appeared event (paywall_impression / workflows_step_started, via the @_spi(Internal) EventsListener debug API), applied from an adversarial-review finding that the wrapper's onAppear can precede visible content.
    • Rejected: keeping wrapper onAppear as the headline (kept as a secondary paywall_appeared phase).
  • Decision (app tier): observe the config path (persist mark, per-blob inline/downloaded events with byte counts) through Purchases.verboseLogHandler, with the parser pinned to RemoteConfigStrings by unit tests so log-copy changes fail tests instead of silently reporting zeros.
    • Rationale: the app links the stock SDK; logs and the events SPI are the only hook-free observables. Logging overhead sits inside the measured window but is identical across variants (documented).
  • Decision: UITests run under Release (review finding: Debug frameworks misrepresent shipping numbers), with the script refusing rows from non-Release compiles.
  • Decision: runtime variant proof via 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 core
    • Symbols: BenchmarkRunner, BenchmarkSDKStack, SimulatedTransportURLProtocol (+Passthrough/Plans), FixtureServer, BenchmarkPayloadFactory, RCContainerEncoder, BenchmarkMetrics, BenchmarkCommand
    • Review relevance: measurement semantics live in BenchmarkRunner.launch/collectEvents and validateWarmMeasurement.
  • Sources/Networking/HTTPClient/HTTPClient.swift, Sources/Caching/DirectoryHelper.swift, Sources/Caching/DeviceCache.swift, Sources/Networking/HTTPClient/ETagManager.swift — gated hooks only
    • Review relevance: confirm every hook is inside #if SDK_CONFIG_BENCHMARK and 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 tier
    • Symbols: SDKConfigBenchmarkApp/LaunchModel (measuring app), LaunchSample/LaunchClock, AppLaunchBenchmarkUITests (relaunch loop), AppLaunchMetrics (row aggregation, unit-tested), run-app-launch.sh
    • Review relevance: phase semantics in LaunchModel, variant-verification and xcconfig restore in the script.
  • Projects/SDKConfigBenchmarkApp/Project.swift — app + UITest targets; Tuist/ProjectDescriptionHelpers/Environment.swiftTUIST_BENCH_API_KEY scheme injection.

Dependencies / Config / Migrations

  • New Tuist project with SDK_CONFIG_BENCHMARK + ENABLE_REMOTE_CONFIG compilation conditions; no SPM/CocoaPods changes; no public API changes.

Validation

  • Commands run:
    • 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)
    • Live CLI config/cold run: blobs_inline_mean 2, blobs_downloaded_mean 0, max_inline_blob_bytes 70,774 (khepri inlining confirmed); simulated run attributes the mirror image (all downloaded)
    • Live app config/cold run (pre-Release-fix): config_persisted_ms_mean ~1219, same 2 inline blobs / 102KB as the CLI
    • xcodebuild ... -scheme SDKConfigBenchmark -configuration Release build: succeeds
    • swift build (plain SPM): succeeds, hooks inert
    • swiftlint: clean
    • run-matrix.sh (simulated, 18 rows) and TRANSPORT=live (4 rows): zero errors
    • Cross-process same-seed runs: loss-free rows byte-identical in counts/bytes
    • run-app-launch.sh (both variants, simulator, live network): exit 0, 4 rows, zero errors, Local.xcconfig restored byte-identically, flag verification ran on real SDK compiles
  • Manual verification: live runs against project 5f07e7e3 confirmed real 304/204 revalidation and inline blob persistence (warm prefetched_blobs proof); app tier verified rendering the project's paywall on simulator.
  • CI: Not run (branch pushed with this PR).

Validation Gaps

  • No CI lane runs the benchmark automatically yet; no checked-in baseline or regression threshold.
  • The live CDN blob-download path hasn't executed yet (khepri inlines the currently published blobs); engages automatically once more workflows are published in the project.
  • App tier: not yet run on a physical device (script supports DESTINATION); app rows don't carry sdk_commit yet. 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 designed BYPASS_SIMULATED_STORE_RELEASE_CHECK opt-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 in Package.swift, removed on exit).
  • Review loop: ran to the non-convergence guard (each pass kept finding narrower validity hardenings). Applied across passes: Debug config, wrapper-vs-content timing, cached-variant verification, Release/Test-Store mismatch, config-outcome gating (failed refresh cannot pass as a config row, in both tiers), warm priming validation + strict warm 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.
  • The 3MB inline budget itself is not yet exercised live: the stress project's published blobs are all far below it. Publishing a >3MB workflow would let min_downloaded_blob_bytes pin the boundary from above.
  • CLI tier absolute numbers are macOS CPU; the app tier covers device timings once run on hardware.

Review Focus

  • Are the four gated SDK hooks provably inert without SDK_CONFIG_BENCHMARK?
  • Does BenchmarkRunner.launch faithfully mirror Purchases.updateAllCaches ordering?
  • Is the warm-validation contract (304/204/blob-proof) the right definition of "warm"?
  • App tier: is the Local.xcconfig rewrite + build-log flag check a sound variant switch, and is the xcconfig backup/restore safe for developers' local files?

Risks / Reviewer Notes

  • Risk: the loss model approximates TCP retransmission, not real packet loss (documented; simulated rows are for A/B comparison, not absolute claims).
  • Risk: live cold runs create ~25 subscriber rows per run in the stress project (namespaced benchmark-user-<uuid>-N).
  • Rejected findings during the review loop: two claimed compile errors contradicted by passing builds; the hardcoded-keys objection (initially a human decision; later addressed by removing keys from source and history).

facumenzella and others added 18 commits July 9, 2026 09:32
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
--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
@RevenueCat-Danger-Bot

RevenueCat-Danger-Bot commented Jul 9, 2026

Copy link
Copy Markdown
1 Warning
⚠️ This PR increases the size of the repo by more than 100.00 KB (increased by 244.93 KB).

Generated by 🚫 Danger

@facumenzella
facumenzella force-pushed the facu/sdk-config-benchmark-v2 branch from f6d810f to 9597957 Compare July 9, 2026 08:03
facumenzella and others added 7 commits July 9, 2026 10:19
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
facumenzella and others added 8 commits July 9, 2026 12:57
…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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants