Integrate an SDK · iOS
iOS SDK
Swift package with no dependencies. It writes capsule spec v1 with sdk.name: bugcapsule-ios, runtime.name: ios, and replays through an XCUITest the CLI launches on a simulator.
- Version
- 0.3.0 (
sdkVersioninSources/BugCapsule/Runtime/Session.swift) - Platforms
- iOS 15+ (macOS 12+ only for the package's unit tests)
- Language
- Swift 6 language mode, strict concurrency, swift-tools 6.0
- Products
BugCapsule(app target),BugCapsuleXCTest(UI test target)- Links
- zlib (gzip); XCTest for the test product
1. Add the package
In Xcode: File → Add Package Dependencies… with the repository URL. Add BugCapsule to the app target and BugCapsuleXCTest to the UI test target. In a Package.swift:
.package(url: "https://github.com/<org>/bug_capsule_ios_sdk.git", from: "0.3.0"),
// app target
.product(name: "BugCapsule", package: "bug_capsule_ios_sdk"),
// UI test target
.product(name: "BugCapsuleXCTest", package: "bug_capsule_ios_sdk"),
2. Start it first thing
import BugCapsule
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BugCapsule.start(
BugCapsuleConfig(
mode: .qa, // default: .debug in DEBUG builds, .production otherwise
backend: BugCapsuleBackendConfig(url: "https://api.bugcapsule.com",
ingestKey: "bci_..."), // never the bcs_ secret key
redactKeys: ["loyaltyId"]),
integrations: [
BugCapsuleURLSession(rules: [
EndpointRule("POST", "/api/checkout", ignoreBodyFields: ["timestamp", "nonce"]),
]),
BugCapsuleUserDefaults(keys: ["cart_cache"], prefixes: ["settings."]),
BugCapsuleNavigation.shared,
BugCapsuleStateIntegration.shared,
])
return true
}
import BugCapsule
import SwiftUI
@main
struct ShopApp: App {
init() {
// App.init runs before any view reads UserDefaults.
BugCapsule.start(
BugCapsuleConfig(mode: .qa,
backend: BugCapsuleBackendConfig(url: "https://api.bugcapsule.com",
ingestKey: "bci_...")),
integrations: [BugCapsuleURLSession(), BugCapsuleUserDefaults(prefixes: ["shop."]),
BugCapsuleNavigation.shared, BugCapsuleStateIntegration.shared])
}
var body: some Scene { WindowGroup { RootView() } }
}
start must run before app code reads UserDefaults: in replay it fetches the capsule and restores the checkpoint synchronously there. It is @MainActor; a second call is ignored with a warning; if storage or setup fails the app runs without capture. Status: BugCapsule.isStarted, isRecording, isReplaying, mode, session.
Choosing the mode
Resolution order: config.mode → Info.plist key BugCapsuleMode → environment BUGCAPSULE_MODE → .debug under #if DEBUG, else .production. Only sampling depends on the mode: debug and QA always record, production records with probability min(productionSampleRate, remote sampleRate).
3. Instrument networking
let config = URLSessionConfiguration.default
BugCapsule.instrument(config) // inserts BugCapsuleURLProtocol first
let session = URLSession(configuration: config)
// Alamofire: same configuration, Session(configuration: config)
// URLSession.shared: BugCapsule.instrumentSharedSession() (URLProtocol.registerClass, opt-in)
instrument alone records nothing. The protocol only activates when a BugCapsuleURLSession instance is in start(integrations:). Add it even if you use the defaults: BugCapsuleURLSession().
BugCapsuleURLSession(…) | Default | Notes |
|---|---|---|
rules | [] | EndpointRule(method, path, ignoreQuery:, ignoreBodyFields:, matchBodyFields:); :name segments, trailing * |
ignoreQuery | _ ts timestamp nonce cachebust | |
ignoreBodyFields | [] | |
maxBinaryBytes, maxKeyBodyBytes | 64 KiB | |
maxBodyWait | 30 s | |
exclude | nil | (URLRequest) -> Bool: record only method, URL, status, timing |
Redirects are followed inside the SDK protocol (the final response is recorded and served; app redirect delegates are not consulted). httpBodyStream bodies are keyed without a body hash and not recorded. WebSocket and stream tasks are not captured.
4. Name actions and track text
button.addAction(BugCapsule.uiAction("cart.checkout") { [weak self] in self?.checkout() },
for: .touchUpInside)
BugCapsule.trackText("cart.coupon", field: couponField) // isSecureTextEntry is always redacted
BugCapsule.observe(navigationController) // route events (set your own delegate first)
Button("Checkout", action: BugCapsule.action("cart.checkout") { model.checkout() })
TextField("Coupon", text: BugCapsule.trackedText("cart.coupon", $model.coupon))
SecureField("PIN", text: BugCapsule.trackedText("login.pin", $pin, sensitive: true))
CartView().bugCapsuleRoute("/cart")
Semantic replay invokes these actions by id and restores tracked text by id. Lower-level API: action(_:payloadHandler:), registerAction(_:handler:), recordAction(_:payload:), trackText(_:setter:) + textChanged(_:value:sensitive:), and View.bugCapsuleAction(_:perform:) (registers a handler only; pair it with recordAction).
iOS does not replay touches. input.pointer.replay is unsupported: XCUITest taps can't be timed to recorded milliseconds. Touches are only captured, and only if you opt in with BugCapsuleWindow or BugCapsule.recordTouches(_:in:). A capsule with pointer input and no semantic action gets the material pointer.unsupported divergence. Name every action that matters.
5. Assert what "broken" means
BugCapsule.check("cart_total_non_negative", total >= 0, "total \(total)")
BugCapsule.fail("checkout_stuck", "spinner after 10s")
BugCapsule.report(error: error) // errors the app caught (honours config.ignoreError)
BugCapsule.report(exception: exception)
BugCapsule.log("legacy cart migrated", level: "warn")
A failure writes a capsule automatically after a 300 ms settle delay, at most 3 per session, one per signature, and queues it for upload.
Failure-capture boundaries
| Captured | Not captured (never claimed) |
|---|---|
check, fail, report(error:), report(exception:); uncaught NSExceptions via a chained NSSetUncaughtExceptionHandler (previous handler always called; capsule written synchronously, uploaded next launch, carries crash.persisted) |
Swift traps (fatalError, precondition, force unwrap of nil, index out of range, overflow, try!), POSIX signals, C++ exceptions, OOM/jetsam, watchdog kills, force-quit. Call check or report(error:) before code that would trap. |
Requests in flight when the crash capsule is written are recorded with "pending": true; replay holds them open.
6. State, storage and determinism
// App state in checkpoints, restored in replay. Register before the replay's first input.
// (pattern from Examples/Demo/BugCapsuleDemo/AppDelegate.swift)
BugCapsule.registerState(
"app.session",
snapshot: { [unowned self] in
["lastOrderId": lastOrderId.map { .string($0) } ?? .null,
"appliedCoupon": appliedCoupon.map { .string($0) } ?? .null]
},
restore: { [unowned self] json in
lastOrderId = json["lastOrderId"]?.stringValue
appliedCoupon = json["appliedCoupon"]?.stringValue
})
BugCapsule.stateChanged("app.session", value: appliedCoupon.map { .string($0) }) // diagnostic `state` event
// UserDefaults: allowlist only (nothing else is read)
BugCapsuleUserDefaults(keys: ["cart_cache"], prefixes: ["settings."],
denyPrefixes: ["bugcapsule.", "com.apple.", "Apple", "NS"], suiteName: nil)
// Navigation restore from periodic checkpoints needs your restorer (yourRouter is app code)
BugCapsuleNavigation.shared.restorer = { route, backStack in try yourRouter.reset(to: backStack + [route]) }
BugCapsuleNavigation.shared.setRoute("/cart") // explicit routes
// Determinism: only these are virtualized
let now = BugCapsule.now() // recorded wall clock in replay
let n = BugCapsule.random.nextInt(100) // seeded per session
UserDefaults types: bool, int, double, string, stringArray, data (base64), date (epoch ms). Keychain, Core Data, files and SQLite are not captured; write a BugCapsuleIntegration (name, snapshot, restore, describeEligibility).
7. Add the replay test
import BugCapsuleXCTest
final class BugCapsuleReplayTests: BugCapsuleReplayTestCase {
@MainActor func testReplay() throws { try runBugCapsuleReplay() }
}
runBugCapsuleReplay(timeout: 900) throws XCTSkip when the CLI didn't start the test, so it is safe in a normal test run. Override makeApplication() to customize the XCUIApplication.
8. Embed the git SHA
<key>BugCapsuleGitSHA</key><string>$(BUGCAPSULE_GIT_SHA)</string>
<key>BugCapsuleGitDirty</key><string>$(BUGCAPSULE_GIT_DIRTY)</string>
The CLI passes both build settings when it builds. For QA and CI builds pass BUGCAPSULE_GIT_SHA=$(git rev-parse HEAD) to xcodebuild. Unexpanded $(…) values count as unset; without a SHA capsules are partial (build.gitSha) and bugcapsule run can't pin the commit.
Privacy manifest. The package ships no PrivacyInfo.xcprivacy. It uses UserDefaults, file timestamps and ProcessInfo.systemUptime, which Apple lists as required-reason APIs, so declare them in your app's privacy manifest before App Store submission.
Configuration reference
BugCapsuleConfig(…): every property is a var with a default.
| Property | Default | Notes |
|---|---|---|
appVersion, appBuild, appId, appName | bundle values | CFBundleShortVersionString, CFBundleVersion, bundle id, CFBundleName |
flavor | nil | |
mode | nil → see above | .debug, .qa, .production |
enabled | true | Local master switch |
backend | nil | BugCapsuleBackendConfig(url:ingestKey:) |
gitSha, gitDirty | Info.plist | BugCapsuleGitSHA, BugCapsuleGitDirty |
stateSchemaVersion | 1 | Mismatch with a periodic checkpoint → INCOMPATIBLE |
checkpointInterval / window / maxEvents | 30 s / 300 s / 20 000 | |
productionSampleRate | 0.1 | |
autoCaptureOnFailure / maxCapsulesPerSession | true / 3 | |
captureNetworkBodies / maxBodyBytes | true / 256 KiB | |
redactKeys, redactHeaders, redactPatterns | [] | redactPatterns: [NSRegularExpression] |
beforeRecord | nil | (type, data) throws -> JSONObject?; nil or throw drops the event |
beforeCheckpoint | nil | (integration, data) throws -> JSONValue? |
outboxLimits | 10 / 10 MiB / 7 days | OutboxLimits(maxCount:maxBytes:maxAge:) |
maxStaleConfig | 7 days | |
ignoreError | nil | (Error) -> Bool |
labels | [:] | Merged with the capture note |
replayURL | nil | Tests only; honoured in DEBUG builds |
Replay with the CLI
{
"runtime": "ios",
"ios": {
"project": "Shop.xcodeproj",
"scheme": "Shop",
"configuration": "Debug",
"bundleId": "com.example.shop",
"replayTest": "ShopUITests/BugCapsuleReplayTests/testReplay",
"simulator": "iPhone 18 Pro",
"startupSeconds": 120
},
"timeouts": { "buildSeconds": 1200, "attemptSeconds": 180 }
}
bugcapsule run <id>
└─ xcodebuild build-for-testing (once; BUGCAPSULE_GIT_SHA / BUGCAPSULE_GIT_DIRTY build settings)
└─ per attempt:
simctl uninstall <bundleId> (fresh data container)
TEST_RUNNER_BUGCAPSULE_REPLAY_URL=http://127.0.0.1:<port>/a/<token> \
xcodebuild test-without-building -only-testing:<UITestTarget>/BugCapsuleReplayTests/testReplay
└─ BugCapsuleReplayTestCase launches the app with BUGCAPSULE_REPLAY_URL + -BugCapsuleReplay
└─ BugCapsule.start(): GET settings + capsule → restore UserDefaults/state
→ at the first visible window: dispatch actions/text/lifecycle at recorded times,
serve URLSession fixtures, watch for the failure
→ POST /result (+ BUGCAPSULE_RESULT:: line + accessibility element)
- Production protection. The replay path compiles only under
DEBUGand arms only with the-BugCapsuleReplaylaunch argument andBUGCAPSULE_REPLAY_URL(both set by the UI test), or withconfig.replayURLin a DEBUG build. Release builds ignore all of them. There is no URL scheme, deep link, push or file trigger. - Network. Fixtures by key + occurrence after the recorded
durationMs; recorded errors raised asURLError; unrecorded requests fail withURLError(.cannotConnectToHost)+network.unrecordedunless--lenient-network. - Lifecycle is
partial:UIApplicationnotifications are posted, the real state doesn't change.hiddenhas no iOS equivalent (lifecycle.unsupported). - Simulator only. Capture works on any device; replay on a physical device would need signing,
--lan, an ATS exception anddevicectl, which the launcher doesn't implement. - The
configurationmust defineDEBUG, or the app ignores replay. Temporary build settings for one run:BUGCAPSULE_IOS_BUILD_SETTINGS='KEY=VALUE'.
Storage and uploads
- Files in
Application Support/bugcapsule(excluded from backup):captures/(newest 20),outbox/,remote_config.json. Atomic writes; corrupt files and disk-full drop the capsule, never the app. - Uploads run only while the app is running (launch, becoming active, after a capture). No background task or background
URLSession. - Kill switch: recording stops, buffer dropped, queued and saved capsules deleted, persists across launches. A rejected ingest key stops capture for the session.
Demo app
Examples/Demo: UIKit home, SwiftUI cart, an in-process FakeShopServer (a URLProtocol below the SDK's). Generated from project.yml with XcodeGen; the generated project is checked in.
| Bug | Capture path |
|---|---|
Coupon SAVE100 makes small carts negative | check("cart_total_non_negative"); fix toggle FIX_NEGATIVE_TOTAL |
Recommendations before profile (~1 in 4) raise an NSException | Uncaught NSException, written synchronously, uploaded next launch; profile request recorded pending |
Legacy v0.9 cart_cache fails to decode in a later session | report(error:); reproduces only because the checkpoint restores UserDefaults |
# Capture by hand (key and URL on the command line, never committed)
cd Examples/Demo
xcodebuild test -project BugCapsuleDemo.xcodeproj -scheme BugCapsuleDemo \
-destination 'id=<simulator udid>' -collect-test-diagnostics never \
BUGCAPSULE_BACKEND_URL=https://api.bugcapsule.com BUGCAPSULE_INGEST_KEY=bci_... \
-only-testing:BugCapsuleDemoUITests/CaptureFlowTests/testCaptureNegativeTotal
# Verify the isolated fix without touching tracked sources
BUGCAPSULE_IOS_BUILD_SETTINGS='DEMO_FIXES=FIX_NEGATIVE_TOTAL' bugcapsule verify <id> -p Examples/Demo
BUGCAPSULE_BACKEND_URL and BUGCAPSULE_INGEST_KEY are read by the demo's own DemoConfig, not by the SDK.
Limitations
- No pointer replay (above). No consent, user-identify or breadcrumb API; delay
startonly if you accept that replay needs it early, and uselabels,log,recordAction. - Only
BugCapsule.now()andBugCapsule.randomare deterministic;Date()andInt.randomare not. - Only allowlisted UserDefaults keys of the listed types are captured.
- Navigation restores from periodic checkpoints only through your
restorer(elserestore.navigation).