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 (sdkVersion in Sources/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(…)DefaultNotes
rules[]EndpointRule(method, path, ignoreQuery:, ignoreBodyFields:, matchBodyFields:); :name segments, trailing *
ignoreQuery_ ts timestamp nonce cachebust
ignoreBodyFields[]
maxBinaryBytes, maxKeyBodyBytes64 KiB
maxBodyWait30 s
excludenil(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

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

i

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.

PropertyDefaultNotes
appVersion, appBuild, appId, appNamebundle valuesCFBundleShortVersionString, CFBundleVersion, bundle id, CFBundleName
flavornil
modenil → see above.debug, .qa, .production
enabledtrueLocal master switch
backendnilBugCapsuleBackendConfig(url:ingestKey:)
gitSha, gitDirtyInfo.plistBugCapsuleGitSHA, BugCapsuleGitDirty
stateSchemaVersion1Mismatch with a periodic checkpoint → INCOMPATIBLE
checkpointInterval / window / maxEvents30 s / 300 s / 20 000
productionSampleRate0.1
autoCaptureOnFailure / maxCapsulesPerSessiontrue / 3
captureNetworkBodies / maxBodyBytestrue / 256 KiB
redactKeys, redactHeaders, redactPatterns[]redactPatterns: [NSRegularExpression]
beforeRecordnil(type, data) throws -> JSONObject?; nil or throw drops the event
beforeCheckpointnil(integration, data) throws -> JSONValue?
outboxLimits10 / 10 MiB / 7 daysOutboxLimits(maxCount:maxBytes:maxAge:)
maxStaleConfig7 days
ignoreErrornil(Error) -> Bool
labels[:]Merged with the capture note
replayURLnilTests 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)

Storage and uploads

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.

BugCapture path
Coupon SAVE100 makes small carts negativecheck("cart_total_non_negative"); fix toggle FIX_NEGATIVE_TOTAL
Recommendations before profile (~1 in 4) raise an NSExceptionUncaught NSException, written synchronously, uploaded next launch; profile request recorded pending
Legacy v0.9 cart_cache fails to decode in a later sessionreport(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