diff --git a/.github/workflows/ios-client.yml b/.github/workflows/ios-client.yml new file mode 100644 index 0000000..b8fab6d --- /dev/null +++ b/.github/workflows/ios-client.yml @@ -0,0 +1,89 @@ +# Builds and tests the iOS SDK (clients/ios) when it changes: the unit tests on macOS and on an iOS +# simulator, two checks that keep the SDK safe to embed, and the sample app, whose Objective-C file +# compile-checks the SDK's generated header. +name: iOS client + +on: + pull_request: + paths: + - 'clients/ios/**' + - '.github/workflows/ios-client.yml' + push: + branches: [main, sudoquery-monorepo-impl] + paths: + - 'clients/ios/**' + - '.github/workflows/ios-client.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ios-client-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: macos-15 + timeout-minutes: 45 + defaults: + run: + working-directory: clients/ios + steps: + - uses: actions/checkout@v4 + + - name: Toolchain + run: xcodebuild -version && swift --version + + - name: Test on macOS + run: swift test -Xswiftc -warnings-as-errors + + - name: The SDK never traps + run: | + if grep -rnE '(^|[^[:alnum:]_])(try!|as!|fatalError\(|precondition(Failure)?\()' Sources; then + echo "::error::SDK code must not trap inside the app; handle the failure instead" + exit 1 + fi + + # iOS 13 and 14 apps (Objective-C-only ones in particular) may not ship the Swift + # Concurrency back-deployment library, so the SDK uses GCD and locks only. + - name: The SDK needs no Swift Concurrency runtime + shell: bash + run: | + symbols=$(find .build/debug/SudoQuery.build -name '*.o' -exec nm -u {} +) + if ! grep -q '_swift_retain' <<<"$symbols"; then + echo "::error::no SudoQuery object files were read" + exit 1 + fi + if grep -E '_swift_task_|_swift_continuation_|_swift_job_' <<<"$symbols"; then + echo "::error::SudoQuery references the Swift Concurrency runtime" + exit 1 + fi + + - name: Create a throwaway iPhone simulator + id: sim + run: | + runtimes=$(xcrun simctl list runtimes --json) + runtime=$(jq -r '[.runtimes[] | select(.platform == "iOS" and .isAvailable)] | sort_by(.version | split(".") | map(tonumber)) | last | .identifier' <<<"$runtimes") + device=$(jq -r --arg runtime "$runtime" '.runtimes[] | select(.identifier == $runtime) | [.supportedDeviceTypes[] | select(.productFamily == "iPhone")] | first | .identifier' <<<"$runtimes") + echo "Using $device on $runtime" + echo "udid=$(xcrun simctl create sudoquery-ci "$device" "$runtime")" >> "$GITHUB_OUTPUT" + + - name: Test on the iOS simulator + run: | + xcodebuild test -scheme SudoQuery -destination "id=${{ steps.sim.outputs.udid }}" \ + -derivedDataPath .build/xcode -resultBundlePath .build/xcode/ios-tests.xcresult \ + SWIFT_TREAT_WARNINGS_AS_ERRORS=YES + total=$(xcrun xcresulttool get test-results summary --path .build/xcode/ios-tests.xcresult --compact | jq .totalTestCount) + echo "iOS tests run: $total" + [ "$total" -gt 0 ] + + - name: Build the sample app (Swift and Objective-C) + run: | + xcodebuild build -project Sample/SudoQuerySample.xcodeproj -scheme SudoQuerySample \ + -destination 'generic/platform=iOS Simulator' -derivedDataPath .build/sample \ + CODE_SIGNING_ALLOWED=NO + + - name: Delete the simulator + if: always() && steps.sim.outputs.udid != '' + run: xcrun simctl delete "${{ steps.sim.outputs.udid }}" diff --git a/clients/ios/.gitignore b/clients/ios/.gitignore new file mode 100644 index 0000000..1332167 --- /dev/null +++ b/clients/ios/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +xcuserdata/ +DerivedData/ +*.xcresult +Package.resolved diff --git a/clients/ios/Package.swift b/clients/ios/Package.swift new file mode 100644 index 0000000..d7f75a9 --- /dev/null +++ b/clients/ios/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.0 +// The SudoQuery iOS SDK: stores events on disk, batches them and delivers them to the +// collector's /v1/events/batch route. +import PackageDescription + +let package = Package( + name: "SudoQuery", + // The SDK is for iOS apps. macOS is listed only so `swift test` runs on a Mac. + platforms: [.iOS(.v13), .macOS(.v10_15)], + products: [ + .library(name: "SudoQuery", targets: ["SudoQuery"]), + ], + targets: [ + .target( + name: "SudoQuery", + resources: [.copy("Resources/PrivacyInfo.xcprivacy")] + ), + .testTarget( + name: "SudoQueryTests", + dependencies: ["SudoQuery"], + resources: [.copy("Resources/golden")] + ), + ] +) diff --git a/clients/ios/README.md b/clients/ios/README.md new file mode 100644 index 0000000..7a26d17 --- /dev/null +++ b/clients/ios/README.md @@ -0,0 +1,294 @@ +# SudoQuery iOS SDK + +The iOS client for the SudoQuery events collector: the Swift counterpart of +[`clients/typescript`](../typescript) and `clients/android`, usable from Swift and Objective-C. +Track events from anywhere in your app; the SDK batches them, keeps them on disk until they are +delivered, and posts them to the collector's `POST /v1/events/batch` route. + +- **Durable:** events are written to disk as they are tracked and survive app restarts and crashes. +- **Batched:** one request per batch, sent when a batch fills up, on a timer, and when the app goes to the background (inside a background task, so the upload can finish). +- **Safe to call anywhere:** every method is thread-safe, returns at once and never throws or traps. Disk and network work runs on the SDK's own background queues, never on the main thread. +- **Small:** one Swift package with no dependencies. It needs no Swift Concurrency runtime, so iOS 13 and 14 apps, Objective-C-only ones included, don't need to ship one. + +Its network, batching and file layers are ported from hyper-sdk-ios (`HPJPLogPusher`, +`HPJPLogsManager`, Airborne's `AJPNetworkClient` and `AJPFileUtil`), with that SDK's bugs fixed: +any 2xx counts as delivered; 400, 413 and 422 drop the batch instead of losing every batch on any +error; other failures back off exponentially instead of retrying back to back; a transport error is +never turned into an empty `URLResponse()`; batches are visible to the uploader only once fully +written; and the queue has size and age limits. + +## Installation + +Requirements: iOS 13 or newer, Xcode 16 or newer (Swift tools 6.0). + +The package lives in this monorepo at `clients/ios` and isn't published yet, so add it as a local +package: + +- **Xcode:** File → Add Package Dependencies… → Add Local… and pick `clients/ios`, then add the + `SudoQuery` library to your app target. +- **Package.swift:** `.package(path: "/clients/ios")` and the product `"SudoQuery"`. + +Keeping a checkout of the repository next to the app (for example as a git submodule) works for +both. A remote dependency (`.package(url:…)` or Xcode's URL field) can't resolve yet: SwiftPM only +reads a `Package.swift` at the root of a repository (see [Publishing](#publishing)). + +## Quick start + +Initialize once, as early as possible: in `application(_:didFinishLaunchingWithOptions:)`, or in +your SwiftUI `App`'s `init`: + +```swift +import SudoQuery + +@main +struct MyApp: App { + init() { + do { + SudoQuery.initialize(try SudoQueryConfig( + orgId: "acme-store-k3x9qa", + workspaceId: "acme-store-app-9t2r4m", + endpoint: "https://collector.example.com/v1/events/batch" + )) + } catch { + print("SudoQuery isn't configured: \(error)") + } + } + + var body: some Scene { + WindowGroup { ContentView() } + } +} +``` + +Then track events from anywhere: + +```swift +SudoQuery.track("button_click", properties: ["button_id": "submit", "page": "/checkout"]) + +SudoQuery.setUser("user_123") // sent as actor_id from now on, and after restarts +SudoQuery.setSuperProperty("plan", "pro") // added to every event +SudoQuery.reset() // on logout: new anonymous id and session, no user +``` + +From Objective-C, enable modules (`CLANG_ENABLE_MODULES`, on by default) and use `SQSudoQuery`: + +```objc +@import SudoQuery; + +SQSudoQueryConfig *config = + [[SQSudoQueryConfig alloc] initWithOrgId:@"acme-store-k3x9qa" + workspaceId:@"acme-store-app-9t2r4m" + endpoint:@"https://collector.example.com/v1/events/batch"]; +NSError *error = nil; +if (![SQSudoQuery initializeWithConfig:config error:&error]) { + NSLog(@"SudoQuery isn't configured: %@", error.localizedDescription); +} +[SQSudoQuery track:@"button_click" properties:@{@"button_id": @"submit"}]; +``` + +`orgId` and `workspaceId` are the server-generated slugs of the organization and the project. The +dashboard's project settings show them as Organization ID and Project ID, and its switcher copies +them as the tenant ID and the workspace ID. They are sent as the `x-org-id` / `x-workspace-id` +headers and as every event's `org_id` / `workspace_id`. + +## Configuration + +`SudoQueryConfig(orgId:workspaceId:endpoint:…)` takes: + +| Parameter (Swift / Obj-C property) | Default | Description | +|---|---|---| +| `endpoint` | required | Full URL of the batch route, e.g. `https://collector.example.com/v1/events/batch`. | +| `token` | none | Sent as `Authorization: Bearer `, for a proxy or gateway in front of the collector. | +| `headers` | none | Extra headers. They can't replace `x-org-id`, `x-workspace-id` or the bearer token. | +| `source` | `"ios"` | The `source` of every event. | +| `sessionId` | managed | Use a fixed session id instead of SDK-managed sessions. | +| `batchSize` | `50` | Events per batch and request (1 to 500). | +| `flushInterval` | `30` s | Send pending events at least this often, in seconds; `0` turns the timer off. | +| `sessionTimeout` | `1800` s | A new session starts after this many seconds without events. | +| `debugLogging` | `false` | Debug logs in the unified log (subsystem `io.juspay.sudoquery`). Warnings and errors are always logged. | + +The initializer throws `SudoQueryConfigError` for a blank id, an endpoint that isn't an http(s) +URL with a host, an out-of-range value, or a header with a line break. From Objective-C, +`initializeWithConfig:error:` returns `NO` with an `NSError` in the `SQSudoQueryConfig.errorDomain` +domain (`io.juspay.sudoquery.config`) and an `SQSudoQueryConfigErrorCode` code. Ids that don't look +like slugs only log a warning. Intervals are seconds (`TimeInterval`), not milliseconds as on Android. + +## API + +| Swift (`SudoQuery`) | Objective-C (`SQSudoQuery`) | Description | +|---|---|---| +| `initialize(_:)` | `+initializeWithConfig:error:` | Starts the SDK. The first call wins; later calls are ignored. | +| `track(_:properties:)` | `+track:`, `+track:properties:` | Records an event. Properties are a dictionary; nested dictionaries, arrays and sets are supported. `Date`s become ISO 8601 strings, `URL`s strings and `UUID`s lowercase strings; NaN and infinite numbers become `null`. | +| `setUser(_:)` / `removeUser()` / `user` | `+setUser:` (nil removes) / `+removeUser` / `user` | The user id sent as `actor_id`. It is kept across app restarts. | +| `setSuperProperty(_:_:)` / `superProperties` / `clearSuperProperties()` | `+setSuperProperty:value:` / `superProperties` / `+clearSuperProperties` | Properties added to every event (the event's own keys win). In memory only; usable before `initialize`. | +| `flush()` | `+flush` | Sends the events tracked so far now instead of waiting for a full batch. | +| `reset()` | `+reset` | For logout: later events get a new anonymous id and session, with no user id and no super properties. | +| `isInitialized` | `isInitialized` | True once `initialize` has started the SDK. | +| `version` | `sdkVersion` | The SDK version (`+version` is taken by `NSObject`). | + +Events tracked before `initialize` are dropped with a warning. The properties are copied when +`track` is called, so changing a dictionary afterwards doesn't change the event; mutating an +`NSMutableDictionary` from another thread while `track` copies it is the caller's race. + +## How events are delivered + +1. `track()` copies the properties and timestamps the event on the calling thread, then hands it + to a background writer queue. The writer adds the ids and session, encodes the event once and + appends it to the open batch file. +2. A batch is closed when it reaches `batchSize` events or 512 KiB, every `flushInterval`, when the + app goes to the background, and on `flush()`. Closed batches wait in an on-disk queue. +3. The uploader posts queued batches oldest first, one per request: + - **Any 2xx:** the batch is delivered and deleted. + - **400, 413, 422:** the collector will never accept the batch. It is dropped and the collector's error is logged. + - **Anything else** (no network, timeouts, 5xx, 429, redirects, 401/403/404): the batch is kept. Uploads stop and retry with exponential backoff from 5 s up to 5 minutes, honouring `Retry-After` (up to an hour). Coming back to the foreground, or going to the background, retries at once. +4. Going to the background starts a background task, so the batch committed then can be uploaded; + the task ends once the uploader has sent what it can or stopped at a failure, or when iOS runs + out of time. Unsent batches wait on disk. + +Delivery is **at least once**. A resend carries the same event ids and the same bytes (unless the +device's time zone changed in between), so the collector's downstream stores can deduplicate it. + +| Limit | Value | +|---|---| +| Single event | 64 KiB encoded; larger events are dropped with a warning | +| Queue on disk | 10 MiB; the oldest batches are dropped beyond it | +| Age | Batches older than 7 days are dropped | +| Events waiting for the writer | 1,000 in memory; more are dropped with a warning | + +Everything is stored under the app's `Library/Application Support/sudoquery/v1/`. Unlike Caches, +iOS never purges it; it is excluded from iCloud and Finder backups, so the anonymous id isn't +restored onto another device. Deleting the app starts over with a new anonymous id. The files use +the `completeUntilFirstUserAuthentication` protection class, so a background flush works while the +device is locked. Before the first unlock after a reboot (a background launch, e.g. for a VoIP push) +nothing can be read or written: events tracked then are dropped with a warning, and the SDK uses a +temporary identity that it never saves over the stored one. Once the device is unlocked it goes on +with the stored anonymous id and user, keeping any `setUser` or `reset` made meanwhile. + +**Sessions:** a session continues while events keep coming, and a new one starts after +`sessionTimeout` without events. This holds across app restarts. + +**App extensions** have their own container, so an extension that initializes the SDK has its own +queue and anonymous id. Extensions get no lifecycle hooks; their events go out on the timer and on +`flush()`. + +## Wire format and changing the event type + +All wire types live in one file, [`Sources/SudoQuery/Event.swift`](Sources/SudoQuery/Event.swift). +`EventV1` mirrors `CollectorEvent` in `crates/events-collector/src/collector_event.rs`, the TS +client's `Event` and the Android SDK's `EventV1`, including the `envelop_version` and `occured_at` +spellings, except that it has `workspace_id` where they have `project_id`. Every key is written out, +nulls included, in the same order: + +```json +{"events":[{"envelop_version":"1.0","id":"3b241101-e2bb-4255-8caf-4136c566a962","name":"button_click", +"org_id":"acme-k3x9qa","workspace_id":"ios-demo1","session_id":"9f1c2d3e-…","anon_id":"6e8bc430-…", +"actor_id":null,"source":"ios","occured_at":"2026-10-14T09:30:12.345Z","properties":{"page":"/checkout"}, +"correlation_id":null,"trace_id":null,"system_properties":null}], +"system_properties":{"geo":null,"timezone":"Asia/Kolkata"}} +``` + +The type the SDK sends is chosen by one line: + +```swift +typealias CanonicalEvent = EventV1 +``` + +To switch envelopes: +1. Add the new type to `Event.swift`, conforming to `WireEvent`: an `init(_ input: EventInput)` that fills it from what `track()` captured, and `json()` with its keys in wire order. +2. Point the alias at it. + +Batching, storage and upload only handle encoded events, so nothing else changes. Events already +queued by an older app version are sent exactly as they were written. If the new type needs data +`EventInput` doesn't carry, add it to `EventInput` and to where `EventWriter` fills it. + +## Differences from the TypeScript and Android clients + +- **Like Android:** `endpoint` is required; events are persisted and retried by status code; the user id persists across restarts and `reset()` exists; sessions end after inactivity; calls before `initialize` are dropped with a warning instead of throwing; properties are always a JSON object. +- **The workspace id** goes out as `x-workspace-id` and `workspace_id`. The TS and Android clients call it `projectId` and send `x-project-id` and `project_id`. +- **`source` defaults to `"ios"`.** +- **Intervals are seconds**, as `TimeInterval`s. +- **Property keys are sorted** when a Swift or Objective-C dictionary (which has no order) is turned into JSON; super properties keep the order they were set in. +- **`Date`, `URL` and `UUID` values** are sent as ISO 8601, absolute and lowercase strings; Android sends `toString()`. +- **Backgrounding** runs the upload inside a background task; Android has no such limit to work around. +- **There is no process check:** on iOS an app's extensions already have separate containers. + +## Privacy + +The package ships a privacy manifest +([`Sources/SudoQuery/Resources/PrivacyInfo.xcprivacy`](Sources/SudoQuery/Resources/PrivacyInfo.xcprivacy)): + +- **No tracking, no tracking domains, and no required-reason APIs.** The SDK uses neither UserDefaults nor file-timestamp, system-boot-time or disk-space APIs. +- **Collected data, all used for analytics and linked to the user:** product interaction (the events you track), a device id (the random `anon_id`), a user id (`actor_id`, after `setUser`), and coarse location (the collector derives the country from each request's IP address). + +For App Store Connect's privacy labels: the SDK stores a random anonymous id and, after +`setUser`, your user id; it sends both with every event, with the properties you track and the +device's time zone. Nothing else is collected automatically. + +## Plain HTTP + +App Transport Security blocks `http://` endpoints. For a collector on your Mac during development, +add `NSAllowsLocalNetworking` under `NSAppTransportSecurity` in the app's Info.plist (the sample app +does); the iOS simulator reaches the Mac at `localhost`. Use HTTPS everywhere else. + +## Development + +From `clients/ios`: + +```sh +swift build -Xswiftc -warnings-as-errors +swift test # unit tests on the Mac (UIKit-only tests are skipped there) +xcodebuild test -scheme SudoQuery -destination 'platform=iOS Simulator,name=' +``` + +The `SudoQuery` package scheme runs the whole suite on the simulator, including the app-lifecycle +tests. The tests use a loopback HTTP server, so they need no network. + +To run against a local collector from the repository root: + +```sh +cargo xtask setup redpanda +docker compose exec redpanda rpk topic create events.acme-k3x9qa.ios-demo1 +COLLECTOR_CONFIG=crates/events-collector/cac.toml SERVER_ADDR=0.0.0.0:3001 cargo run -p event-collector +``` + +- **Through the real pipeline:** + ```sh + (cd clients/ios && SUDOQUERY_LIVE_COLLECTOR_URL=http://localhost:3001/v1/events/batch swift test --filter LiveCollectorTests) + ``` + For the simulator, run the `xcodebuild test` above with + `TEST_RUNNER_SUDOQUERY_LIVE_COLLECTOR_URL=http://localhost:3001/v1/events/batch` and + `-only-testing:SudoQueryTests/LiveCollectorTests`. +- **The sample app** (`Sample/SudoQuerySample.xcodeproj`, iOS 16+) talks to + `http://localhost:3001/v1/events/batch` as org `acme-k3x9qa`, workspace `ios-demo1`; change the + `SUDOQUERY_*` build settings or set them as environment variables. Its buttons mirror the Android + sample, and launch arguments drive it from the command line: + ```sh + xcrun simctl launch io.juspay.sudoquery.sample --sudoquery-smoke-test # 3 events, flushed + xcrun simctl launch io.juspay.sudoquery.sample --sudoquery-objc-smoke # the Objective-C API + xcrun simctl launch io.juspay.sudoquery.sample --sudoquery-background-test # sent once backgrounded + ``` +- **Read what arrived:** + ```sh + docker compose exec redpanda rpk topic consume events.acme-k3x9qa.ios-demo1 -n 10 + ``` +- **Logs:** `xcrun simctl spawn log stream --predicate 'subsystem == "io.juspay.sudoquery"'`. + +CI ([`.github/workflows/ios-client.yml`](../../.github/workflows/ios-client.yml)) runs the tests on +macOS and on an iOS simulator, checks that the SDK has no trapping calls (`try!`, `as!`, +`fatalError`, `precondition`) and references no Swift Concurrency runtime symbols, and builds the +sample, whose `ObjCSmokeTest.m` compile-checks the Objective-C header. iOS 13 to 15 are +compile-checked only; tests run on the simulator runtimes the CI image has. + +## Publishing + +Not set up yet. SwiftPM resolves a package by its git URL and reads `Package.swift` at the root of +that repository, at semver tags. Here the manifest is in `clients/ios`, and the repository's `v*` +tags belong to the npm package (`.github/workflows/release.yml` publishes on them), so publishing +needs either: + +- a mirror repository (e.g. `juspay/sudoquery-ios`) that a release workflow fills from + `git subtree split --prefix=clients/ios` and tags `X.Y.Z`, on `ios-v*` tags here; or +- a root `Package.swift`, which would also need tags that don't collide with npm's. + +Either way the package needs a LICENSE file (the npm package and the Android SDK use ISC), and the +version in [`Sources/SudoQuery/Version.swift`](Sources/SudoQuery/Version.swift) is the one to tag. diff --git a/clients/ios/Sample/Info.plist b/clients/ios/Sample/Info.plist new file mode 100644 index 0000000..2e8a61f --- /dev/null +++ b/clients/ios/Sample/Info.plist @@ -0,0 +1,17 @@ + + + + + SudoQueryEndpoint + $(SUDOQUERY_ENDPOINT) + SudoQueryOrgId + $(SUDOQUERY_ORG_ID) + SudoQueryWorkspaceId + $(SUDOQUERY_WORKSPACE_ID) + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/clients/ios/Sample/SudoQuerySample.xcodeproj/project.pbxproj b/clients/ios/Sample/SudoQuerySample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c084c3a --- /dev/null +++ b/clients/ios/Sample/SudoQuerySample.xcodeproj/project.pbxproj @@ -0,0 +1,277 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 5D0A1000000000000000000B /* SudoQuery in Frameworks */ = {isa = PBXBuildFile; productRef = 5D0A1000000000000000000D /* SudoQuery */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 5D0A10000000000000000005 /* SudoQuerySample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SudoQuerySample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 5D0A10000000000000000006 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 5D0A10000000000000000004 /* SudoQuerySample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SudoQuerySample; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 5D0A10000000000000000009 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5D0A1000000000000000000B /* SudoQuery in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 5D0A10000000000000000002 = { + isa = PBXGroup; + children = ( + 5D0A10000000000000000004 /* SudoQuerySample */, + 5D0A10000000000000000006 /* Info.plist */, + 5D0A10000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + 5D0A10000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + 5D0A10000000000000000005 /* SudoQuerySample.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 5D0A10000000000000000007 /* SudoQuerySample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5D0A1000000000000000000F /* Build configuration list for PBXNativeTarget "SudoQuerySample" */; + buildPhases = ( + 5D0A10000000000000000008 /* Sources */, + 5D0A10000000000000000009 /* Frameworks */, + 5D0A1000000000000000000A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5D0A10000000000000000004 /* SudoQuerySample */, + ); + name = SudoQuerySample; + packageProductDependencies = ( + 5D0A1000000000000000000D /* SudoQuery */, + ); + productName = SudoQuerySample; + productReference = 5D0A10000000000000000005 /* SudoQuerySample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 5D0A10000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 5D0A10000000000000000007 = { + CreatedOnToolsVersion = 26.0; + }; + }; + }; + buildConfigurationList = 5D0A1000000000000000000E /* Build configuration list for PBXProject "SudoQuerySample" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 5D0A10000000000000000002; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 5D0A1000000000000000000C /* XCLocalSwiftPackageReference ".." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 5D0A10000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 5D0A10000000000000000007 /* SudoQuerySample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 5D0A1000000000000000000A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 5D0A10000000000000000008 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 5D0A10000000000000000010 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 5D0A10000000000000000011 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 5D0A10000000000000000012 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "SudoQuery Sample"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.juspay.sudoquery.sample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUDOQUERY_ENDPOINT = "http://localhost:3001/v1/events/batch"; + SUDOQUERY_ORG_ID = "acme-k3x9qa"; + SUDOQUERY_WORKSPACE_ID = "ios-demo1"; + SWIFT_OBJC_BRIDGING_HEADER = "SudoQuerySample/SudoQuerySample-Bridging-Header.h"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 5D0A10000000000000000013 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "SudoQuery Sample"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.juspay.sudoquery.sample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUDOQUERY_ENDPOINT = "http://localhost:3001/v1/events/batch"; + SUDOQUERY_ORG_ID = "acme-k3x9qa"; + SUDOQUERY_WORKSPACE_ID = "ios-demo1"; + SWIFT_OBJC_BRIDGING_HEADER = "SudoQuerySample/SudoQuerySample-Bridging-Header.h"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 5D0A1000000000000000000E /* Build configuration list for PBXProject "SudoQuerySample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5D0A10000000000000000010 /* Debug */, + 5D0A10000000000000000011 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5D0A1000000000000000000F /* Build configuration list for PBXNativeTarget "SudoQuerySample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5D0A10000000000000000012 /* Debug */, + 5D0A10000000000000000013 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 5D0A1000000000000000000C /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ".."; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 5D0A1000000000000000000D /* SudoQuery */ = { + isa = XCSwiftPackageProductDependency; + productName = SudoQuery; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 5D0A10000000000000000001 /* Project object */; +} diff --git a/clients/ios/Sample/SudoQuerySample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/clients/ios/Sample/SudoQuerySample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/clients/ios/Sample/SudoQuerySample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/clients/ios/Sample/SudoQuerySample.xcodeproj/xcshareddata/xcschemes/SudoQuerySample.xcscheme b/clients/ios/Sample/SudoQuerySample.xcodeproj/xcshareddata/xcschemes/SudoQuerySample.xcscheme new file mode 100644 index 0000000..b645915 --- /dev/null +++ b/clients/ios/Sample/SudoQuerySample.xcodeproj/xcshareddata/xcschemes/SudoQuerySample.xcscheme @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/ios/Sample/SudoQuerySample/ContentView.swift b/clients/ios/Sample/SudoQuerySample/ContentView.swift new file mode 100644 index 0000000..be62092 --- /dev/null +++ b/clients/ios/Sample/SudoQuerySample/ContentView.swift @@ -0,0 +1,55 @@ +import SudoQuery +import SwiftUI + +struct ContentView: View { + @State private var clicks = 0 + @State private var status = "Ready" + + var body: some View { + NavigationStack { + List { + Section("Events") { + Button("Track button_click") { + clicks += 1 + SudoQuery.track("button_click", properties: ["clicks": clicks, "screen": "home"]) + status = "Tracked button_click #\(clicks)" + } + Button("Track 100 events") { + for index in 0..<100 { + SudoQuery.track("bulk_event", properties: ["index": index]) + } + status = "Tracked 100 events" + } + } + Section("User") { + Button("Set user user_123") { + SudoQuery.setUser("user_123") + status = "User is user_123" + } + Button("Remove user") { + SudoQuery.removeUser() + status = "User removed" + } + Button("Reset (logout)") { + SudoQuery.reset() + status = "Reset: new anonymous id and session" + } + } + Section("Delivery") { + Button("Flush") { + SudoQuery.flush() + status = "Flushed" + } + Button("Run Objective-C smoke test") { + SQSampleRunObjCSmokeTest() + status = "Objective-C smoke test ran; see the log" + } + } + Section("Status") { + Text(status) + } + } + .navigationTitle("SudoQuery \(SudoQuery.version)") + } + } +} diff --git a/clients/ios/Sample/SudoQuerySample/ObjCSmokeTest.h b/clients/ios/Sample/SudoQuerySample/ObjCSmokeTest.h new file mode 100644 index 0000000..c1a288f --- /dev/null +++ b/clients/ios/Sample/SudoQuerySample/ObjCSmokeTest.h @@ -0,0 +1,9 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Uses every Objective-C method of the SDK, so building the sample checks the generated header, +/// and tracks an `objc_smoke` event. The results are logged with NSLog. +void SQSampleRunObjCSmokeTest(void); + +NS_ASSUME_NONNULL_END diff --git a/clients/ios/Sample/SudoQuerySample/ObjCSmokeTest.m b/clients/ios/Sample/SudoQuerySample/ObjCSmokeTest.m new file mode 100644 index 0000000..0c2c17d --- /dev/null +++ b/clients/ios/Sample/SudoQuerySample/ObjCSmokeTest.m @@ -0,0 +1,48 @@ +#import "ObjCSmokeTest.h" + +@import SudoQuery; + +void SQSampleRunObjCSmokeTest(void) { + // An invalid config is reported as an NSError. + SQSudoQueryConfig *invalid = [[SQSudoQueryConfig alloc] initWithOrgId:@"acme-k3x9qa" + workspaceId:@"ios-demo1" + endpoint:@"ftp://nowhere"]; + NSError *error = nil; + BOOL started = [SQSudoQuery initializeWithConfig:invalid error:&error]; + NSLog(@"SudoQuery ObjC smoke: invalid config started=%d domain=%@ code=%ld (expected %ld)", + started, error.domain, (long)error.code, (long)SQSudoQueryConfigErrorCodeInvalidEndpoint); + + // Every option. The app already initialized the SDK from Swift, so this call is ignored. + SQSudoQueryConfig *config = [[SQSudoQueryConfig alloc] initWithOrgId:@"acme-k3x9qa" + workspaceId:@"ios-demo1" + endpoint:@"http://localhost:3001/v1/events/batch"]; + config.token = nil; + config.headers = @{}; + config.source = @"ios"; + config.sessionId = nil; + config.batchSize = 20; + config.flushInterval = 10; + config.sessionTimeout = 30 * 60; + config.debugLogging = YES; + if (![SQSudoQuery initializeWithConfig:config error:&error]) { + NSLog(@"SudoQuery ObjC smoke: %@", error.localizedDescription); + } + + [SQSudoQuery setSuperProperty:@"objc" value:@YES]; + [SQSudoQuery track:@"objc_smoke" + properties:@{@"flag": @YES, @"count": @1, @"ratio": @0.5, @"none": NSNull.null, @2: @"numeric key"}]; + [SQSudoQuery track:@"objc_smoke_plain"]; + [SQSudoQuery setUser:@"objc_user"]; + NSLog(@"SudoQuery ObjC smoke: user=%@ initialized=%d version=%@ errorDomain=%@", + SQSudoQuery.user, SQSudoQuery.isInitialized, SQSudoQuery.sdkVersion, SQSudoQueryConfig.errorDomain); + [SQSudoQuery removeUser]; + NSLog(@"SudoQuery ObjC smoke: superProperties=%@", SQSudoQuery.superProperties); + [SQSudoQuery clearSuperProperties]; + [SQSudoQuery flush]; + + // Compiled but not run: a smoke test shouldn't log the app out. + void (^logout)(void) = ^{ + [SQSudoQuery reset]; + }; + (void)logout; +} diff --git a/clients/ios/Sample/SudoQuerySample/SmokeTest.swift b/clients/ios/Sample/SudoQuerySample/SmokeTest.swift new file mode 100644 index 0000000..108ad42 --- /dev/null +++ b/clients/ios/Sample/SudoQuerySample/SmokeTest.swift @@ -0,0 +1,30 @@ +import SudoQuery + +/// Launch arguments for checking the sample from the command line: +/// +/// ``` +/// xcrun simctl launch io.juspay.sudoquery.sample --sudoquery-smoke-test +/// ``` +/// +/// `--sudoquery-smoke-test` tracks three `smoke_test` events and flushes them; +/// `--sudoquery-objc-smoke` runs ObjCSmokeTest.m, which tracks an `objc_smoke` event; +/// `--sudoquery-background-test` tracks two `background_test` events without flushing, so they +/// go out when the app is sent to the background. +enum SmokeTest { + static func runIfRequested(_ arguments: [String]) { + if arguments.contains("--sudoquery-smoke-test") { + for index in 0..<3 { + SudoQuery.track("smoke_test", properties: ["index": index]) + } + SudoQuery.flush() + } + if arguments.contains("--sudoquery-background-test") { + for index in 0..<2 { + SudoQuery.track("background_test", properties: ["index": index]) + } + } + if arguments.contains("--sudoquery-objc-smoke") { + SQSampleRunObjCSmokeTest() + } + } +} diff --git a/clients/ios/Sample/SudoQuerySample/SudoQuerySample-Bridging-Header.h b/clients/ios/Sample/SudoQuerySample/SudoQuerySample-Bridging-Header.h new file mode 100644 index 0000000..4041700 --- /dev/null +++ b/clients/ios/Sample/SudoQuerySample/SudoQuerySample-Bridging-Header.h @@ -0,0 +1 @@ +#import "ObjCSmokeTest.h" diff --git a/clients/ios/Sample/SudoQuerySample/SudoQuerySampleApp.swift b/clients/ios/Sample/SudoQuerySample/SudoQuerySampleApp.swift new file mode 100644 index 0000000..4d03df3 --- /dev/null +++ b/clients/ios/Sample/SudoQuerySample/SudoQuerySampleApp.swift @@ -0,0 +1,43 @@ +import SudoQuery +import SwiftUI + +@main +struct SudoQuerySampleApp: App { + init() { + SampleSDK.start() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +enum SampleSDK { + /// The endpoint, org and workspace come from the SUDOQUERY_* build settings (through Info.plist); + /// environment variables of the same names override them, e.g. with + /// `SIMCTL_CHILD_SUDOQUERY_ENDPOINT=… xcrun simctl launch …`. + static func start() { + let info = Bundle.main.infoDictionary ?? [:] + let environment = ProcessInfo.processInfo.environment + func setting(_ name: String, _ key: String) -> String { + environment[name] ?? (info[key] as? String) ?? "" + } + do { + let config = try SudoQueryConfig( + orgId: setting("SUDOQUERY_ORG_ID", "SudoQueryOrgId"), + workspaceId: setting("SUDOQUERY_WORKSPACE_ID", "SudoQueryWorkspaceId"), + endpoint: setting("SUDOQUERY_ENDPOINT", "SudoQueryEndpoint"), + batchSize: 20, + flushInterval: 10, + debugLogging: true + ) + SudoQuery.initialize(config) + SudoQuery.setSuperProperty("app_version", (info["CFBundleShortVersionString"] as? String) ?? "dev") + } catch { + print("SudoQuery is not configured: \(error)") + } + SmokeTest.runIfRequested(ProcessInfo.processInfo.arguments) + } +} diff --git a/clients/ios/Sources/SudoQuery/Event.swift b/clients/ios/Sources/SudoQuery/Event.swift new file mode 100644 index 0000000..aa893e6 --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Event.swift @@ -0,0 +1,134 @@ +import Foundation + +/// The event type the SDK sends. To move to a new envelope, add its type to this file (conforming +/// to `WireEvent`) and point this alias at it; nothing else needs to change. Batching, storage and +/// upload only ever see encoded events. +typealias CanonicalEvent = EventV1 + +/// Everything the SDK knows about one tracked event, independent of the envelope version. Each +/// event type picks the fields it needs in its `init(_:)`. +struct EventInput: Sendable { + let id: String + let name: String + let orgId: String + let workspaceId: String + let sessionId: String? + let anonId: String + let actorId: String? + let source: String? + /// RFC 3339 in UTC with milliseconds, e.g. `2026-10-14T09:30:12.345Z`. + let occurredAt: String + let properties: JSONObject +} + +protocol WireEvent { + init(_ input: EventInput) + + /// The event as a JSON object, keys in wire order. + func json() -> JSONObject +} + +extension WireEvent { + /// One NDJSON line, without the newline. + static func encode(_ input: EventInput) -> [UInt8] { + JSON.encode(.object(Self(input).json())) + } +} + +/// Envelope "1.0". Mirrors `CollectorEvent` in crates/events-collector/src/collector_event.rs and +/// `Event` in clients/typescript/src/types.ts field for field and in the same order, including the +/// `envelop_version` and `occured_at` spellings, except that it sends `workspace_id` where they have +/// `project_id`. +struct EventV1: WireEvent, Sendable { + var envelopVersion = "1.0" + var id: String + var name: String + var orgId: String + var workspaceId: String + var sessionId: String? + var anonId: String + var actorId: String? + var source: String? + var occuredAt: String + var properties: JSONObject? + var correlationId: String? + var traceId: String? + var systemProperties: SystemProperties? + + init(_ input: EventInput) { + id = input.id + name = input.name + orgId = input.orgId + workspaceId = input.workspaceId + sessionId = input.sessionId + anonId = input.anonId + actorId = input.actorId + source = input.source + occuredAt = input.occurredAt + properties = input.properties + } + + func json() -> JSONObject { + JSONObject([ + ("envelop_version", .string(envelopVersion)), + ("id", .string(id)), + ("name", .string(name)), + ("org_id", .string(orgId)), + ("workspace_id", .string(workspaceId)), + ("session_id", string(sessionId)), + ("anon_id", .string(anonId)), + ("actor_id", string(actorId)), + ("source", string(source)), + ("occured_at", .string(occuredAt)), + ("properties", properties.map(JSONValue.object) ?? .null), + ("correlation_id", string(correlationId)), + ("trace_id", string(traceId)), + ("system_properties", systemProperties?.json() ?? .null), + ]) + } +} + +/// System properties understood by the collector. +struct SystemProperties: Sendable, Equatable { + var geo: Geo? + var timezone: String? + + func json() -> JSONValue { + .object(JSONObject([ + ("geo", geo?.json() ?? .null), + ("timezone", string(timezone)), + ])) + } +} + +struct Geo: Sendable, Equatable { + var country: String? + + func json() -> JSONValue { + .object(JSONObject([("country", string(country))])) + } +} + +/// The body of `POST /v1/events/batch`. +enum BatchBody { + /// `{"events":[…],"system_properties":{…}}`. The events are the stored NDJSON lines copied + /// verbatim, so a resend is byte-for-byte the same and events of any envelope version are sent + /// exactly as they were written. + static func build(events: [Data], systemProperties: SystemProperties?) -> Data { + var out: [UInt8] = [] + out.reserveCapacity(events.reduce(64) { $0 + $1.count + 1 }) + out.append(contentsOf: #"{"events":["#.utf8) + for (index, event) in events.enumerated() { + if index > 0 { out.append(UInt8(ascii: ",")) } + out.append(contentsOf: event) + } + out.append(contentsOf: #"],"system_properties":"#.utf8) + JSON.write(systemProperties?.json() ?? .null, to: &out) + out.append(UInt8(ascii: "}")) + return Data(out) + } +} + +private func string(_ value: String?) -> JSONValue { + value.map(JSONValue.string) ?? .null +} diff --git a/clients/ios/Sources/SudoQuery/Internal/AppLifecycle.swift b/clients/ios/Sources/SudoQuery/Internal/AppLifecycle.swift new file mode 100644 index 0000000..43516c0 --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/AppLifecycle.swift @@ -0,0 +1,125 @@ +#if canImport(UIKit) && !os(watchOS) +import UIKit + +/// Background-task calls, so tests can stand in for `UIApplication`. +protocol BackgroundTasks: Sendable { + func begin(name: String, expiration: @escaping @Sendable () -> Void) -> UIBackgroundTaskIdentifier? + func end(_ identifier: UIBackgroundTaskIdentifier) +} + +struct UIApplicationBackgroundTasks: BackgroundTasks { + let application: UIApplication + + /// The app's `UIApplication`, or nil in an app extension, where there is none. Looked up by + /// name because `UIApplication.shared` doesn't compile for extensions. + static func current() -> UIApplicationBackgroundTasks? { + guard !Bundle.main.bundlePath.hasSuffix(".appex"), + let application = (UIApplication.self as AnyObject).value(forKey: "sharedApplication") as? UIApplication + else { return nil } + return UIApplicationBackgroundTasks(application: application) + } + + func begin(name: String, expiration: @escaping @Sendable () -> Void) -> UIBackgroundTaskIdentifier? { + let identifier = application.beginBackgroundTask(withName: name, expirationHandler: expiration) + return identifier == .invalid ? nil : identifier + } + + func end(_ identifier: UIBackgroundTaskIdentifier) { + application.endBackgroundTask(identifier) + } +} + +/// Reports the app moving between foreground and background. Going to the background starts a +/// background task, so the batch committed then can still be uploaded, and ends it when the +/// uploader is idle or iOS runs out of time. hyper-sdk-ios flushed on timers only. +/// +/// The UIKit calls used here are nonisolated, so nothing needs the main actor. +final class AppLifecycleObserver: @unchecked Sendable { + static let taskName = "SudoQuery flush" + + private let center: NotificationCenter + private let tasks: @Sendable () -> (any BackgroundTasks)? + private let onBackground: @Sendable (_ done: @escaping @Sendable () -> Void) -> Void + private let onForeground: @Sendable () -> Void + private let current = Locked(nil) + private let tokens = Locked<[any NSObjectProtocol]>([]) + + init( + center: NotificationCenter = .default, + tasks: @escaping @Sendable () -> (any BackgroundTasks)? = { UIApplicationBackgroundTasks.current() }, + onBackground: @escaping @Sendable (_ done: @escaping @Sendable () -> Void) -> Void, + onForeground: @escaping @Sendable () -> Void + ) { + self.center = center + self.tasks = tasks + self.onBackground = onBackground + self.onForeground = onForeground + } + + func register() { + let background = center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in + self?.didEnterBackground() + } + let foreground = center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in + self?.onForeground() + } + tokens.withLock { $0 = [background, foreground] } + } + + func unregister() { + let registered = tokens.withLock { tokens in + defer { tokens = [] } + return tokens + } + for token in registered { + center.removeObserver(token) + } + } + + private func didEnterBackground() { + let provider = tasks() + let slot = TaskSlot(provider: provider) + // A task from an earlier trip to the background that hasn't finished is ended now. + let previous = current.withLock { current in + defer { current = slot } + return current + } + previous?.finish() + + if let provider, let identifier = provider.begin(name: Self.taskName, expiration: { slot.finish() }) { + slot.started(identifier) + } + onBackground { slot.finish() } + } +} + +/// One background task, ended exactly once: by the flush finishing, by expiry, or by the next +/// trip to the background. If it finishes before `begin` has returned, it is ended right away. +private final class TaskSlot: Sendable { + private let provider: (any BackgroundTasks)? + private let state = Locked<(identifier: UIBackgroundTaskIdentifier?, finished: Bool)>((nil, false)) + + init(provider: (any BackgroundTasks)?) { + self.provider = provider + } + + func started(_ identifier: UIBackgroundTaskIdentifier) { + let endNow = state.withLock { state -> Bool in + if state.finished { return true } + state.identifier = identifier + return false + } + if endNow { provider?.end(identifier) } + } + + func finish() { + let identifier = state.withLock { state -> UIBackgroundTaskIdentifier? in + if state.finished { return nil } + state.finished = true + defer { state.identifier = nil } + return state.identifier + } + if let identifier { provider?.end(identifier) } + } +} +#endif diff --git a/clients/ios/Sources/SudoQuery/Internal/EventWriter.swift b/clients/ios/Sources/SudoQuery/Internal/EventWriter.swift new file mode 100644 index 0000000..6199429 --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/EventWriter.swift @@ -0,0 +1,239 @@ +import Foundation + +/// Work for the event writer, handled strictly in the order the app made the calls. +enum Command: Sendable { + case track(name: String, timestampMillis: Int64, properties: JSONObject) + case setUser(String?) + case reset + case flush + case tick + /// The app went to the background. `done` runs once the batch committed here has been tried, + /// or the uploader stopped at a failure. + case background(done: (@Sendable () -> Void)?) + case foreground + /// Runs `done` once every earlier command has been handled. + case barrier(@Sendable () -> Void) +} + +struct WriterSettings: Sendable { + let orgId: String + let workspaceId: String + let source: String? + let fixedSessionId: String? + let sessionTimeoutMillis: Int64 +} + +/// Turns commands into stored events. It runs on one serial queue, so `setUser` → `track` → +/// `removeUser` keeps its meaning, and identity is resolved here (after it was loaded from disk) +/// rather than on the app's thread. This is the role of hyper-sdk-ios's serial `trackerQueue` in +/// `HPJPLogsManager`, without its in-memory staging (`HPJPPreLogs`), whose copy-per-event and races +/// with the sessioniser lost or duplicated events. +/// +/// Each event is encoded exactly once, so a resend carries the same `id` and timestamp. The event +/// type comes in through `encode`; the SDK passes `CanonicalEvent.encode`. +final class EventWriter: @unchecked Sendable { + /// Larger events are dropped: they would crowd out whole batches. + static let maxEventBytes = 64 * 1024 + + private let encode: @Sendable (EventInput) -> [UInt8] + private let settings: WriterSettings + private let store: QueueStore + private let identityStore: IdentityStore + private let uploads: any UploadControl + private let log: any SDKLog + private let newId: @Sendable () -> String + private let onStoredUser: @Sendable (String?) -> Void + + // Writer queue only. + private var identity = IdentityState(anonId: "") + private var identityDirty = false + /// The stored identity exists but couldn't be read at start, e.g. because the app was launched + /// in the background before the first unlock after a reboot. Until it can be read, a temporary + /// identity is used and never saved over it. + private var storedIdentityPending = false + private var userChangedWhilePending = false + private var resetWhilePending = false + + /// `onStoredUser` gets the stored user id when it can only be read after `start()`. + init( + encode: @escaping @Sendable (EventInput) -> [UInt8], + settings: WriterSettings, + store: QueueStore, + identityStore: IdentityStore, + uploads: any UploadControl, + log: any SDKLog, + newId: @escaping @Sendable () -> String = { UUID().uuidString.lowercased() }, + onStoredUser: @escaping @Sendable (String?) -> Void = { _ in } + ) { + self.encode = encode + self.settings = settings + self.store = store + self.identityStore = identityStore + self.uploads = uploads + self.log = log + self.newId = newId + self.onStoredUser = onStoredUser + } + + /// Loads the identity and recovers batches left by the previous run. Returns the stored user id. + func start() -> String? { + switch identityStore.load() { + case .loaded(let stored): + identity = stored + case .unavailable: + identity = IdentityState(anonId: newId()) + storedIdentityPending = true + log.warn("The SDK identity can't be read yet (the device may not have been unlocked since it started); using a temporary one until it can") + case .missing, .corrupt: + identity = IdentityState(anonId: newId()) + saveIdentity() + } + do { + try store.recover() + store.enforceLimits() + } catch { + log.error("Could not recover the event queue", error: error) + } + uploads.requestUpload() + return identity.actorId + } + + func handle(_ command: Command) { + if storedIdentityPending { adoptStoredIdentityIfReadable() } + switch command { + case let .track(name, timestampMillis, properties): + track(name: name, timestampMillis: timestampMillis, properties: properties) + case .setUser(let userId): + if storedIdentityPending { userChangedWhilePending = true } + var state = identity + state.actorId = userId + updateIdentity(state) + case .reset: + if storedIdentityPending { resetWhilePending = true } + updateIdentity(IdentityState(anonId: newId())) + case .flush: + commitAndUpload() + case .tick: + // While uploads back off (e.g. offline), let the open batch fill up instead of + // committing many small ones. + if !uploads.isBackingOff { commitAndUpload() } + saveIdentityIfDirty() + case .background(let done): + // iOS suspends the app soon after this, so try once now even if uploads are backing + // off, as coming back to the foreground does. + uploads.resetBackoff() + commitAndUpload() + saveIdentityIfDirty() + if let done { uploads.whenIdle(done) } + case .foreground: + uploads.resetBackoff() + uploads.requestUpload() + case .barrier(let done): + done() + } + } + + private func track(name: String, timestampMillis: Int64, properties: JSONObject) { + let previousSession = identity.sessionId + identity = identity.touched( + at: timestampMillis, + timeoutMillis: settings.sessionTimeoutMillis, + fixedSessionId: settings.fixedSessionId, + newId: newId + ) + if identity.sessionId != previousSession { + updateIdentity(identity) + } else { + identityDirty = true + } + + let input = EventInput( + id: newId(), + name: name, + orgId: settings.orgId, + workspaceId: settings.workspaceId, + sessionId: identity.sessionId, + anonId: identity.anonId, + actorId: identity.actorId, + source: settings.source, + occurredAt: formatIsoUtcMillis(timestampMillis), + properties: properties + ) + let line = encode(input) + if line.count > Self.maxEventBytes { + log.warn("Dropped event \"\(name)\": it is \(line.count) bytes, over the \(Self.maxEventBytes)-byte limit") + return + } + do { + if try store.append(line) { + afterCommit() + } + } catch let failure as QueueStore.AppendFailure { + log.warn("Could not store event \"\(name)\"; it was dropped", error: failure.cause) + // The batch before it may still have been committed. + if failure.committed { afterCommit() } + } catch { + log.warn("Could not store event \"\(name)\"; it was dropped", error: error) + } + } + + private func commitAndUpload() { + if store.commit() { store.enforceLimits() } + uploads.requestUpload() + } + + private func afterCommit() { + store.enforceLimits() + uploads.requestUpload() + } + + /// Switches from the temporary identity to the stored one once it can be read. The app's + /// `setUser`, `removeUser` or `reset` from this run still apply, and a session already started + /// keeps going. + private func adoptStoredIdentityIfReadable() { + let stored: IdentityState + switch identityStore.load() { + case .unavailable: + return + case .missing, .corrupt: + storedIdentityPending = false + saveIdentity() + return + case .loaded(let state): + stored = state + } + storedIdentityPending = false + if resetWhilePending { + // The app logged out meanwhile: the new identity replaces the stored one. + saveIdentity() + return + } + var adopted = stored + if userChangedWhilePending { + adopted.actorId = identity.actorId + } else { + onStoredUser(stored.actorId) + } + if identity.sessionId != nil { + adopted.sessionId = identity.sessionId + adopted.lastActivityMillis = identity.lastActivityMillis + } + identity = adopted + saveIdentity() + } + + private func updateIdentity(_ state: IdentityState) { + identity = state + saveIdentity() + } + + /// Saves the identity, or leaves it dirty to try again on the next tick: when the save fails, + /// and while the stored identity can't be read, so it is never overwritten. + private func saveIdentity() { + identityDirty = storedIdentityPending || !identityStore.save(identity) + } + + private func saveIdentityIfDirty() { + if identityDirty { saveIdentity() } + } +} diff --git a/clients/ios/Sources/SudoQuery/Internal/FileIO.swift b/clients/ios/Sources/SudoQuery/Internal/FileIO.swift new file mode 100644 index 0000000..4431e6e --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/FileIO.swift @@ -0,0 +1,168 @@ +import Foundation +#if canImport(Darwin) +import Darwin +#endif + +/// File operations for the event queue and the identity file, ported from Airborne's `AJPFileUtil` +/// (atomic writes, folder creation, deletes). +/// +/// Unlike there, the files live in Application Support, excluded from backup, with an explicit +/// protection class; folders are created once instead of on every call; and failures are thrown, +/// not swallowed by `try?`. Plain POSIX calls are used so a failure is always a Swift error (never +/// an Objective-C exception such as `FileHandle.write` raises) and an event is one `write`. +enum FileIO { + struct Failure: Error, CustomStringConvertible, LocalizedError { + let operation: String + let name: String + let code: Int32 + + var description: String { + "\(operation) \(name) failed: \(String(cString: strerror(code)))" + } + + var errorDescription: String? { + description + } + } + + /// Readable after the first unlock since boot, so a flush in the background still works while + /// the device is locked. Apps whose default class is `complete` would otherwise lose that. + static var protectionAttributes: [FileAttributeKey: Any]? { + #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + return [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] + #else + return nil + #endif + } + + /// Creates `url` and any missing parents. + static func ensureDirectory(_ url: URL) throws { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: protectionAttributes) + } + + /// Best effort: the directory's class normally applies already. + static func setProtection(_ url: URL) { + guard let attributes = protectionAttributes else { return } + try? FileManager.default.setAttributes(attributes, ofItemAtPath: url.path) + } + + /// Keeps `url` (and everything in it) out of iCloud and Finder backups, so the anonymous id is + /// never restored onto another device. + static func excludeFromBackup(_ url: URL) throws { + var url = url + var values = URLResourceValues() + values.isExcludedFromBackup = true + try url.setResourceValues(values) + } + + static func openForAppend(_ url: URL) throws -> Int32 { + try openFile(url, flags: O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC) + } + + /// Writes all of `bytes`, retrying short writes and interruptions. + static func writeAll(_ fd: Int32, _ bytes: [UInt8], name: String) throws { + try bytes.withUnsafeBytes { buffer in + guard var pointer = buffer.baseAddress else { return } + var remaining = buffer.count + while remaining > 0 { + let written = Darwin.write(fd, pointer, remaining) + if written < 0 { + let code = errno + if code == EINTR { continue } + throw Failure(operation: "write", name: name, code: code) + } + pointer = pointer.advanced(by: written) + remaining -= written + } + } + } + + static func closeFile(_ fd: Int32, name: String) throws { + if Darwin.close(fd) != 0 { + throw Failure(operation: "close", name: name, code: errno) + } + } + + /// `rename(2)`: atomic, and replaces `destination` if it exists. + static func moveFile(_ source: URL, to destination: URL) throws { + var code: Int32 = 0 + let result = source.withUnsafeFileSystemRepresentation { from in + destination.withUnsafeFileSystemRepresentation { to -> Int32 in + guard let from, let to else { + code = EINVAL + return -1 + } + let result = Darwin.rename(from, to) + if result != 0 { code = errno } + return result + } + } + if result != 0 { + throw Failure(operation: "rename", name: source.lastPathComponent, code: code) + } + } + + /// Returns true if the file was deleted. + @discardableResult + static func removeFile(_ url: URL) -> Bool { + url.withUnsafeFileSystemRepresentation { path in + guard let path else { return false } + return unlink(path) == 0 + } + } + + static func exists(_ url: URL) -> Bool { + FileManager.default.fileExists(atPath: url.path) + } + + /// The file's size. `URLResourceValues` rather than `stat`, which the privacy manifest would + /// have to declare as a file-timestamp API. + static func fileSize(_ url: URL) -> Int64? { + guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize else { return nil } + return Int64(size) + } + + static func listNames(_ directory: URL) -> [String] { + (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] + } + + static func readAll(_ url: URL) throws -> Data { + try Data(contentsOf: url) + } + + /// Writes `bytes` to a temporary file, syncs it and renames it over `url`, so a crash leaves + /// either the old file or the new one, never half of one. + static func atomicWrite(_ bytes: [UInt8], to url: URL) throws { + let temp = url.deletingLastPathComponent().appendingPathComponent(url.lastPathComponent + ".tmp") + let fd = try openFile(temp, flags: O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC) + do { + try writeAll(fd, bytes, name: temp.lastPathComponent) + if fsync(fd) != 0 { + throw Failure(operation: "fsync", name: temp.lastPathComponent, code: errno) + } + } catch { + _ = Darwin.close(fd) + removeFile(temp) + throw error + } + try closeFile(fd, name: temp.lastPathComponent) + try moveFile(temp, to: url) + } + + private static func openFile(_ url: URL, flags: Int32) throws -> Int32 { + var code: Int32 = 0 + let fd = url.withUnsafeFileSystemRepresentation { path -> Int32 in + guard let path else { + code = EINVAL + return -1 + } + let fd = open(path, flags, 0o600) + if fd < 0 { code = errno } + return fd + } + guard fd >= 0 else { + throw Failure(operation: "open", name: url.lastPathComponent, code: code) + } + return fd + } +} diff --git a/clients/ios/Sources/SudoQuery/Internal/HTTPTransport.swift b/clients/ios/Sources/SudoQuery/Internal/HTTPTransport.swift new file mode 100644 index 0000000..7a5150f --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/HTTPTransport.swift @@ -0,0 +1,169 @@ +import Foundation + +struct HTTPResult: Sendable, Equatable { + let code: Int + /// `Retry-After` in seconds, when the server sent one. + let retryAfterSeconds: Int64? + /// The start of the response body, for logging. + let body: String? +} + +protocol Transport: Sendable { + /// Posts one batch. `completion` is called once, on any queue, with the response, or with the + /// error that kept one from arriving. + func post(_ body: Data, completion: @escaping @Sendable (Result) -> Void) +} + +/// Request headers, with the TS client's precedence: custom headers can replace `Content-Type` and +/// `User-Agent`, but `x-org-id`, `x-workspace-id` and the bearer token always win. Names are compared +/// case-insensitively. (hyper-sdk-ios merged headers through a dictionary, so which of two +/// duplicates won depended on the dictionary's order.) +func requestHeaders( + orgId: String, + workspaceId: String, + token: String?, + custom: [String: String], + userAgent: String +) -> [(name: String, value: String)] { + var headers: [(name: String, value: String)] = [] + func put(_ name: String, _ value: String) { + headers.removeAll { $0.name.lowercased() == name.lowercased() } + headers.append((name, value)) + } + put("Content-Type", "application/json") + put("User-Agent", userAgent) + for (name, value) in custom.sorted(by: { $0.key < $1.key }) { + put(name, value) + } + put("x-org-id", orgId) + put("x-workspace-id", workspaceId) + if let token, !token.isEmpty { + put("Authorization", "Bearer \(token)") + } + return headers +} + +/// `sudoquery-ios/ (iOS )`. +func defaultUserAgent() -> String { + let version = ProcessInfo.processInfo.operatingSystemVersion + var osVersion = "\(version.majorVersion).\(version.minorVersion)" + if version.patchVersion != 0 { + osVersion += ".\(version.patchVersion)" + } + #if targetEnvironment(macCatalyst) + let platform = "Mac Catalyst" + #elseif os(iOS) + let platform = "iOS" + #elseif os(macOS) + let platform = "macOS" + #elseif os(tvOS) + let platform = "tvOS" + #elseif os(visionOS) + let platform = "visionOS" + #else + let platform = "Apple" + #endif + return "sudoquery-ios/\(SudoQuery.version) (\(platform) \(osVersion))" +} + +/// `Retry-After` as whole seconds. The HTTP-date form is ignored, as in the Android SDK. +func parseRetryAfter(_ value: String?) -> Int64? { + guard let value else { return nil } + return Int64(value.trimmingCharacters(in: .whitespaces)) +} + +/// `Transport` over one shared `URLSession`, ported from Airborne's `AJPNetworkClient` (shared +/// session, header merging, JSON body, redirect interception). +/// +/// Unlike hyper-sdk-ios's log uploads, one session is reused for every batch, so connections are +/// kept alive; cookies, caches and stored credentials are off; timeouts are explicit; and a +/// transport error is reported as an error instead of an empty `URLResponse()`. +final class URLSessionTransport: Transport { + static let maxBodyBytes = 2048 + + private let session: URLSession + private let endpoint: URL + private let headers: [(name: String, value: String)] + /// A task created on an invalidated session raises an Objective-C exception. + private let invalidated = Locked(false) + + init( + endpoint: URL, + headers: [(name: String, value: String)], + configuration: URLSessionConfiguration = URLSessionTransport.configuration() + ) { + self.endpoint = endpoint + self.headers = headers + session = URLSession(configuration: configuration, delegate: RedirectBlocker(), delegateQueue: nil) + } + + static func configuration() -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.httpCookieAcceptPolicy = .never + configuration.urlCredentialStorage = nil + // 30 s without data fails a request, like Android's read timeout. The total time is + // capped loosely: a 512 KiB batch on a slow cellular link needs minutes, and a tighter cap + // would fail it on every retry and hold up the batches behind it. + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 10 * 60 + configuration.httpMaximumConnectionsPerHost = 1 + configuration.waitsForConnectivity = false + return configuration + } + + func post(_ body: Data, completion: @escaping @Sendable (Result) -> Void) { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.httpBody = body + for (name, value) in headers { + request.setValue(value, forHTTPHeaderField: name) + } + let task = invalidated.withLock { invalidated -> URLSessionDataTask? in + guard !invalidated else { return nil } + return session.dataTask(with: request) { data, response, error in + if let error { + completion(.failure(error)) + return + } + guard let http = response as? HTTPURLResponse else { + completion(.failure(URLError(.badServerResponse))) + return + } + let text = data.map { String(decoding: $0.prefix(Self.maxBodyBytes), as: UTF8.self) } + let retryAfter = parseRetryAfter(http.value(forHTTPHeaderField: "Retry-After")) + completion(.success(HTTPResult(code: http.statusCode, retryAfterSeconds: retryAfter, body: text))) + } + } + guard let task else { + completion(.failure(URLError(.cancelled))) + return + } + task.resume() + } + + /// Cancels requests in flight and releases the session. Later posts fail with `.cancelled`. + func invalidate() { + invalidated.withLock { invalidated in + invalidated = true + session.invalidateAndCancel() + } + } +} + +/// Refuses redirects: following one would send the batch again, somewhere else. The 3xx response +/// comes back instead and the batch is kept for a retry, as for any misconfigured endpoint. +final class RedirectBlocker: NSObject, URLSessionTaskDelegate, Sendable { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} diff --git a/clients/ios/Sources/SudoQuery/Internal/Identity.swift b/clients/ios/Sources/SudoQuery/Internal/Identity.swift new file mode 100644 index 0000000..d1259ae --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/Identity.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Who events belong to. Persisted so the anonymous id, user and session survive app restarts. +/// hyper-sdk-ios keeps its session id in UserDefaults; a file needs no privacy-manifest reason. +struct IdentityState: Codable, Equatable, Sendable { + var anonId: String + var actorId: String? + var sessionId: String? + var lastActivityMillis: Int64 + + enum CodingKeys: String, CodingKey { + case anonId = "anon_id" + case actorId = "actor_id" + case sessionId = "session_id" + case lastActivityMillis = "last_activity_ms" + } + + init(anonId: String, actorId: String? = nil, sessionId: String? = nil, lastActivityMillis: Int64 = 0) { + self.anonId = anonId + self.actorId = actorId + self.sessionId = sessionId + self.lastActivityMillis = lastActivityMillis + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + anonId = try container.decode(String.self, forKey: .anonId) + actorId = try container.decodeIfPresent(String.self, forKey: .actorId) + sessionId = try container.decodeIfPresent(String.self, forKey: .sessionId) + lastActivityMillis = try container.decodeIfPresent(Int64.self, forKey: .lastActivityMillis) ?? 0 + } + + /// Applies the session rules for an event at `nowMillis`: a session ends after `timeoutMillis` + /// without events. If the clock moved backwards the session continues, timed from the new + /// clock. A `fixedSessionId` from the config is used as is. + func touched(at nowMillis: Int64, timeoutMillis: Int64, fixedSessionId: String?, newId: () -> String) -> IdentityState { + let session: String + if let fixedSessionId { + session = fixedSessionId + } else if let sessionId, !Self.timedOut(nowMillis, since: lastActivityMillis, timeoutMillis) { + session = sessionId + } else { + session = newId() + } + var state = self + state.sessionId = session + state.lastActivityMillis = nowMillis + return state + } + + private static func timedOut(_ nowMillis: Int64, since lastMillis: Int64, _ timeoutMillis: Int64) -> Bool { + let (gap, overflow) = nowMillis.subtractingReportingOverflow(lastMillis) + return overflow || gap > timeoutMillis + } +} + +/// What `IdentityStore.load()` found. +enum StoredIdentity: Equatable { + case loaded(IdentityState) + /// No identity file: a first launch, or the app's data was cleared. + case missing + /// The file isn't a valid identity; it gets replaced. + case corrupt + /// The file exists but can't be read now, e.g. before the first unlock after a reboot. It must + /// not be replaced: it holds the anonymous id and user of earlier runs. + case unavailable +} + +/// Keeps `IdentityState` in one small JSON file, replaced atomically so a crash can't leave it +/// half-written. +final class IdentityStore: Sendable { + private let url: URL + private let log: any SDKLog + + init(url: URL, log: any SDKLog) { + self.url = url + self.log = log + } + + func load() -> StoredIdentity { + guard FileIO.exists(url) else { return .missing } + let data: Data + do { + data = try FileIO.readAll(url) + } catch { + return .unavailable + } + do { + return .loaded(try JSONDecoder().decode(IdentityState.self, from: data)) + } catch { + log.warn("Ignoring unreadable \(url.lastPathComponent); starting with a new anonymous id", error: error) + return .corrupt + } + } + + /// Returns false if the identity could not be saved. + @discardableResult + func save(_ state: IdentityState) -> Bool { + do { + try FileIO.ensureDirectory(url.deletingLastPathComponent()) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + try FileIO.atomicWrite(Array(encoder.encode(state)), to: url) + return true + } catch { + log.warn("Could not save the SDK identity", error: error) + return false + } + } +} diff --git a/clients/ios/Sources/SudoQuery/Internal/IsoTime.swift b/clients/ios/Sources/SudoQuery/Internal/IsoTime.swift new file mode 100644 index 0000000..75cd434 --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/IsoTime.swift @@ -0,0 +1,55 @@ +private let millisPerDay: Int64 = 86_400_000 + +/// Formats epoch milliseconds as RFC 3339 in UTC with milliseconds, e.g. `2026-10-14T09:30:12.345Z`, +/// the same as `Date.toISOString()` in the TS client. +/// +/// Plain arithmetic, as in the Android SDK: no formatter to create or share between threads, and +/// no rounding through `Double`. The date conversion is Howard Hinnant's `civil_from_days`. +func formatIsoUtcMillis(_ epochMillis: Int64) -> String { + let days = floorDiv(epochMillis, millisPerDay) + let millisOfDay = floorMod(epochMillis, millisPerDay) + + let z = days + 719_468 + let era = floorDiv(z, 146_097) + let dayOfEra = z - era * 146_097 + let yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365 + let dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100) + let shiftedMonth = (5 * dayOfYear + 2) / 153 + let day = dayOfYear - (153 * shiftedMonth + 2) / 5 + 1 + let month = shiftedMonth < 10 ? shiftedMonth + 3 : shiftedMonth - 9 + let year = yearOfEra + era * 400 + (month <= 2 ? 1 : 0) + + var text = "" + text.reserveCapacity(24) + text += padded(year, 4) + text += "-" + text += padded(month, 2) + text += "-" + text += padded(day, 2) + text += "T" + text += padded(millisOfDay / 3_600_000, 2) + text += ":" + text += padded(millisOfDay / 60_000 % 60, 2) + text += ":" + text += padded(millisOfDay / 1_000 % 60, 2) + text += "." + text += padded(millisOfDay % 1_000, 3) + text += "Z" + return text +} + +func floorDiv(_ a: Int64, _ b: Int64) -> Int64 { + let quotient = a / b + return (a % b != 0 && (a < 0) != (b < 0)) ? quotient - 1 : quotient +} + +func floorMod(_ a: Int64, _ b: Int64) -> Int64 { + let remainder = a % b + return (remainder != 0 && (remainder < 0) != (b < 0)) ? remainder + b : remainder +} + +private func padded(_ value: Int64, _ width: Int) -> String { + let digits = String(value) + guard digits.count < width else { return digits } + return String(repeating: "0", count: width - digits.count) + digits +} diff --git a/clients/ios/Sources/SudoQuery/Internal/JSON.swift b/clients/ios/Sources/SudoQuery/Internal/JSON.swift new file mode 100644 index 0000000..d965506 --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/JSON.swift @@ -0,0 +1,126 @@ +import Foundation + +/// A JSON value that can cross queues. A number keeps the literal it will be written as, so a +/// value's exact text (an integer beyond 2^53, a `Decimal`) survives until it is sent. +enum JSONValue: Sendable, Equatable { + case null + case bool(Bool) + /// A valid JSON number literal, e.g. `42`, `-0.5`, `1e-07`. + case number(String) + case string(String) + case array([JSONValue]) + case object(JSONObject) +} + +/// A JSON object that keeps its keys in insertion order. Setting a key that exists replaces its +/// value in place, as in Kotlin's `LinkedHashMap` and JavaScript objects. +struct JSONObject: Sendable, Equatable { + private(set) var keys: [String] = [] + private var values: [String: JSONValue] = [:] + + init() {} + + init(_ pairs: [(String, JSONValue)]) { + for (key, value) in pairs { + self[key] = value + } + } + + var isEmpty: Bool { + keys.isEmpty + } + + var count: Int { + keys.count + } + + subscript(key: String) -> JSONValue? { + get { values[key] } + set { + if let newValue { + if values.updateValue(newValue, forKey: key) == nil { keys.append(key) } + } else if values.removeValue(forKey: key) != nil { + keys.removeAll { $0 == key } + } + } + } + + var pairs: [(key: String, value: JSONValue)] { + keys.compactMap { key in values[key].map { (key, $0) } } + } + + /// This object with `other` set over it, like `{...self, ...other}` in JavaScript. + func merging(_ other: JSONObject) -> JSONObject { + var result = self + for (key, value) in other.pairs { + result[key] = value + } + return result + } +} + +/// Writes JSON the way the TS client's `JSON.stringify` and the Android SDK do: keys in the order +/// given, nulls written out, no whitespace, `/` and non-ASCII characters left as they are. +/// `JSONEncoder` and `JSONSerialization` guarantee none of the first three. +enum JSON { + static func encode(_ value: JSONValue) -> [UInt8] { + var out: [UInt8] = [] + out.reserveCapacity(256) + write(value, to: &out) + return out + } + + static func write(_ value: JSONValue, to out: inout [UInt8]) { + switch value { + case .null: + out.append(contentsOf: "null".utf8) + case .bool(let flag): + out.append(contentsOf: (flag ? "true" : "false").utf8) + case .number(let literal): + out.append(contentsOf: literal.utf8) + case .string(let text): + writeString(text, to: &out) + case .array(let items): + out.append(UInt8(ascii: "[")) + for (index, item) in items.enumerated() { + if index > 0 { out.append(UInt8(ascii: ",")) } + write(item, to: &out) + } + out.append(UInt8(ascii: "]")) + case .object(let object): + out.append(UInt8(ascii: "{")) + for (index, pair) in object.pairs.enumerated() { + if index > 0 { out.append(UInt8(ascii: ",")) } + writeString(pair.key, to: &out) + out.append(UInt8(ascii: ":")) + write(pair.value, to: &out) + } + out.append(UInt8(ascii: "}")) + } + } + + static func writeString(_ text: String, to out: inout [UInt8]) { + out.append(UInt8(ascii: "\"")) + // Bytes of multi-byte UTF-8 sequences are all >= 0x80, so escaping byte by byte is safe. + for byte in text.utf8 { + switch byte { + case UInt8(ascii: "\""): out.append(contentsOf: #"\""#.utf8) + case UInt8(ascii: "\\"): out.append(contentsOf: #"\\"#.utf8) + case 0x08: out.append(contentsOf: #"\b"#.utf8) + case 0x0C: out.append(contentsOf: #"\f"#.utf8) + case 0x0A: out.append(contentsOf: #"\n"#.utf8) + case 0x0D: out.append(contentsOf: #"\r"#.utf8) + case 0x09: out.append(contentsOf: #"\t"#.utf8) + case 0x00..<0x20: + out.append(contentsOf: #"\u00"#.utf8) + out.append(hexDigits[Int(byte >> 4)]) + out.append(hexDigits[Int(byte & 0x0F)]) + default: + out.append(byte) + } + } + out.append(UInt8(ascii: "\"")) + } + + private static let hexDigits = Array("0123456789abcdef".utf8) +} diff --git a/clients/ios/Sources/SudoQuery/Internal/JSONConversion.swift b/clients/ios/Sources/SudoQuery/Internal/JSONConversion.swift new file mode 100644 index 0000000..e558ea9 --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/JSONConversion.swift @@ -0,0 +1,187 @@ +import Foundation +#if canImport(CoreGraphics) +import CoreGraphics +#endif + +/// Converts values passed to `track()` and `setSuperProperty()` into JSON, on the caller's thread. +/// The result is a copy, so a caller changing its collections afterwards can't alter an event that +/// is already queued. +enum JSONConversion { + /// Deeper values become null; this also stops a dictionary that contains itself. + static let maxDepth = 32 + + static func object(from dictionary: [String: Any], log: any SDKLog) -> JSONObject { + convertDictionary(dictionary.map { ($0.key, $0.value) }, depth: 0, log: log) + } + + /// For dictionaries from Objective-C, whose keys may be numbers. + static func object(fromAnyKeys dictionary: [AnyHashable: Any], log: any SDKLog) -> JSONObject { + convertDictionary(dictionary.map { (key(for: $0.key), $0.value) }, depth: 0, log: log) + } + + static func value(_ value: Any?, log: any SDKLog) -> JSONValue { + guard let value else { return .null } + return convert(value, depth: 0, log: log) + } + + /// The reverse, for handing super properties back to the app: objects become dictionaries, + /// arrays become arrays and null becomes `NSNull`. + static func plain(_ value: JSONValue) -> Any { + switch value { + case .null: + return NSNull() + case .bool(let flag): + return flag + case .number(let literal): + if let integer = Int(literal) { return integer } + if let unsigned = UInt64(literal) { return unsigned } + return Double(literal) ?? NSDecimalNumber(string: literal) + case .string(let text): + return text + case .array(let items): + return items.map(plain) + case .object(let object): + return plain(object) + } + } + + static func plain(_ object: JSONObject) -> [String: Any] { + var result: [String: Any] = [:] + for (key, value) in object.pairs { + result[key] = plain(value) + } + return result + } + + private static func convert(_ raw: Any, depth: Int, log: any SDKLog) -> JSONValue { + guard let value = unwrapped(raw) else { return .null } + if depth > maxDepth { + log.warn("A property nested deeper than \(maxDepth) levels was replaced with null") + return .null + } + if let json = value as? JSONValue { return json } + if value is NSNull { return .null } + // Real NSNumber objects go first: a bridged NSNumber also casts to Bool, Int and Double, so + // `@1` would become `true` and `@2.0` the integer 2. + if type(of: value) is NSNumber.Type, let number = value as? NSNumber { + return convert(number, log: log) + } + switch value { + case let flag as Bool: + return .bool(flag) + case let text as String: + return .string(text) + case let text as Substring: + return .string(String(text)) + case let character as Character: + return .string(String(character)) + case let integer as any BinaryInteger: + return .number("\(integer)") + case let number as Double: + return finite(number.isFinite, number.description, log) + case let number as Float: + return finite(number.isFinite, number.description, log) + case let number as CGFloat: + return finite(number.isFinite, number.description, log) + case let number as Decimal: + return number.isNaN ? nonFinite("NaN", log) : .number(number.description) + case let date as Date: + return .string(formatIsoUtcMillis(millis(date.timeIntervalSince1970))) + case let url as URL: + return .string(url.absoluteString) + case let uuid as UUID: + return .string(uuid.uuidString.lowercased()) + case let data as Data: + return .array(data.map { .number(String($0)) }) + case let dictionary as [String: Any]: + return .object(convertDictionary(dictionary.map { ($0.key, $0.value) }, depth: depth, log: log)) + case let dictionary as [AnyHashable: Any]: + return .object(convertDictionary(dictionary.map { (key(for: $0.key), $0.value) }, depth: depth, log: log)) + case let array as [Any]: + return .array(array.map { convert($0, depth: depth + 1, log: log) }) + case let set as Set: + return .array(sortedForOutput(set.map { convert($0.base, depth: depth + 1, log: log) })) + case let set as NSOrderedSet: + return .array(set.array.map { convert($0, depth: depth + 1, log: log) }) + case let enumValue as any RawRepresentable: + return convert(enumValue.rawValue, depth: depth + 1, log: log) + default: + return .string(String(describing: value)) + } + } + + private static func convert(_ number: NSNumber, log: any SDKLog) -> JSONValue { + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return .bool(number.boolValue) + } + if let decimal = number as? NSDecimalNumber { + return decimal.decimalValue.isNaN ? nonFinite("NaN", log) : .number(decimal.stringValue) + } + if CFNumberIsFloatType(number as CFNumber) { + switch CFNumberGetType(number as CFNumber) { + case .float32Type, .floatType: + let value = number.floatValue + return finite(value.isFinite, value.description, log) + default: + let value = number.doubleValue + return finite(value.isFinite, value.description, log) + } + } + // `stringValue` keeps UInt64 values above Int64.max exact. + return .number(number.stringValue) + } + + private static func convertDictionary(_ entries: [(String, Any)], depth: Int, log: any SDKLog) -> JSONObject { + // Swift dictionaries have no order; sorted keys make the same input give the same bytes. + var object = JSONObject() + for (key, value) in entries.sorted(by: { $0.0 < $1.0 }) { + object[key] = convert(value, depth: depth + 1, log: log) + } + return object + } + + private static func key(for key: AnyHashable) -> String { + if let text = key.base as? String { return text } + return String(describing: key.base) + } + + private static func sortedForOutput(_ values: [JSONValue]) -> [JSONValue] { + values + .map { (String(decoding: JSON.encode($0), as: UTF8.self), $0) } + .sorted { $0.0 < $1.0 } + .map(\.1) + } + + /// JSON has no NaN or Infinity; sending one would make the collector reject the whole batch. + private static func finite(_ isFinite: Bool, _ literal: String, _ log: any SDKLog) -> JSONValue { + isFinite ? .number(literal) : nonFinite(literal, log) + } + + private static func nonFinite(_ literal: String, _ log: any SDKLog) -> JSONValue { + log.warn("A property value of \(literal) can't be sent as JSON and was replaced with null") + return .null + } + + /// Optionals boxed in `Any`, such as `["coupon": coupon as Any]`, unwrapped or null. + private static func unwrapped(_ value: Any) -> Any? { + var current = value + while let optional = current as? AnyOptional { + guard let wrapped = optional.wrappedAny else { return nil } + current = wrapped + } + return current + } +} + +private protocol AnyOptional { + var wrappedAny: Any? { get } +} + +extension Optional: AnyOptional { + fileprivate var wrappedAny: Any? { + switch self { + case .some(let wrapped): return wrapped + case .none: return nil + } + } +} diff --git a/clients/ios/Sources/SudoQuery/Internal/Platform.swift b/clients/ios/Sources/SudoQuery/Internal/Platform.swift new file mode 100644 index 0000000..7110d4f --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/Platform.swift @@ -0,0 +1,160 @@ +import Foundation +import os.log + +/// A value guarded by a lock, for the little state that more than one queue touches. +final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + init(_ value: Value) { + self.value = value + } + + func withLock(_ body: (inout Value) throws -> Result) rethrows -> Result { + lock.lock() + defer { lock.unlock() } + return try body(&value) + } + + var current: Value { + withLock { $0 } + } +} + +protocol TimeSource: Sendable { + /// Wall-clock milliseconds since 1970, for event timestamps, file ages and sessions. + func nowMillis() -> Int64 + + /// Monotonic milliseconds that ignore clock changes, for retry backoff. + func elapsedMillis() -> Int64 +} + +struct SystemTime: TimeSource { + func nowMillis() -> Int64 { + millis(Date().timeIntervalSince1970) + } + + // The same clock as `DispatchQueue.asyncAfter`, so a backoff wake-up and `isBackingOff` agree. + // (`ProcessInfo.systemUptime` would need a privacy-manifest reason.) + func elapsedMillis() -> Int64 { + Int64(clamping: DispatchTime.now().uptimeNanoseconds / 1_000_000) + } +} + +/// Seconds to whole milliseconds, rounded down. Saturates instead of trapping on huge or NaN input. +func millis(_ seconds: TimeInterval) -> Int64 { + let value = (seconds * 1000).rounded(.down) + if value.isNaN { return 0 } + if value >= Double(Int64.max) { return .max } + if value < Double(Int64.min) { return .min } + return Int64(value) +} + +protocol Cancellable: Sendable { + func cancel() +} + +protocol WorkScheduler: Sendable { + /// Runs `work` on `queue` after `delayMillis`, unless it is cancelled first. + func schedule(afterMillis delayMillis: Int64, on queue: DispatchQueue, _ work: @escaping @Sendable () -> Void) -> any Cancellable +} + +struct DispatchWorkScheduler: WorkScheduler { + func schedule(afterMillis delayMillis: Int64, on queue: DispatchQueue, _ work: @escaping @Sendable () -> Void) -> any Cancellable { + let item = ScheduledWork(work) + queue.asyncAfter(deadline: .now() + .milliseconds(Int(clamping: max(0, delayMillis)))) { item.run() } + return item + } +} + +private final class ScheduledWork: Cancellable, @unchecked Sendable { + private let state: Locked<(@Sendable () -> Void)?> + + init(_ work: @escaping @Sendable () -> Void) { + state = Locked(work) + } + + func run() { + let work = state.withLock { work -> (@Sendable () -> Void)? in + defer { work = nil } + return work + } + work?() + } + + func cancel() { + state.withLock { $0 = nil } + } +} + +protocol SDKLog: Sendable { + func debug(_ message: String) + func warn(_ message: String, error: (any Error)?) + func error(_ message: String, error: (any Error)?) +} + +extension SDKLog { + func warn(_ message: String) { + warn(message, error: nil) + } + + func error(_ message: String) { + error(message, error: nil) + } +} + +/// Logs to the unified log under the `io.juspay.sudoquery` subsystem. Debug messages appear only +/// with `debugLogging`; warnings and errors always do. Debug messages are logged at the default +/// level, not `.debug`, so that once switched on they show up in Console and `log show` like +/// Android's logcat lines do. +struct OSLogLog: SDKLog { + let debugEnabled: Bool + + private static let log = OSLog(subsystem: "io.juspay.sudoquery", category: "SudoQuery") + + func debug(_ message: String) { + guard debugEnabled else { return } + os_log("%{public}@", log: Self.log, type: .default, message) + } + + func warn(_ message: String, error: (any Error)?) { + os_log("%{public}@", log: Self.log, type: .default, describe(message, error)) + } + + func error(_ message: String, error: (any Error)?) { + os_log("%{public}@", log: Self.log, type: .error, describe(message, error)) + } + + private func describe(_ message: String, _ error: (any Error)?) -> String { + guard let error else { return message } + return "\(message): \(describeError(error))" + } +} + +/// An error as one line, e.g. `NSURLErrorDomain -1004: Could not connect to the server.` +func describeError(_ error: any Error) -> String { + let nsError = error as NSError + return "\(nsError.domain) \(nsError.code): \(nsError.localizedDescription)" +} + +enum StorageLocation { + /// `Library/Application Support/sudoquery/v1`, created if needed, with `sudoquery` excluded + /// from backup. Application Support is never purged by the OS (unlike Caches). On macOS and Mac + /// Catalyst it can be shared by every app of a user, so the bundle id goes in front of `sudoquery`. + static func defaultRoot() throws -> URL { + var base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + #if os(macOS) || targetEnvironment(macCatalyst) + base.appendPathComponent(Bundle.main.bundleIdentifier ?? ProcessInfo.processInfo.processName, isDirectory: true) + #endif + let folder = base.appendingPathComponent("sudoquery", isDirectory: true) + let root = folder.appendingPathComponent("v1", isDirectory: true) + try FileIO.ensureDirectory(root) + try FileIO.excludeFromBackup(folder) + return root + } +} diff --git a/clients/ios/Sources/SudoQuery/Internal/QueueStore.swift b/clients/ios/Sources/SudoQuery/Internal/QueueStore.swift new file mode 100644 index 0000000..fe452ee --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/QueueStore.swift @@ -0,0 +1,299 @@ +import Foundation + +/// A batch file, named `-.ndjson`. +struct BatchFile: Sendable, Equatable { + let url: URL + let name: String + let seq: Int64 + let createdMillis: Int64 +} + +/// The on-disk event queue, ported from the batch files of hyper-sdk-ios's `HPJPLogPusher`: one +/// immutable file per batch, deleted once the collector acknowledges it, replayed on the next launch. +/// +/// Encoded events are appended as NDJSON lines to one open batch file in `staging/`. When the batch +/// is full (or flushed) it is committed: closed and renamed into `ready/`, after which it is never +/// modified. The uploader sends `ready/` files oldest first and deletes each once delivered. +/// +/// Unlike `HPJPLogPusher`, a batch becomes visible to the uploader only once it is completely +/// written; the queue is ordered by a sequence number, not by an unsorted directory listing, so a +/// wall-clock change can't reorder it (the creation time is only used for expiry); it has size and +/// age limits; and nothing is wiped when the SDK is upgraded. +/// +/// `recover`, `append`, `commit` and `enforceLimits` belong to the writer queue. `readyBatches`, +/// `read` and `delete` are also called by the uploader: committed files never change and deleting +/// one twice is harmless. +final class QueueStore: @unchecked Sendable { + static let maxBatchBytes: Int64 = 512 * 1024 + static let maxQueueBytes: Int64 = 10 * 1024 * 1024 + static let maxAgeMillis: Int64 = 7 * 24 * millisPerHour + private static let millisPerHour: Int64 = 60 * 60 * 1000 + + private let stagingDir: URL + private let readyDir: URL + private let maxEventsPerBatch: Int + private let maxBytesPerBatch: Int64 + private let maxQueueBytes: Int64 + private let maxAgeMillis: Int64 + private let time: any TimeSource + private let log: any SDKLog + + // Writer queue only. + private var nextSeq: Int64 = 1 + private var open: OpenBatch? + + private struct OpenBatch { + let file: BatchFile + let fd: Int32 + var events = 0 + var bytes: Int64 = 0 + } + + init( + root: URL, + maxEventsPerBatch: Int, + time: any TimeSource, + log: any SDKLog, + maxBytesPerBatch: Int64 = QueueStore.maxBatchBytes, + maxQueueBytes: Int64 = QueueStore.maxQueueBytes, + maxAgeMillis: Int64 = QueueStore.maxAgeMillis + ) { + stagingDir = root.appendingPathComponent("staging", isDirectory: true) + readyDir = root.appendingPathComponent("ready", isDirectory: true) + self.maxEventsPerBatch = maxEventsPerBatch + self.maxBytesPerBatch = maxBytesPerBatch + self.maxQueueBytes = maxQueueBytes + self.maxAgeMillis = maxAgeMillis + self.time = time + self.log = log + } + + /// Commits any batch a previous process left open, and picks the next sequence number. Call + /// once, first. + func recover() throws { + try FileIO.ensureDirectory(stagingDir) + try FileIO.ensureDirectory(readyDir) + var recovered = 0 + for name in FileIO.listNames(stagingDir) { + let url = stagingDir.appendingPathComponent(name) + if Self.parseName(name) == nil || (FileIO.fileSize(url) ?? 0) == 0 { + FileIO.removeFile(url) + } else { + moveToReady(url, name: name) + recovered += 1 + } + } + if recovered > 0 { + log.debug("Recovered \(recovered) unsent batch(es) from the previous run") + } + nextSeq = (FileIO.listNames(readyDir).compactMap { Self.parseName($0)?.seq }.max() ?? 0) + 1 + } + + /// Why `append` failed, and whether it still committed a batch (the one before the event). + struct AppendFailure: Error { + let cause: any Error + let committed: Bool + } + + /// Appends one encoded event (without a trailing newline) with a single unbuffered write, so it + /// reaches the file system even if the process dies right after. Returns true if a batch was + /// committed, i.e. there is something new to upload. Throws `AppendFailure`. + func append(_ event: [UInt8]) throws -> Bool { + var committed = false + if let current = open, current.bytes + Int64(event.count) + 1 > maxBytesPerBatch { + committed = commit() + } + + var record = event + record.append(UInt8(ascii: "\n")) + do { + let batch: OpenBatch + if let current = open { + batch = current + } else { + batch = try openBatch() + open = batch + } + do { + try FileIO.writeAll(batch.fd, record, name: batch.file.name) + } catch { + // Keep the events already written; the reader skips the partial line. + committed = commit() || committed + throw error + } + } catch { + throw AppendFailure(cause: error, committed: committed) + } + open?.events += 1 + open?.bytes += Int64(record.count) + + if let events = open?.events, events >= maxEventsPerBatch { + committed = commit() || committed + } + return committed + } + + /// Closes the open batch and moves it to `ready/`. Returns true if it held any events. + @discardableResult + func commit() -> Bool { + guard let batch = open else { return false } + open = nil + do { + try FileIO.closeFile(batch.fd, name: batch.file.name) + } catch { + log.warn("Could not close \(batch.file.name)", error: error) + } + if batch.events == 0 { + FileIO.removeFile(batch.file.url) + return false + } + moveToReady(batch.file.url, name: batch.file.name) + return true + } + + /// Committed batches, oldest first. + func readyBatches() -> [BatchFile] { + FileIO.listNames(readyDir) + .compactMap { name in + Self.parseName(name).map { + BatchFile(url: readyDir.appendingPathComponent(name), name: name, seq: $0.seq, createdMillis: $0.createdMillis) + } + } + .sorted { $0.seq < $1.seq } + } + + /// The events of a committed batch, exactly as they were written, or nil if the file is gone. + /// Lines that aren't a JSON object, such as a write cut short by a crash, are skipped. + func read(_ file: BatchFile) -> [Data]? { + let data: Data + do { + data = try FileIO.readAll(file.url) + } catch { + if FileIO.exists(file.url) { + log.warn("Could not read \(file.name)", error: error) + } + return nil + } + var events: [Data] = [] + var skipped = 0 + for line in data.split(separator: UInt8(ascii: "\n"), omittingEmptySubsequences: true) { + if line.allSatisfy({ $0 == UInt8(ascii: " ") || $0 == UInt8(ascii: "\t") || $0 == UInt8(ascii: "\r") }) { + continue + } + let event = Data(line) + if (try? JSONSerialization.jsonObject(with: event)) is [String: Any] { + events.append(event) + } else { + skipped += 1 + } + } + if skipped > 0 { + log.warn("Skipped \(skipped) unreadable event(s) in \(file.name)") + } + return events + } + + func delete(_ file: BatchFile) { + if !FileIO.removeFile(file.url) && FileIO.exists(file.url) { + log.warn("Could not delete \(file.name)") + } + } + + /// Drops batches older than the maximum age, then the oldest batches while the queue is over + /// its size limit, down to 80 % of it. A file's size is taken before it is deleted. + func enforceLimits() { + let now = time.nowMillis() + var expired = 0 + var kept: [(file: BatchFile, size: Int64)] = [] + for file in readyBatches() { + // A batch dated in the future (the clock moved back) counts as fresh. + if now - file.createdMillis > maxAgeMillis { + if FileIO.removeFile(file.url) { expired += 1 } + } else { + kept.append((file, FileIO.fileSize(file.url) ?? 0)) + } + } + + var total = kept.reduce(Int64(0)) { $0 + $1.size } + var trimmed = 0 + if total > maxQueueBytes { + let target = maxQueueBytes / 10 * 8 + for (file, size) in kept { + if total <= target { break } + let removed = FileIO.removeFile(file.url) + // Gone either way: deleted here, or delivered by the uploader meanwhile. + if removed || !FileIO.exists(file.url) { + total -= size + if removed { trimmed += 1 } + } + } + } + + if expired > 0 { + log.warn("Dropped \(expired) unsent batch(es) older than \(maxAgeMillis / Self.millisPerHour) h") + } + if trimmed > 0 { + log.warn("Dropped the \(trimmed) oldest unsent batch(es) to keep the queue under \(maxQueueBytes) bytes") + } + } + + /// Closes the open batch without committing it, as if the process had ended. For tests. + func closeOpenBatch() { + guard let batch = open else { return } + open = nil + try? FileIO.closeFile(batch.fd, name: batch.file.name) + } + + private func openBatch() throws -> OpenBatch { + let seq = nextSeq + nextSeq += 1 + let created = max(0, time.nowMillis()) + let name = Self.fileName(seq: seq, createdMillis: created) + let url = stagingDir.appendingPathComponent(name) + let fd: Int32 + do { + fd = try FileIO.openForAppend(url) + } catch let failure as FileIO.Failure where failure.code == ENOENT { + // The directory was removed while the app ran; create it again. + try FileIO.ensureDirectory(stagingDir) + fd = try FileIO.openForAppend(url) + } + FileIO.setProtection(url) + return OpenBatch(file: BatchFile(url: url, name: name, seq: seq, createdMillis: created), fd: fd) + } + + private func moveToReady(_ url: URL, name: String) { + let destination = readyDir.appendingPathComponent(name) + do { + try FileIO.moveFile(url, to: destination) + } catch let failure as FileIO.Failure where failure.code == ENOENT && FileIO.exists(url) { + do { + try FileIO.ensureDirectory(readyDir) + try FileIO.moveFile(url, to: destination) + } catch { + log.warn("Could not move \(name) to the upload queue", error: error) + } + } catch { + log.warn("Could not move \(name) to the upload queue", error: error) + } + } + + static func fileName(seq: Int64, createdMillis: Int64) -> String { + let digits = String(seq) + let padded = digits.count < 10 ? String(repeating: "0", count: 10 - digits.count) + digits : digits + return "\(padded)-\(createdMillis).ndjson" + } + + static func parseName(_ name: String) -> (seq: Int64, createdMillis: Int64)? { + let suffix = ".ndjson" + guard name.hasSuffix(suffix) else { return nil } + let parts = name.dropLast(suffix.count).split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 2, let seq = number(parts[0]), let created = number(parts[1]) else { return nil } + return (seq, created) + } + + private static func number(_ text: Substring) -> Int64? { + guard (1...18).contains(text.count), text.allSatisfy({ ("0"..."9").contains($0) }) else { return nil } + return Int64(text) + } +} diff --git a/clients/ios/Sources/SudoQuery/Internal/SudoQueryClient.swift b/clients/ios/Sources/SudoQuery/Internal/SudoQueryClient.swift new file mode 100644 index 0000000..064ea45 --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/SudoQueryClient.swift @@ -0,0 +1,264 @@ +import Foundation + +/// What a client needs from its surroundings. Tests replace parts of it. +struct ClientEnvironment: Sendable { + var storageRoot: @Sendable () throws -> URL = { try StorageLocation.defaultRoot() } + var time: any TimeSource = SystemTime() + var scheduler: any WorkScheduler = DispatchWorkScheduler() + var makeTransport: @Sendable (URL, [(name: String, value: String)]) -> any Transport = { + URLSessionTransport(endpoint: $0, headers: $1) + } + var timezone: @Sendable () -> String? = { TimeZone.autoupdatingCurrent.identifier } + var makeLog: @Sendable (_ debugEnabled: Bool) -> any SDKLog = { OSLogLog(debugEnabled: $0) } + var observesLifecycle = true +} + +/// One initialized SDK. App threads only enqueue commands; the writer queue handles them in order, +/// and the uploader does the network I/O on its own queue, so a slow network never blocks writes. +/// Nothing here runs on the main thread (hyper-sdk-ios did its log file I/O there). +final class SudoQueryClient: @unchecked Sendable { + static let maxQueuedTracks = 1_000 + + private let writerQueue = DispatchQueue(label: "io.juspay.sudoquery.writer", qos: .utility) + private let log: any SDKLog + private let backlog = Locked(Backlog()) + private let user = UserMirror() + + private struct Backlog { + var queued = 0 + var overflowWarned = false + } + + // Writer queue only. + private var writer: EventWriter? + private var store: QueueStore? + private var uploader: Uploader? + private var transport: (any Transport)? + + // Set while starting, read by shutdown(). + private var timer: (any DispatchSourceTimer)? + private var lifecycle: AnyObject? + + private init(log: any SDKLog) { + self.log = log + } + + static func start(config: SudoQueryConfig, log: any SDKLog, environment: ClientEnvironment) -> SudoQueryClient { + let client = SudoQueryClient(log: log) + // Storage touches the disk, so it is set up on the writer queue, not on the app's thread. + // Commands sent meanwhile wait behind it. + client.writerQueue.async { + client.setUp(config: config, environment: environment) + } + if config.flushIntervalMillis > 0 { + client.startTimer(intervalMillis: config.flushIntervalMillis) + } + if environment.observesLifecycle { + client.observeLifecycle() + } + return client + } + + func track(_ name: String, timestampMillis: Int64, properties: JSONObject) { + // Only events are capped: control commands such as setUser must never be dropped. + let (accepted, warn) = backlog.withLock { state -> (Bool, Bool) in + guard state.queued < Self.maxQueuedTracks else { + defer { state.overflowWarned = true } + return (false, !state.overflowWarned) + } + state.queued += 1 + return (true, false) + } + if warn { + log.warn("More than \(Self.maxQueuedTracks) events are waiting to be stored; dropping new ones until they are") + } + if accepted { + send(.track(name: name, timestampMillis: timestampMillis, properties: properties)) + } + } + + func setUser(_ userId: String?) { + user.set(userId) + send(.setUser(userId)) + } + + func currentUser() -> String? { + user.get() + } + + func reset() { + user.set(nil) + send(.reset) + } + + func flush() { + send(.flush) + } + + func onForeground() { + send(.foreground) + } + + func onBackground(done: @escaping @Sendable () -> Void) { + send(.background(done: done)) + } + + /// Runs `work` on the writer queue after every command sent so far. For tests. + func onWriterQueueForTests(_ work: @escaping @Sendable () -> Void) { + send(.barrier(work)) + } + + /// Waits until every command sent so far has been handled and the uploader is idle. For tests; + /// never call it on the writer queue. + @discardableResult + func awaitIdle(timeout: TimeInterval = 10) -> Bool { + let done = DispatchSemaphore(value: 0) + writerQueue.async { + if let uploader = self.uploader { + uploader.whenIdle { done.signal() } + } else { + done.signal() + } + } + return done.wait(timeout: .now() + timeout) == .success + } + + /// Stops the timer, the lifecycle observers and the network session. For tests: the SDK + /// otherwise lives as long as the process. + func shutdown() { + timer?.cancel() + #if canImport(UIKit) && !os(watchOS) + (lifecycle as? AppLifecycleObserver)?.unregister() + #endif + writerQueue.sync { + store?.closeOpenBatch() + uploader?.shutdown() + (transport as? URLSessionTransport)?.invalidate() + writer = nil + } + } + + private func setUp(config: SudoQueryConfig, environment: ClientEnvironment) { + let root: URL + do { + root = try environment.storageRoot() + } catch { + log.error("Could not set up the event store; events will be dropped", error: error) + return + } + let store = QueueStore(root: root, maxEventsPerBatch: config.batchSize, time: environment.time, log: log) + let headers = requestHeaders( + orgId: config.orgId, + workspaceId: config.workspaceId, + token: config.token, + custom: config.headers, + userAgent: defaultUserAgent() + ) + let transport = environment.makeTransport(config.endpointURL, headers) + let uploader = Uploader( + store: store, + transport: transport, + time: environment.time, + scheduler: environment.scheduler, + log: log, + timezone: environment.timezone + ) + let writer = EventWriter( + // The only place that names the event type; see `CanonicalEvent` in Event.swift. + encode: { CanonicalEvent.encode($0) }, + settings: WriterSettings( + orgId: config.orgId, + workspaceId: config.workspaceId, + source: config.source, + fixedSessionId: config.sessionId, + sessionTimeoutMillis: config.sessionTimeoutMillis + ), + store: store, + identityStore: IdentityStore(url: root.appendingPathComponent("identity.json"), log: log), + uploads: uploader, + log: log, + onStoredUser: { [user] in user.loaded($0) } + ) + self.store = store + self.uploader = uploader + self.transport = transport + self.writer = writer + user.loaded(writer.start()) + } + + private func send(_ command: Command) { + writerQueue.async { + self.handle(command) + } + } + + private func handle(_ command: Command) { + if case .track = command { + backlog.withLock { state in + state.queued -= 1 + if state.queued < Self.maxQueuedTracks / 2 { state.overflowWarned = false } + } + } + guard let writer else { + // No storage: nothing is stored, but nobody waiting on a command is left hanging. + switch command { + case .barrier(let done): done() + case .background(let done): done?() + default: break + } + return + } + writer.handle(command) + } + + /// A `DispatchSourceTimer` on the writer queue. hyper-sdk-ios used `NSTimer`s on the main run + /// loop, which stop firing while the user scrolls. + private func startTimer(intervalMillis: Int64) { + let timer = DispatchSource.makeTimerSource(queue: writerQueue) + let interval = DispatchTimeInterval.milliseconds(Int(clamping: intervalMillis)) + timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) + timer.setEventHandler { [weak self] in + self?.handle(.tick) + } + timer.resume() + self.timer = timer + } + + private func observeLifecycle() { + #if canImport(UIKit) && !os(watchOS) + let observer = AppLifecycleObserver( + onBackground: { [weak self] done in + if let self { + self.onBackground(done: done) + } else { + done() + } + }, + onForeground: { [weak self] in + self?.onForeground() + } + ) + observer.register() + lifecycle = observer + #endif + } +} + +/// `SudoQuery.user` must reflect `setUser` at once, before the writer has loaded the stored user. +private final class UserMirror: Sendable { + private let state = Locked<(value: String?, setByApp: Bool)>((nil, false)) + + func set(_ userId: String?) { + state.withLock { $0 = (userId, true) } + } + + func loaded(_ userId: String?) { + state.withLock { state in + if !state.setByApp { state.value = userId } + } + } + + func get() -> String? { + state.withLock { $0.value } + } +} diff --git a/clients/ios/Sources/SudoQuery/Internal/SuperProperties.swift b/clients/ios/Sources/SudoQuery/Internal/SuperProperties.swift new file mode 100644 index 0000000..f380ead --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/SuperProperties.swift @@ -0,0 +1,22 @@ +/// Properties added to every event. In memory only, as in the TS client. +final class SuperProperties: Sendable { + private let values = Locked(JSONObject()) + + func set(_ key: String, _ value: JSONValue) { + values.withLock { $0[key] = value } + } + + func clear() { + values.withLock { $0 = JSONObject() } + } + + func snapshot() -> JSONObject { + values.current + } + + /// The event's own properties win, like `{...superProperties, ...properties}` in the TS client. + func mergeUnder(_ properties: JSONObject) -> JSONObject { + let current = snapshot() + return current.isEmpty ? properties : current.merging(properties) + } +} diff --git a/clients/ios/Sources/SudoQuery/Internal/Uploader.swift b/clients/ios/Sources/SudoQuery/Internal/Uploader.swift new file mode 100644 index 0000000..389128e --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Internal/Uploader.swift @@ -0,0 +1,301 @@ +import Foundation + +/// What the event writer needs from the uploader. +protocol UploadControl: AnyObject, Sendable { + /// Asks for queued batches to be sent. Cheap; requests made while one is pending collapse into it. + func requestUpload() + + /// Forgets the retry backoff, e.g. when the app comes back to the foreground. + func resetBackoff() + + /// True while uploads are failing and waiting to retry. + var isBackingOff: Bool { get } + + /// Calls `done` once the uploader has nothing it can send right now: every batch has been + /// tried, or it is waiting out a backoff. + func whenIdle(_ done: @escaping @Sendable () -> Void) +} + +/// How the SDK treats a collector response. +enum Delivery: Equatable { + /// Delivered: delete the batch. + case sent + /// The collector will never accept this batch: delete it and move on. + case rejected + /// Keep the batch and try again later. + case retry +} + +/// 400 (bad headers or body), 413 (too large) and 422 can't succeed on a resend. Everything else +/// (redirects, 401/403/404 configuration problems, 408, 429, 5xx) may, so the batch is kept. +func classify(_ code: Int) -> Delivery { + switch code { + case 200...299: return .sent + case 400, 413, 422: return .rejected + default: return .retry + } +} + +/// Sends committed batches to the collector, one file per request, oldest first. Ported from +/// hyper-sdk-ios's `HPJPLogPusher` (`processBatchLogs`), which deleted a batch after any response +/// whose status wasn't in an (empty by default) retry list, so 4xx, 5xx and offline all lost data, +/// and which retried back to back. Here any 2xx counts as delivered, rejected batches are dropped, +/// and every other failure keeps the batch and backs off exponentially with jitter. +/// +/// It runs on its own serial queue and never blocks it: a request is a `URLSession` task, and its +/// result is handled back on the queue. Only one request is in flight at a time. +final class Uploader: UploadControl, @unchecked Sendable { + static let baseBackoffMillis: Int64 = 5_000 + static let maxBackoffMillis: Int64 = 5 * 60 * 1000 + static let maxRetryAfterSeconds: Int64 = 60 * 60 + + private let queue = DispatchQueue(label: "io.juspay.sudoquery.uploader", qos: .utility) + private let store: QueueStore + private let transport: any Transport + private let time: any TimeSource + private let scheduler: any WorkScheduler + private let log: any SDKLog + private let timezone: @Sendable () -> String? + private let random: @Sendable (ClosedRange) -> Int64 + + /// Read by the writer queue too. + private let backoff = Locked(Backoff()) + + private struct Backoff { + var failures = 0 + var retryAtMillis: Int64 = 0 + } + + // Uploader queue only. + private var stopped = false + private var pending = false + private var draining = false + private var attempted = Set() + private var pass: [BatchFile] = [] + private var passIndex = 0 + private var wake: (any Cancellable)? + private var wakeAtMillis: Int64? + private var waiters: [@Sendable () -> Void] = [] + + init( + store: QueueStore, + transport: any Transport, + time: any TimeSource, + scheduler: any WorkScheduler, + log: any SDKLog, + timezone: @escaping @Sendable () -> String? = { TimeZone.autoupdatingCurrent.identifier }, + random: @escaping @Sendable (ClosedRange) -> Int64 = { Int64.random(in: $0) } + ) { + self.store = store + self.transport = transport + self.time = time + self.scheduler = scheduler + self.log = log + self.timezone = timezone + self.random = random + } + + func requestUpload() { + queue.async { + self.pending = true + self.pump() + } + } + + func resetBackoff() { + backoff.withLock { $0 = Backoff() } + } + + var isBackingOff: Bool { + time.elapsedMillis() < backoff.withLock { $0.retryAtMillis } + } + + func whenIdle(_ done: @escaping @Sendable () -> Void) { + queue.async { + if self.isIdle { + done() + } else { + self.waiters.append(done) + } + } + } + + /// Sends every ready batch now, even while backing off, then calls `done`. For tests. + func drainNow(_ done: @escaping @Sendable () -> Void) { + queue.async { + if !self.draining { self.startDrain() } + if self.isIdle { done() } else { self.waiters.append(done) } + } + } + + /// Stops sending, for good, and cancels a pending backoff wake-up. For tests. + func shutdown() { + queue.sync { + stopped = true + wake?.cancel() + wake = nil + wakeAtMillis = nil + fireWaiters() + } + } + + private var isIdle: Bool { + stopped || (!draining && (!pending || isBackingOff)) + } + + /// Starts a drain if one is wanted and allowed; otherwise, when the backoff ends, wakes up to + /// retry without being asked again. + private func pump() { + if stopped { + fireWaiters() + return + } + guard !draining else { return } + let now = time.elapsedMillis() + let retryAt = backoff.withLock { $0.retryAtMillis } + if retryAt > now { + if pending { scheduleWake(at: retryAt, now: now) } + fireWaiters() + return + } + guard pending else { + fireWaiters() + return + } + pending = false + startDrain() + } + + private func scheduleWake(at retryAt: Int64, now: Int64) { + if wakeAtMillis == retryAt { return } + wake?.cancel() + wakeAtMillis = retryAt + wake = scheduler.schedule(afterMillis: retryAt - now, on: queue) { [weak self] in + guard let self else { return } + self.wake = nil + self.wakeAtMillis = nil + self.pump() + } + } + + private func startDrain() { + draining = true + attempted = [] + pass = [] + passIndex = 0 + sendNext() + } + + /// Sends the next ready batch. Each file is tried at most once per drain, and the queue is + /// listed again after each pass so batches committed meanwhile go out too. + private func sendNext() { + while true { + if stopped { + finishDrain() + return + } + if passIndex >= pass.count { + pass = store.readyBatches().filter { !attempted.contains($0.name) } + passIndex = 0 + if pass.isEmpty { + finishDrain() + return + } + } + let file = pass[passIndex] + passIndex += 1 + attempted.insert(file.name) + guard let events = store.read(file) else { continue } + if events.isEmpty { + store.delete(file) + continue + } + let body = BatchBody.build(events: events, systemProperties: SystemProperties(geo: nil, timezone: timezone())) + transport.post(body) { result in + self.queue.async { self.handle(result, file: file, count: events.count) } + } + return + } + } + + private func handle(_ result: Result, file: BatchFile, count: Int) { + let response: HTTPResult + switch result { + case .failure(let error): + retryLater("Could not reach the collector (\(describeError(error)))", retryAfterSeconds: nil) + return + case .success(let value): + response = value + } + switch classify(response.code) { + case .sent: + store.delete(file) + backoff.withLock { $0.failures = 0 } + log.debug("Sent \(count) event(s)") + sendNext() + case .rejected: + store.delete(file) + log.error("The collector rejected \(count) event(s) with HTTP \(response.code), dropping them: \(errorText(response.body))") + sendNext() + case .retry: + retryLater("The collector answered HTTP \(response.code)", retryAfterSeconds: response.retryAfterSeconds) + } + } + + /// Stops at the first batch to retry, so the queue stays in order, and backs off. + private func retryLater(_ reason: String, retryAfterSeconds: Int64?) { + let now = time.elapsedMillis() + let (failures, delay) = backoff.withLock { state -> (Int, Int64) in + state.failures += 1 + let delay = backoffMillis(failures: state.failures, retryAfterSeconds: retryAfterSeconds) + state.retryAtMillis = now + delay + return (state.failures, delay) + } + let message = "\(reason); events are kept and retried in \((delay + 999) / 1000) s" + if failures == 1 { + log.warn(message) + } else { + log.debug(message) + } + // Retry on our own once the backoff ends. + pending = true + finishDrain() + } + + /// 5 s doubling to 5 min, with half of it randomized so many devices don't retry in step. + private func backoffMillis(failures: Int, retryAfterSeconds: Int64?) -> Int64 { + let shift = Int64(min(max(failures - 1, 0), 16)) + let exponential = min(Self.baseBackoffMillis << shift, Self.maxBackoffMillis) + let jittered = exponential / 2 + random(0...(exponential / 2)) + let requested = min(max(retryAfterSeconds ?? 0, 0), Self.maxRetryAfterSeconds) * 1000 + return max(jittered, requested) + } + + private func finishDrain() { + draining = false + pass = [] + passIndex = 0 + pump() + } + + private func fireWaiters() { + guard !waiters.isEmpty else { return } + let ready = waiters + waiters = [] + for done in ready { + done() + } + } + + /// The collector's `{"error": "..."}` message, or the raw body (a 413 is plain text). + private func errorText(_ body: String?) -> String { + let text = (body ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + var message: String? + if let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = object["error"] as? String { + message = error + } + return String((message ?? text).prefix(300)) + } +} diff --git a/clients/ios/Sources/SudoQuery/ObjC/SQSudoQuery.swift b/clients/ios/Sources/SudoQuery/ObjC/SQSudoQuery.swift new file mode 100644 index 0000000..ba5d509 --- /dev/null +++ b/clients/ios/Sources/SudoQuery/ObjC/SQSudoQuery.swift @@ -0,0 +1,90 @@ +import Foundation + +/// The Objective-C entry point. It forwards to `SudoQuery`, which Swift code should call instead. +/// +/// ```objc +/// @import SudoQuery; +/// +/// SQSudoQueryConfig *config = +/// [[SQSudoQueryConfig alloc] initWithOrgId:@"acme-store-k3x9qa" +/// workspaceId:@"acme-store-app-9t2r4m" +/// endpoint:@"https://collector.example.com/v1/events/batch"]; +/// NSError *error = nil; +/// if (![SQSudoQuery initializeWithConfig:config error:&error]) { +/// NSLog(@"SudoQuery: %@", error.localizedDescription); +/// } +/// [SQSudoQuery track:@"button_click" properties:@{@"button_id": @"submit"}]; +/// ``` +@objc(SQSudoQuery) +public final class SQSudoQuery: NSObject { + @available(*, unavailable) + override public init() { + super.init() + } + + /// Starts the SDK; see `SudoQuery.initialize(_:)`. Fails only if `config` is invalid, with an + /// error in the `SQSudoQueryConfig.errorDomain` domain. + @objc(initializeWithConfig:error:) + public static func initialize(with config: SQSudoQueryConfig) throws { + SudoQuery.initialize(try config.makeConfig()) + } + + @objc public static var isInitialized: Bool { + SudoQuery.isInitialized + } + + @objc(track:) + public static func track(_ name: String) { + SudoQuery.track(name) + } + + /// Keys that aren't strings (e.g. `@1`) are sent as their description. + @objc(track:properties:) + public static func track(_ name: String, properties: [AnyHashable: Any]?) { + SudoQuery.track(name, anyKeyedProperties: properties) + } + + /// Passing nil removes the user. + @objc(setUser:) + public static func setUser(_ userId: String?) { + if let userId { + SudoQuery.setUser(userId) + } else { + SudoQuery.removeUser() + } + } + + @objc public static func removeUser() { + SudoQuery.removeUser() + } + + @objc public static var user: String? { + SudoQuery.user + } + + @objc(setSuperProperty:value:) + public static func setSuperProperty(_ key: String, value: Any?) { + SudoQuery.setSuperProperty(key, value) + } + + @objc public static var superProperties: [String: Any] { + SudoQuery.superProperties + } + + @objc public static func clearSuperProperties() { + SudoQuery.clearSuperProperties() + } + + @objc public static func flush() { + SudoQuery.flush() + } + + @objc public static func reset() { + SudoQuery.reset() + } + + /// The SDK version. (NSObject already has a `+version` class method.) + @objc public static var sdkVersion: String { + SudoQuery.version + } +} diff --git a/clients/ios/Sources/SudoQuery/ObjC/SQSudoQueryConfig.swift b/clients/ios/Sources/SudoQuery/ObjC/SQSudoQueryConfig.swift new file mode 100644 index 0000000..7e16a80 --- /dev/null +++ b/clients/ios/Sources/SudoQuery/ObjC/SQSudoQueryConfig.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Objective-C configuration for `SQSudoQuery`; Swift code should use `SudoQueryConfig`. The +/// defaults and rules are the same. The values are read when the config is passed to +/// `+[SQSudoQuery initializeWithConfig:error:]`, which is when it is validated; later changes have +/// no effect. Configure it on one thread. +@objc(SQSudoQueryConfig) +public final class SQSudoQueryConfig: NSObject { + /// The domain of the `NSError` that `initializeWithConfig:error:` reports. Its codes are + /// `SQSudoQueryConfigErrorCode` values. + @objc public static let errorDomain = SudoQueryConfigError.errorDomain + + @objc public let orgId: String + @objc public let workspaceId: String + @objc public let endpoint: String + @objc public var token: String? + /// Header names and values, both strings. Typed loosely because Objective-C can't rule out + /// other values, which `initializeWithConfig:error:` then reports instead of crashing. + @objc public var headers: [AnyHashable: Any] = [:] + @objc public var source: String? = SudoQueryConfig.defaultSource + @objc public var sessionId: String? + @objc public var batchSize: Int = SudoQueryConfig.defaultBatchSize + /// Seconds; 0 turns the timer off. + @objc public var flushInterval: TimeInterval = SudoQueryConfig.defaultFlushInterval + /// Seconds. + @objc public var sessionTimeout: TimeInterval = SudoQueryConfig.defaultSessionTimeout + @objc public var debugLogging = false + + @objc(initWithOrgId:workspaceId:endpoint:) + public init(orgId: String, workspaceId: String, endpoint: String) { + self.orgId = orgId + self.workspaceId = workspaceId + self.endpoint = endpoint + super.init() + } + + @available(*, unavailable) + override public init() { + orgId = "" + workspaceId = "" + endpoint = "" + super.init() + } + + func makeConfig() throws -> SudoQueryConfig { + var stringHeaders: [String: String] = [:] + for (key, value) in headers { + guard let name = key.base as? String, let text = value as? String else { + throw SudoQueryConfigError(.invalidHeader, "header \(key.base) must have a string name and a string value") + } + stringHeaders[name] = text + } + return try SudoQueryConfig( + orgId: orgId, + workspaceId: workspaceId, + endpoint: endpoint, + token: token, + headers: stringHeaders, + source: source, + sessionId: sessionId, + batchSize: batchSize, + flushInterval: flushInterval, + sessionTimeout: sessionTimeout, + debugLogging: debugLogging + ) + } +} diff --git a/clients/ios/Sources/SudoQuery/Resources/PrivacyInfo.xcprivacy b/clients/ios/Sources/SudoQuery/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..3bd154b --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,63 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeProductInteraction + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeUserID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeCoarseLocation + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + + diff --git a/clients/ios/Sources/SudoQuery/SudoQuery.swift b/clients/ios/Sources/SudoQuery/SudoQuery.swift new file mode 100644 index 0000000..76339f5 --- /dev/null +++ b/clients/ios/Sources/SudoQuery/SudoQuery.swift @@ -0,0 +1,155 @@ +import Foundation + +/// The SudoQuery iOS SDK. Call `initialize(_:)` once, typically in +/// `application(_:didFinishLaunchingWithOptions:)` or your `App`'s `init`, then `track` events +/// from anywhere. +/// +/// Every method is thread-safe, returns quickly and never throws: events are written to disk and +/// sent to the collector on background queues. Events survive app restarts and are retried until +/// the collector accepts them (at-least-once delivery). +public enum SudoQuery { + private static let state = Locked(FacadeState()) + private static let superPropertyStore = SuperProperties() + + struct FacadeState { + var client: SudoQueryClient? + var log: any SDKLog = OSLogLog(debugEnabled: false) + var environment = ClientEnvironment() + var warnedNotInitialized = false + } + + /// Starts the SDK. Only the first call has an effect; later calls are ignored, as in the TS + /// client. Returns at once: storage is set up on a background queue. + public static func initialize(_ config: SudoQueryConfig) { + state.withLock { state in + if state.client != nil { + state.log.debug("initialize called again; keeping the first configuration") + return + } + let log = state.environment.makeLog(config.debugLogging) + warnIfNotSlug("orgId", config.orgId, log) + warnIfNotSlug("workspaceId", config.workspaceId, log) + state.log = log + state.client = SudoQueryClient.start(config: config, log: log, environment: state.environment) + } + } + + /// True once `initialize(_:)` has started the SDK. + public static var isInitialized: Bool { + state.withLock { $0.client != nil } + } + + /// Records an event. `properties` are copied and sent as a JSON object, merged over the super + /// properties (keys given here win). Nested dictionaries and arrays are supported; `Date`s are + /// sent as ISO 8601 strings, and NaN or infinite numbers become null. Events tracked before + /// `initialize(_:)` are dropped with a warning. + public static func track(_ name: String, properties: [String: Any] = [:]) { + record(name) { JSONConversion.object(from: properties, log: $0) } + } + + /// `track` for dictionaries from Objective-C, whose keys may be numbers. + static func track(_ name: String, anyKeyedProperties properties: [AnyHashable: Any]?) { + record(name) { JSONConversion.object(fromAnyKeys: properties ?? [:], log: $0) } + } + + /// Sets the user id sent as `actor_id` with every later event. It is kept across app restarts. + public static func setUser(_ userId: String) { + clientOrWarn("setUser")?.client.setUser(userId) + } + + /// Stops sending a user id. The anonymous id and session are kept; see `reset()` for a full logout. + public static func removeUser() { + clientOrWarn("removeUser")?.client.setUser(nil) + } + + /// The current user id, or nil. + public static var user: String? { + state.withLock { $0.client }?.currentUser() + } + + /// Adds a property to every later event. Kept in memory only, and usable before `initialize(_:)`. + public static func setSuperProperty(_ key: String, _ value: Any?) { + let log = state.withLock { $0.log } + superPropertyStore.set(key, JSONConversion.value(value, log: log)) + } + + /// A copy of the super properties. Nested objects come back as dictionaries, and null as `NSNull`. + public static var superProperties: [String: Any] { + JSONConversion.plain(superPropertyStore.snapshot()) + } + + public static func clearSuperProperties() { + superPropertyStore.clear() + } + + /// Commits the events tracked so far into a batch and starts sending it. Returns at once; if + /// uploads are failing, the batch waits for the next retry. + public static func flush() { + clientOrWarn("flush")?.client.flush() + } + + /// For logout: later events get a new anonymous id and session, and no user id. Super + /// properties are cleared. Events already tracked are still sent unchanged. + public static func reset() { + superPropertyStore.clear() + clientOrWarn("reset")?.client.reset() + } + + static var clientForTests: SudoQueryClient? { + state.withLock { $0.client } + } + + /// Stops the SDK and forgets its state, so a test can initialize again with `environment`. + static func resetForTests(environment: ClientEnvironment = ClientEnvironment(), log: any SDKLog = OSLogLog(debugEnabled: false)) { + let client = state.withLock { state -> SudoQueryClient? in + defer { state = FacadeState(client: nil, log: log, environment: environment, warnedNotInitialized: false) } + return state.client + } + client?.shutdown() + superPropertyStore.clear() + } + + private static func record(_ name: String, _ properties: (any SDKLog) -> JSONObject) { + // The event's time is when the app called track, not when the writer gets to it. + let timestamp = state.withLock { $0.environment.time }.nowMillis() + guard let active = clientOrWarn("track") else { return } + let merged = superPropertyStore.mergeUnder(properties(active.log)) + active.client.track(name, timestampMillis: timestamp, properties: merged) + } + + private static func clientOrWarn(_ operation: String) -> (client: SudoQueryClient, log: any SDKLog)? { + let (client, log, warn) = state.withLock { state -> (SudoQueryClient?, any SDKLog, Bool) in + guard state.client == nil, !state.warnedNotInitialized else { + return (state.client, state.log, false) + } + state.warnedNotInitialized = true + return (nil, state.log, true) + } + if warn { + log.warn("SudoQuery.\(operation) was called before SudoQuery.initialize; such calls are ignored") + } + guard let client else { return nil } + return (client, log) + } + + /// Org and workspace ids are server-generated slugs (e.g. "acme-store-k3x9qa"). Warn, never + /// throw, when one deviates, so the misconfiguration is visible without breaking the app. + private static func warnIfNotSlug(_ field: String, _ value: String, _ log: any SDKLog) { + guard !isSlug(value) else { return } + log.warn( + "config option \"\(field)\" value \"\(value)\" does not match the expected server-generated slug shape " + + "/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/ (e.g. \"acme-store-k3x9qa\"); the collector may reject events carrying it." + ) + } + + /// `^[a-z][a-z0-9-]{4,28}[a-z0-9]$`, the collector's `is_valid_slug`. + static func isSlug(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (6...30).contains(bytes.count), let first = bytes.first, let last = bytes.last else { return false } + func lower(_ byte: UInt8) -> Bool { (UInt8(ascii: "a")...UInt8(ascii: "z")).contains(byte) } + func digit(_ byte: UInt8) -> Bool { (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte) } + return lower(first) + && (lower(last) || digit(last)) + && bytes.allSatisfy { lower($0) || digit($0) || $0 == UInt8(ascii: "-") } + } +} diff --git a/clients/ios/Sources/SudoQuery/SudoQueryConfig.swift b/clients/ios/Sources/SudoQuery/SudoQueryConfig.swift new file mode 100644 index 0000000..6afbf3d --- /dev/null +++ b/clients/ios/Sources/SudoQuery/SudoQueryConfig.swift @@ -0,0 +1,163 @@ +import Foundation + +/// Configuration for `SudoQuery.initialize(_:)`. +/// +/// ```swift +/// let config = try SudoQueryConfig( +/// orgId: "acme-store-k3x9qa", +/// workspaceId: "acme-store-app-9t2r4m", +/// endpoint: "https://collector.example.com/v1/events/batch" +/// ) +/// ``` +public struct SudoQueryConfig: Sendable { + public static let defaultSource = "ios" + public static let defaultBatchSize = 50 + public static let maxBatchSize = 500 + public static let defaultFlushInterval: TimeInterval = 30 + public static let defaultSessionTimeout: TimeInterval = 30 * 60 + + let orgId: String + let workspaceId: String + let endpoint: String + let endpointURL: URL + let token: String? + let headers: [String: String] + let source: String? + let sessionId: String? + let batchSize: Int + let flushIntervalMillis: Int64 + let sessionTimeoutMillis: Int64 + let debugLogging: Bool + + /// - Parameters: + /// - orgId: The organization id (a server-generated slug such as `acme-store-k3x9qa`), sent as + /// `x-org-id` and in every event. + /// - workspaceId: The project's id (a server-generated slug): the dashboard's Project ID, which + /// its switcher copies as the workspace ID. Sent as `x-workspace-id` and in every event as + /// `workspace_id`. + /// - endpoint: The full URL of the collector's batch route, e.g. + /// `https://collector.example.com/v1/events/batch`. + /// - token: Sent as `Authorization: Bearer `, for a proxy or gateway in front of the collector. + /// - headers: Extra headers for every request. They can replace `Content-Type` and `User-Agent`, + /// but not `x-org-id`, `x-workspace-id` or the bearer token. + /// - source: The `source` of every event. + /// - sessionId: Use this session id for every event instead of SDK-managed sessions. + /// - batchSize: Events per batch file and per request, 1 to 500. + /// - flushInterval: Seconds between sends of pending events even if a batch isn't full; 0 turns + /// the timer off. + /// - sessionTimeout: A new session starts when no event was tracked for this many seconds. + /// - debugLogging: Log debug details to the unified log (subsystem `io.juspay.sudoquery`). + /// Warnings and errors are always logged. + /// - Throws: `SudoQueryConfigError` if an id is blank, the endpoint isn't an http(s) URL, a + /// value is out of range, or a header contains a line break. + public init( + orgId: String, + workspaceId: String, + endpoint: String, + token: String? = nil, + headers: [String: String] = [:], + source: String? = SudoQueryConfig.defaultSource, + sessionId: String? = nil, + batchSize: Int = SudoQueryConfig.defaultBatchSize, + flushInterval: TimeInterval = SudoQueryConfig.defaultFlushInterval, + sessionTimeout: TimeInterval = SudoQueryConfig.defaultSessionTimeout, + debugLogging: Bool = false + ) throws { + guard !orgId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw SudoQueryConfigError(.blankOrgId, "orgId must not be blank") + } + guard !workspaceId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw SudoQueryConfigError(.blankWorkspaceId, "workspaceId must not be blank") + } + guard let url = Self.parseURL(endpoint) else { + throw SudoQueryConfigError(.invalidEndpoint, "endpoint is not a valid URL: \(endpoint)") + } + guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http", + let host = url.host, !host.isEmpty else { + throw SudoQueryConfigError(.invalidEndpoint, "endpoint must be an http(s) URL: \(endpoint)") + } + guard (1...Self.maxBatchSize).contains(batchSize) else { + throw SudoQueryConfigError(.batchSizeOutOfRange, "batchSize must be between 1 and \(Self.maxBatchSize), was \(batchSize)") + } + guard flushInterval.isFinite, flushInterval >= 0 else { + throw SudoQueryConfigError(.invalidFlushInterval, "flushInterval must be a non-negative number of seconds, was \(flushInterval)") + } + guard sessionTimeout.isFinite, sessionTimeout > 0 else { + throw SudoQueryConfigError(.invalidSessionTimeout, "sessionTimeout must be a positive number of seconds, was \(sessionTimeout)") + } + for (name, value) in headers { + if name.isEmpty || Self.hasLineBreak(name) || Self.hasLineBreak(value) { + throw SudoQueryConfigError(.invalidHeader, "header \"\(name)\" must have a name and no line breaks") + } + } + + self.orgId = orgId + self.workspaceId = workspaceId + self.endpoint = endpoint + self.endpointURL = url + self.token = token + self.headers = headers + self.source = source + self.sessionId = sessionId + self.batchSize = batchSize + self.flushIntervalMillis = millis(flushInterval) + self.sessionTimeoutMillis = millis(sessionTimeout) + self.debugLogging = debugLogging + } + + /// Strict on every OS version: from iOS 17, `URL(string:)` percent-encodes invalid characters + /// instead of failing. + private static func parseURL(_ text: String) -> URL? { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + return URL(string: text, encodingInvalidCharacters: false) + } + return URL(string: text) + } + + private static func hasLineBreak(_ text: String) -> Bool { + text.unicodeScalars.contains { $0 == "\r" || $0 == "\n" } + } +} + +/// Why a `SudoQueryConfig` could not be created. From Objective-C it arrives as an `NSError` in +/// the `io.juspay.sudoquery.config` domain, with a `SQSudoQueryConfigErrorCode` code. +public struct SudoQueryConfigError: Error, Equatable, CustomStringConvertible, LocalizedError, CustomNSError { + public let code: SudoQueryConfigErrorCode + public let message: String + + init(_ code: SudoQueryConfigErrorCode, _ message: String) { + self.code = code + self.message = message + } + + public var description: String { + message + } + + public var errorDescription: String? { + message + } + + public static var errorDomain: String { + "io.juspay.sudoquery.config" + } + + public var errorCode: Int { + code.rawValue + } + + public var errorUserInfo: [String: Any] { + [NSLocalizedDescriptionKey: message] + } +} + +@objc(SQSudoQueryConfigErrorCode) +public enum SudoQueryConfigErrorCode: Int, Sendable { + case blankOrgId = 1 + case blankWorkspaceId = 2 + case invalidEndpoint = 3 + case batchSizeOutOfRange = 4 + case invalidFlushInterval = 5 + case invalidSessionTimeout = 6 + case invalidHeader = 7 +} diff --git a/clients/ios/Sources/SudoQuery/Version.swift b/clients/ios/Sources/SudoQuery/Version.swift new file mode 100644 index 0000000..d782acd --- /dev/null +++ b/clients/ios/Sources/SudoQuery/Version.swift @@ -0,0 +1,4 @@ +extension SudoQuery { + /// The SDK version. It is sent in the `User-Agent` header of every upload. + public static let version = "0.1.0" +} diff --git a/clients/ios/Tests/SudoQueryTests/AppLifecycleTests.swift b/clients/ios/Tests/SudoQueryTests/AppLifecycleTests.swift new file mode 100644 index 0000000..86acab0 --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/AppLifecycleTests.swift @@ -0,0 +1,110 @@ +#if canImport(UIKit) && !os(watchOS) +import UIKit +import XCTest +@testable import SudoQuery + +final class AppLifecycleTests: XCTestCase { + private final class FakeBackgroundTasks: BackgroundTasks { + private let state = Locked((next: 0, begun: [Int](), ended: [Int](), expirations: [@Sendable () -> Void]())) + + var begun: [Int] { state.current.begun } + var ended: [Int] { state.current.ended } + + func begin(name: String, expiration: @escaping @Sendable () -> Void) -> UIBackgroundTaskIdentifier? { + state.withLock { state in + state.next += 1 + state.begun.append(state.next) + state.expirations.append(expiration) + return UIBackgroundTaskIdentifier(rawValue: state.next) + } + } + + func end(_ identifier: UIBackgroundTaskIdentifier) { + state.withLock { $0.ended.append(identifier.rawValue) } + } + + func expire(_ index: Int) { + state.current.expirations[index]() + } + } + + private let center = NotificationCenter() + private let tasks = FakeBackgroundTasks() + private let flushes = Locked<[@Sendable () -> Void]>([]) + private let foregrounds = Locked(0) + private var observer: AppLifecycleObserver! + + override func setUp() { + super.setUp() + observer = makeObserver(tasks: { [tasks] in tasks }) + } + + override func tearDown() { + observer.unregister() + super.tearDown() + } + + private func makeObserver(tasks: @escaping @Sendable () -> (any BackgroundTasks)?) -> AppLifecycleObserver { + let observer = AppLifecycleObserver( + center: center, + tasks: tasks, + onBackground: { [flushes] done in flushes.withLock { $0.append(done) } }, + onForeground: { [foregrounds] in foregrounds.withLock { $0 += 1 } } + ) + observer.register() + return observer + } + + private func enterBackground() { + center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + func testGoingToTheBackgroundBeginsATaskAndEndsItOnceWhenTheFlushIsDone() { + enterBackground() + XCTAssertEqual(tasks.begun, [1]) + XCTAssertEqual(flushes.current.count, 1) + XCTAssertEqual(tasks.ended, []) + + flushes.current[0]() + flushes.current[0]() + XCTAssertEqual(tasks.ended, [1]) + } + + func testAnExpiredTaskIsEndedAndNotEndedAgain() { + enterBackground() + tasks.expire(0) + XCTAssertEqual(tasks.ended, [1]) + flushes.current[0]() + XCTAssertEqual(tasks.ended, [1]) + } + + func testASecondTripToTheBackgroundEndsTheFirstTask() { + enterBackground() + enterBackground() + XCTAssertEqual(tasks.begun, [1, 2]) + XCTAssertEqual(tasks.ended, [1]) + flushes.current[1]() + XCTAssertEqual(tasks.ended, [1, 2]) + } + + func testComingToTheForegroundIsReported() { + center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + XCTAssertEqual(foregrounds.current, 1) + } + + func testWithoutAnApplicationTheBatchIsStillFlushed() { + observer.unregister() + observer = makeObserver(tasks: { nil }) + enterBackground() + XCTAssertEqual(flushes.current.count, 1) + flushes.current[0]() + XCTAssertEqual(tasks.begun, []) + } + + func testUnregisteredObserversHearNothing() { + observer.unregister() + enterBackground() + XCTAssertEqual(flushes.current.count, 0) + } +} +#endif diff --git a/clients/ios/Tests/SudoQueryTests/EventWireFormatTests.swift b/clients/ios/Tests/SudoQueryTests/EventWireFormatTests.swift new file mode 100644 index 0000000..0f897bd --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/EventWireFormatTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import SudoQuery + +final class EventWireFormatTests: XCTestCase { + private let input = EventInput( + id: "3b241101-e2bb-4255-8caf-4136c566a962", + name: "button_click", + orgId: "acme-k3x9qa", + workspaceId: "ios-demo1", + sessionId: "9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f", + anonId: "6e8bc430-9c3a-4d6b-9f3e-2a1b0c9d8e7f", + actorId: nil, + source: "ios", + occurredAt: "2026-10-14T09:30:12.345Z", + properties: JSONObject([ + ("app_version", .string("1.0.0")), + ("button_id", .string("submit")), + ("page", .string("/checkout")), + ]) + ) + + func testAnEventV1EncodesExactlyLikeTheTSClientsEvent() throws { + // All 14 keys, in the TS order, with explicit nulls and an unescaped "/". + XCTAssertEqual(String(decoding: EventV1.encode(input), as: UTF8.self), try golden("event")) + } + + func testABatchBodyMatchesTheCollectorsBatchShape() throws { + let body = BatchBody.build( + events: [Data(EventV1.encode(input))], + systemProperties: SystemProperties(geo: nil, timezone: "Asia/Kolkata") + ) + XCTAssertEqual(String(decoding: body, as: UTF8.self), try golden("batch")) + } + + private struct TestEventV2: WireEvent { + let eventId: String + let eventName: String + let anonId: String + + init(_ input: EventInput) { + eventId = input.id + eventName = input.name + anonId = input.anonId + } + + func json() -> JSONObject { + JSONObject([ + ("envelop_version", .string("2.0")), + ("event_id", .string(eventId)), + ("event_name", .string(eventName)), + ("anon_id", .string(anonId)), + ]) + } + } + + func testThePipelineSendsWhateverEventTypeItIsGiven() throws { + // What changing the CanonicalEvent alias does: the same writer, store and uploader, another event type. + let root = makeTempDirectory() + defer { removeDirectory(root) } + let time = FakeTime() + let log = RecordingLog() + let store = QueueStore(root: root, maxEventsPerBatch: 10, time: time, log: log) + let writer = EventWriter( + encode: { TestEventV2.encode($0) }, + settings: WriterSettings(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", source: "ios", fixedSessionId: nil, sessionTimeoutMillis: 30 * 60 * 1000), + store: store, + identityStore: IdentityStore(url: root.appendingPathComponent("identity.json"), log: log), + uploads: FakeUploads(), + log: log + ) + let transport = FakeTransport() + let uploader = Uploader(store: store, transport: transport, time: time, scheduler: ManualScheduler(time: time), log: log, timezone: { "UTC" }) + + _ = writer.start() + writer.handle(.track(name: "signup", timestampMillis: T0, properties: JSONObject())) + writer.handle(.flush) + uploader.drainAndWait() + + let body = try XCTUnwrap(transport.bodyStrings.first) + let pattern = #"^\{"events":\[\{"envelop_version":"2\.0","event_id":"[0-9a-f-]{36}","event_name":"signup","anon_id":"[0-9a-f-]{36}"\}\],"system_properties":\{"geo":null,"timezone":"UTC"\}\}$"# + XCTAssertNotNil(body.range(of: pattern, options: .regularExpression), body) + } + + private func golden(_ name: String) throws -> String { + let url = try XCTUnwrap(Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "golden"), "missing golden/\(name).json") + return try String(contentsOf: url, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/clients/ios/Tests/SudoQueryTests/EventWriterTests.swift b/clients/ios/Tests/SudoQueryTests/EventWriterTests.swift new file mode 100644 index 0000000..fbbf58e --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/EventWriterTests.swift @@ -0,0 +1,299 @@ +import XCTest +@testable import SudoQuery + +final class EventWriterTests: XCTestCase { + private static let timeout: Int64 = 30 * 60 * 1000 + private static let uuidV4 = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + + private let time = FakeTime() + private let log = RecordingLog() + private let uploads = FakeUploads() + private var root: URL! + private var store: QueueStore! + + override func setUp() { + super.setUp() + root = makeTempDirectory() + } + + override func tearDown() { + store?.closeOpenBatch() + removeDirectory(root) + super.tearDown() + } + + private let storedUsers = Locked<[String?]>([]) + + private var identityURL: URL { + root.appendingPathComponent("identity.json") + } + + private func writer(batchSize: Int = 50, maxBytes: Int64 = QueueStore.maxBatchBytes, fixedSessionId: String? = nil, start: Bool = true) -> EventWriter { + store = QueueStore(root: root, maxEventsPerBatch: batchSize, time: time, log: log, maxBytesPerBatch: maxBytes) + let writer = EventWriter( + encode: { CanonicalEvent.encode($0) }, + settings: WriterSettings(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", source: "ios", fixedSessionId: fixedSessionId, sessionTimeoutMillis: Self.timeout), + store: store, + identityStore: IdentityStore(url: identityURL, log: log), + uploads: uploads, + log: log, + onStoredUser: { [storedUsers] user in storedUsers.withLock { $0.append(user) } } + ) + if start { _ = writer.start() } + return writer + } + + private func storedIdentity() -> IdentityState? { + guard case .loaded(let state) = IdentityStore(url: identityURL, log: RecordingLog()).load() else { return nil } + return state + } + + /// Makes the identity file unreadable, as it is before the first unlock after a reboot. + private func lockIdentity(_ locked: Bool) throws { + try FileManager.default.setAttributes([.posixPermissions: locked ? 0o000 : 0o600], ofItemAtPath: identityURL.path) + } + + private func track(_ name: String, at timestamp: Int64? = nil, properties: [(String, String)] = []) -> Command { + .track(name: name, timestampMillis: timestamp ?? time.now, properties: JSONObject(properties.map { ($0.0, JSONValue.string($0.1)) })) + } + + private func sentEvents() -> [[String: Any]] { + store.readyBatches().flatMap { ndjsonObjects($0.url) } + } + + private func matches(_ value: String?, _ pattern: String) -> Bool { + value?.range(of: pattern, options: .regularExpression) != nil + } + + func testATrackedEventCarriesTheIdentityConfigAndTimestamp() throws { + let writer = writer() + writer.handle(track("button_click", at: T0, properties: [("page", "/home")])) + writer.handle(.flush) + + let event = try XCTUnwrap(sentEvents().first) + XCTAssertEqual(sentEvents().count, 1) + XCTAssertEqual(text(event, "envelop_version"), "1.0") + XCTAssertEqual(text(event, "name"), "button_click") + XCTAssertEqual(text(event, "org_id"), "acme-k3x9qa") + XCTAssertEqual(text(event, "workspace_id"), "ios-demo1") + XCTAssertEqual(text(event, "source"), "ios") + XCTAssertEqual(text(event, "occured_at"), "2025-10-09T08:53:20.000Z") + XCTAssertEqual(canonicalJSON(event["properties"] as Any), #"{"page":"/home"}"#) + XCTAssertTrue(event["actor_id"] is NSNull) + XCTAssertTrue(matches(text(event, "id"), Self.uuidV4)) + XCTAssertTrue(matches(text(event, "anon_id"), Self.uuidV4)) + XCTAssertTrue(matches(text(event, "session_id"), Self.uuidV4)) + } + + func testCommandsAreAppliedInTheOrderTheyWereMade() { + let writer = writer() + writer.handle(.setUser("u1")) + writer.handle(track("a")) + writer.handle(.setUser(nil)) + writer.handle(track("b")) + writer.handle(.flush) + + XCTAssertEqual(sentEvents().map { text($0, "actor_id") }, ["u1", nil]) + } + + func testResetGivesLaterEventsANewAnonymousIdAndSession() { + let writer = writer() + writer.handle(.setUser("u1")) + writer.handle(track("a")) + writer.handle(.reset) + writer.handle(track("b")) + writer.handle(.flush) + + let events = sentEvents() + XCTAssertEqual(events.count, 2) + XCTAssertNotEqual(text(events[0], "anon_id"), text(events[1], "anon_id")) + XCTAssertNotEqual(text(events[0], "session_id"), text(events[1], "session_id")) + XCTAssertNil(text(events[1], "actor_id")) + } + + func testIdentityAndSessionSurviveARestart() { + let first = writer() + first.handle(.setUser("u1")) + first.handle(track("a")) + first.handle(.background(done: nil)) + + time.now += 60_000 + let second = writer(start: false) + XCTAssertEqual(second.start(), "u1") + second.handle(track("b")) + second.handle(.flush) + + let events = sentEvents() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(text(events[0], "anon_id"), text(events[1], "anon_id")) + XCTAssertEqual(text(events[0], "session_id"), text(events[1], "session_id")) + XCTAssertEqual(text(events[1], "actor_id"), "u1") + } + + func testASessionEndsAfterTheTimeoutWithoutEvents() { + let writer = writer() + writer.handle(track("a", at: T0)) + writer.handle(track("b", at: T0 + Self.timeout)) + writer.handle(track("c", at: T0 + 2 * Self.timeout + 1)) + writer.handle(.flush) + + let sessions = sentEvents().map { text($0, "session_id") } + XCTAssertEqual(sessions.count, 3) + XCTAssertEqual(sessions[0], sessions[1]) + XCTAssertNotEqual(sessions[1], sessions[2]) + } + + func testAConfiguredSessionIdIsUsedForEveryEvent() { + let writer = writer(fixedSessionId: "checkout-42") + writer.handle(track("a", at: T0)) + writer.handle(track("b", at: T0 + 3 * Self.timeout)) + writer.handle(.flush) + XCTAssertEqual(sentEvents().map { text($0, "session_id") }, ["checkout-42", "checkout-42"]) + } + + func testAFullBatchIsCommittedAndAnUploadRequested() { + let writer = writer(batchSize: 2) + let before = uploads.requests + writer.handle(track("a")) + writer.handle(track("b")) + XCTAssertEqual(store.readyBatches().count, 1) + XCTAssertEqual(uploads.requests, before + 1) + } + + func testAnOversizedEventIsDropped() { + let writer = writer() + writer.handle(track("huge", properties: [("blob", String(repeating: "x", count: EventWriter.maxEventBytes))])) + writer.handle(.flush) + XCTAssertEqual(sentEvents().count, 0) + XCTAssertEqual(log.warnings.count, 1) + XCTAssertTrue(log.warnings.first?.contains("huge") ?? false) + } + + func testTicksLeaveTheOpenBatchAloneWhileUploadsBackOff() { + let writer = writer() + writer.handle(track("a")) + uploads.isBackingOff = true + writer.handle(.tick) + XCTAssertEqual(store.readyBatches().count, 0) + + uploads.isBackingOff = false + writer.handle(.tick) + XCTAssertEqual(store.readyBatches().count, 1) + } + + func testGoingToTheBackgroundCommitsResetsTheBackoffAndWaitsForTheUploader() { + let writer = writer() + writer.handle(track("a")) + uploads.isBackingOff = true + let before = uploads.requests + let done = Locked(false) + writer.handle(.background(done: { done.withLock { $0 = true } })) + XCTAssertEqual(store.readyBatches().count, 1) + XCTAssertEqual(uploads.resets, 1) // one try before iOS suspends the app + XCTAssertEqual(uploads.requests, before + 1) + XCTAssertEqual(uploads.idleWaits, 1) + XCTAssertTrue(done.current) + } + + func testABatchCommittedBeforeAFailedAppendIsStillUploaded() throws { + let writer = writer(maxBytes: 500) // room for one event + writer.handle(track("a")) + // The next batch file can't be created: a directory is in the way. + let blocker = root.appendingPathComponent("staging").appendingPathComponent(QueueStore.fileName(seq: 2, createdMillis: T0)) + try FileManager.default.createDirectory(at: blocker, withIntermediateDirectories: true) + let before = uploads.requests + + writer.handle(track("b")) + + XCTAssertEqual(store.readyBatches().count, 1) + XCTAssertEqual(uploads.requests, before + 1) + XCTAssertTrue(log.warnings.last?.contains("Could not store event \"b\"") ?? false) + } + + func testAStoredIdentityThatCantBeReadYetIsNeverReplacedAndIsUsedOnceItCanBe() throws { + let first = writer() + first.handle(.setUser("u1")) + first.handle(track("a")) + first.handle(.flush) + let stored = try XCTUnwrap(storedIdentity()) + + try lockIdentity(true) + let second = writer(start: false) + XCTAssertNil(second.start()) + XCTAssertTrue(log.warnings.last?.contains("can't be read yet") ?? false) + second.handle(.tick) + second.handle(.background(done: nil)) + try lockIdentity(false) + XCTAssertEqual(storedIdentity(), stored) // not overwritten by the temporary identity + + // After the unlock, the next command switches to the stored identity. + second.handle(track("b")) + second.handle(.flush) + let event = try XCTUnwrap(sentEvents().last) + XCTAssertEqual(text(event, "anon_id"), stored.anonId) + XCTAssertEqual(text(event, "actor_id"), "u1") + XCTAssertEqual(storedUsers.current, ["u1"]) + } + + func testAUserSetWhileTheStoredIdentityCantBeReadIsKept() throws { + let first = writer() + first.handle(.setUser("u1")) + first.handle(.flush) + let stored = try XCTUnwrap(storedIdentity()) + + try lockIdentity(true) + let second = writer(start: false) + _ = second.start() + second.handle(.setUser("u2")) + try lockIdentity(false) + second.handle(track("b")) + second.handle(.flush) + + let event = try XCTUnwrap(sentEvents().last) + XCTAssertEqual(text(event, "anon_id"), stored.anonId) + XCTAssertEqual(text(event, "actor_id"), "u2") + XCTAssertEqual(storedIdentity()?.actorId, "u2") + XCTAssertEqual(storedUsers.current, []) + } + + func testAResetWhileTheStoredIdentityCantBeReadReplacesIt() throws { + let first = writer() + first.handle(.setUser("u1")) + first.handle(.flush) + let stored = try XCTUnwrap(storedIdentity()) + + try lockIdentity(true) + let second = writer(start: false) + _ = second.start() + second.handle(.reset) + try lockIdentity(false) + second.handle(track("b")) + second.handle(.flush) + + let event = try XCTUnwrap(sentEvents().last) + XCTAssertNotEqual(text(event, "anon_id"), stored.anonId) + XCTAssertNil(text(event, "actor_id")) + XCTAssertEqual(storedIdentity()?.anonId, text(event, "anon_id")) + } + + func testComingToTheForegroundResetsTheBackoffAndAsksForAnUpload() { + let writer = writer() + let before = uploads.requests + writer.handle(.foreground) + XCTAssertEqual(uploads.resets, 1) + XCTAssertEqual(uploads.requests, before + 1) + } + + func testABarrierCompletesInOrder() { + let writer = writer() + let done = Locked(false) + writer.handle(.barrier { done.withLock { $0 = true } }) + XCTAssertTrue(done.current) + } + + func testStartRequestsAnUploadOfWhatThePreviousRunLeft() { + let before = uploads.requests + _ = writer() + XCTAssertEqual(uploads.requests, before + 1) + } +} diff --git a/clients/ios/Tests/SudoQueryTests/HTTPTransportTests.swift b/clients/ios/Tests/SudoQueryTests/HTTPTransportTests.swift new file mode 100644 index 0000000..312ba3c --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/HTTPTransportTests.swift @@ -0,0 +1,129 @@ +import XCTest +@testable import SudoQuery + +final class HTTPTransportTests: XCTestCase { + private var server: LocalHTTPServer! + private var transports: [URLSessionTransport] = [] + + override func setUpWithError() throws { + try super.setUpWithError() + server = try LocalHTTPServer() + } + + override func tearDown() { + transports.forEach { $0.invalidate() } + server.close() + super.tearDown() + } + + private func transport(_ headers: [(name: String, value: String)] = [("Content-Type", "application/json")]) -> URLSessionTransport { + let transport = URLSessionTransport(endpoint: server.url(), headers: headers) + transports.append(transport) + return transport + } + + private func post(_ transport: URLSessionTransport, _ body: Data = Data()) -> Result? { + let result = Locked?>(nil) + let done = DispatchSemaphore(value: 0) + transport.post(body) { value in + result.withLock { $0 = value } + done.signal() + } + XCTAssertEqual(done.wait(timeout: .now() + 10), .success, "no response") + return result.current + } + + private func response(_ transport: URLSessionTransport, _ body: Data = Data()) throws -> HTTPResult { + switch post(transport, body) { + case .success(let result)?: return result + case .failure(let error)?: throw error + case nil: throw URLError(.timedOut) + } + } + + func testPostsTheBodyWithTheGivenHeaders() throws { + server.enqueue(code: 200, body: #"{"filtered":0,"collected":1,"total":1}"#) + let headers = requestHeaders(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", token: "secret", custom: ["X-Api-Key": "k"], userAgent: defaultUserAgent()) + + let result = try response(transport(headers), Data(#"{"events":[]}"#.utf8)) + + XCTAssertEqual(result.code, 200) + XCTAssertEqual(result.body, #"{"filtered":0,"collected":1,"total":1}"#) + let request = try XCTUnwrap(server.takeRequest()) + XCTAssertEqual(request.method, "POST") + XCTAssertEqual(request.path, "/v1/events/batch") + XCTAssertEqual(request.bodyText, #"{"events":[]}"#) + XCTAssertEqual(request.header("Content-Type"), "application/json") + XCTAssertEqual(request.header("x-org-id"), "acme-k3x9qa") + XCTAssertEqual(request.header("x-workspace-id"), "ios-demo1") + XCTAssertEqual(request.header("Authorization"), "Bearer secret") + XCTAssertEqual(request.header("X-Api-Key"), "k") + let userAgent = try XCTUnwrap(request.header("User-Agent")) + XCTAssertNotNil(userAgent.range(of: #"^sudoquery-ios/\d+\.\d+\.\d+ \((iOS|macOS|Mac Catalyst) \d+\.\d+(\.\d+)?\)$"#, options: .regularExpression), userAgent) + } + + func testReturnsErrorResponsesWithTheirBodyAndRetryAfter() throws { + server.enqueue(code: 400, body: #"{"error":"bad"}"#) + server.enqueue(code: 429, headers: [("Retry-After", "30")]) + server.enqueue(code: 503, headers: [("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT")]) + + let rejected = try response(transport()) + XCTAssertEqual(rejected.code, 400) + XCTAssertEqual(rejected.body, #"{"error":"bad"}"#) + XCTAssertNil(rejected.retryAfterSeconds) + + XCTAssertEqual(try response(transport()).retryAfterSeconds, 30) + XCTAssertNil(try response(transport()).retryAfterSeconds) // an HTTP-date is ignored + XCTAssertEqual(parseRetryAfter(" 30 "), 30) + } + + func testDoesNotFollowRedirects() throws { + server.enqueue(code: 307, headers: [("Location", server.url("/elsewhere").absoluteString)]) + XCTAssertEqual(try response(transport(), Data([1])).code, 307) + XCTAssertEqual(server.requestCount, 1) + } + + func testReportsAnErrorWhenTheCollectorIsUnreachable() { + let unreachable = transport() + server.close() + guard case .failure? = post(unreachable, Data([1])) else { + return XCTFail("expected a transport error") + } + } + + func testKeepsOnlyTheStartOfALongBody() throws { + server.enqueue(code: 413, body: String(repeating: "x", count: 10_000)) + XCTAssertEqual(try response(transport()).body?.count, URLSessionTransport.maxBodyBytes) + } + + func testDoesNotSendCookiesBack() throws { + server.enqueue(code: 200, headers: [("Set-Cookie", "session=abc; Path=/")]) + let transport = transport() + _ = try response(transport) + _ = try response(transport) + _ = try XCTUnwrap(server.takeRequest()) + XCTAssertNil(try XCTUnwrap(server.takeRequest()).header("Cookie")) + } + + func testCustomHeadersCantReplaceTheIdsOrTheToken() { + let headers = requestHeaders( + orgId: "acme-k3x9qa", + workspaceId: "ios-demo1", + token: "secret", + custom: [ + "content-type": "application/x-ndjson", + "X-Org-Id": "other-org", + "authorization": "Basic abc", + "user-agent": "custom", + ], + userAgent: "sudoquery-ios/0.1.0" + ) + XCTAssertEqual(headers.map(\.name), ["content-type", "user-agent", "x-org-id", "x-workspace-id", "Authorization"]) + XCTAssertEqual(headers.map(\.value), ["application/x-ndjson", "custom", "acme-k3x9qa", "ios-demo1", "Bearer secret"]) + } + + func testAnEmptyTokenIsNotSent() { + let headers = requestHeaders(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", token: "", custom: [:], userAgent: "ua") + XCTAssertEqual(headers.map(\.name), ["Content-Type", "User-Agent", "x-org-id", "x-workspace-id"]) + } +} diff --git a/clients/ios/Tests/SudoQueryTests/IdentityTests.swift b/clients/ios/Tests/SudoQueryTests/IdentityTests.swift new file mode 100644 index 0000000..b76bf0b --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/IdentityTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import SudoQuery + +final class IdentityTests: XCTestCase { + private let timeout: Int64 = 30 * 60 * 1000 + private var ids = 0 + private var root: URL! + + override func setUp() { + super.setUp() + root = makeTempDirectory() + } + + override func tearDown() { + removeDirectory(root) + super.tearDown() + } + + private func newId() -> String { + ids += 1 + return "id-\(ids)" + } + + func testTheFirstEventStartsASession() { + let state = IdentityState(anonId: "anon").touched(at: T0, timeoutMillis: timeout, fixedSessionId: nil, newId: newId) + XCTAssertEqual(state.sessionId, "id-1") + XCTAssertEqual(state.lastActivityMillis, T0) + } + + func testASessionContinuesWithinTheTimeoutAndEndsAfterIt() { + let started = IdentityState(anonId: "anon").touched(at: T0, timeoutMillis: timeout, fixedSessionId: nil, newId: newId) + let continued = started.touched(at: T0 + timeout, timeoutMillis: timeout, fixedSessionId: nil, newId: newId) + XCTAssertEqual(continued.sessionId, started.sessionId) + + let renewed = continued.touched(at: T0 + 2 * timeout + 1, timeoutMillis: timeout, fixedSessionId: nil, newId: newId) + XCTAssertEqual(renewed.sessionId, "id-2") + } + + func testAClockMovingBackwardsKeepsTheSessionAndTimesItFromTheNewClock() { + let started = IdentityState(anonId: "anon").touched(at: T0, timeoutMillis: timeout, fixedSessionId: nil, newId: newId) + let back = started.touched(at: T0 - 3_600_000, timeoutMillis: timeout, fixedSessionId: nil, newId: newId) + XCTAssertEqual(back.sessionId, started.sessionId) + XCTAssertEqual(back.lastActivityMillis, T0 - 3_600_000) + } + + func testAConfiguredSessionIdIsAlwaysUsed() { + let state = IdentityState(anonId: "anon", sessionId: "old", lastActivityMillis: 0) + .touched(at: T0, timeoutMillis: timeout, fixedSessionId: "fixed", newId: newId) + XCTAssertEqual(state.sessionId, "fixed") + XCTAssertEqual(ids, 0) + } + + func testAnExtremeLastActivityStartsANewSessionWithoutTrapping() { + let state = IdentityState(anonId: "anon", sessionId: "old", lastActivityMillis: .min) + .touched(at: T0, timeoutMillis: timeout, fixedSessionId: nil, newId: newId) + XCTAssertEqual(state.sessionId, "id-1") + } + + func testTheStoreRoundTripsAndReplacesTheFileAtomically() { + let file = root.appendingPathComponent("v1/identity.json") + let store = IdentityStore(url: file, log: RecordingLog()) + XCTAssertEqual(store.load(), .missing) + + let state = IdentityState(anonId: "anon", actorId: "user", sessionId: "session", lastActivityMillis: T0) + XCTAssertTrue(store.save(state)) + XCTAssertEqual(store.load(), .loaded(state)) + XCTAssertFalse(FileManager.default.fileExists(atPath: root.appendingPathComponent("v1/identity.json.tmp").path)) + + var loggedOut = state + loggedOut.actorId = nil + store.save(loggedOut) + XCTAssertEqual(IdentityStore(url: file, log: RecordingLog()).load(), .loaded(loggedOut)) + } + + func testAnInvalidFileIsReportedAsCorruptWithAWarning() throws { + let file = root.appendingPathComponent("identity.json") + try Data("{not json".utf8).write(to: file) + let log = RecordingLog() + XCTAssertEqual(IdentityStore(url: file, log: log).load(), .corrupt) + XCTAssertEqual(log.warnings.count, 1) + } + + func testAFileThatCantBeReadIsReportedAsUnavailable() throws { + // Like a protected file before the first unlock after a reboot. + let file = root.appendingPathComponent("identity.json") + let store = IdentityStore(url: file, log: RecordingLog()) + store.save(IdentityState(anonId: "anon")) + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: file.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) } + XCTAssertEqual(store.load(), .unavailable) + } + + func testASaveThatFailsReportsIt() throws { + let readOnly = root.appendingPathComponent("locked") + try FileManager.default.createDirectory(at: readOnly, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: readOnly.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: readOnly.path) } + let log = RecordingLog() + XCTAssertFalse(IdentityStore(url: readOnly.appendingPathComponent("identity.json"), log: log).save(IdentityState(anonId: "anon"))) + XCTAssertEqual(log.warnings.count, 1) + } +} diff --git a/clients/ios/Tests/SudoQueryTests/IsoTimeTests.swift b/clients/ios/Tests/SudoQueryTests/IsoTimeTests.swift new file mode 100644 index 0000000..d1ceda4 --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/IsoTimeTests.swift @@ -0,0 +1,57 @@ +import Foundation +import XCTest +@testable import SudoQuery + +final class IsoTimeTests: XCTestCase { + func testFormatsLikeDateToISOString() { + XCTAssertEqual(formatIsoUtcMillis(epochMillis(2026, 10, 14, 9, 30, 12, 345)), "2026-10-14T09:30:12.345Z") + XCTAssertEqual(formatIsoUtcMillis(T0), "2025-10-09T08:53:20.000Z") + XCTAssertEqual(formatIsoUtcMillis(0), "1970-01-01T00:00:00.000Z") + XCTAssertEqual(formatIsoUtcMillis(-1), "1969-12-31T23:59:59.999Z") + } + + func testHandlesLeapDaysAndYearEnds() { + XCTAssertEqual(formatIsoUtcMillis(epochMillis(2000, 2, 29, 12, 0, 0, 1)), "2000-02-29T12:00:00.001Z") + XCTAssertEqual(formatIsoUtcMillis(epochMillis(2024, 2, 29, 23, 59, 59, 999)), "2024-02-29T23:59:59.999Z") + XCTAssertEqual(formatIsoUtcMillis(epochMillis(2100, 3, 1, 0, 0, 0, 0)), "2100-03-01T00:00:00.000Z") + XCTAssertEqual(formatIsoUtcMillis(epochMillis(2026, 12, 31, 23, 59, 59, 999)), "2026-12-31T23:59:59.999Z") + XCTAssertEqual(formatIsoUtcMillis(epochMillis(2027, 1, 1, 0, 0, 0, 0)), "2027-01-01T00:00:00.000Z") + } + + func testMatchesGmtimeAcrossTwoCenturies() { + var generator = SplitMix64(seed: 42) + let end = epochMillis(2170, 1, 1, 0, 0, 0, 0) + for _ in 0..<20_000 { + let millis = Int64.random(in: 0.. String { + var seconds = time_t(floorDiv(millis, 1000)) + var parts = tm() + gmtime_r(&seconds, &parts) + return String( + format: "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", + parts.tm_year + 1900, parts.tm_mon + 1, parts.tm_mday, parts.tm_hour, parts.tm_min, parts.tm_sec, + Int32(floorMod(millis, 1000)) + ) + } + + private func epochMillis(_ year: Int32, _ month: Int32, _ day: Int32, _ hour: Int32, _ minute: Int32, _ second: Int32, _ millis: Int64) -> Int64 { + var parts = tm() + parts.tm_year = year - 1900 + parts.tm_mon = month - 1 + parts.tm_mday = day + parts.tm_hour = hour + parts.tm_min = minute + parts.tm_sec = second + return Int64(timegm(&parts)) * 1000 + millis + } +} diff --git a/clients/ios/Tests/SudoQueryTests/JSONConversionTests.swift b/clients/ios/Tests/SudoQueryTests/JSONConversionTests.swift new file mode 100644 index 0000000..ff69197 --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/JSONConversionTests.swift @@ -0,0 +1,157 @@ +import CoreGraphics +import XCTest +@testable import SudoQuery + +final class JSONConversionTests: XCTestCase { + private let log = RecordingLog() + + private enum Plan: String { + case pro = "PRO" + } + + private enum Tier { + case gold + } + + private func encoded(_ object: JSONObject) -> String { + String(decoding: JSON.encode(.object(object)), as: UTF8.self) + } + + func testConvertsNestedValuesWithSortedKeys() throws { + let missing: String? = nil + let object = JSONConversion.object( + from: [ + "string": "a", + "int": 1, + "int64": Int64(2), + "uint8": UInt8(7), + "double": 1.5, + "decimal": try XCTUnwrap(Decimal(string: "10.25")), + "bool": true, + "null": missing as Any, + "character": Character("x"), + "raw": Plan.pro, + "plain": Tier.gold, + "list": [1, "two"] as [Any], + "ints": [1, 2], + "nested": ["inner": ["deep": 1]], + ], + log: log + ) + XCTAssertEqual( + encoded(object), + #"{"bool":true,"character":"x","decimal":10.25,"double":1.5,"int":1,"int64":2,"ints":[1,2],"list":[1,"two"],"# + + #""nested":{"inner":{"deep":1}},"null":null,"plain":"gold","raw":"PRO","string":"a","uint8":7}"# + ) + XCTAssertEqual(log.warnings, []) + } + + func testNonFiniteNumbersBecomeNullWithAWarning() { + let object = JSONConversion.object(from: ["nan": Double.nan, "inf": Float.infinity, "list": [1.0, -Double.infinity]], log: log) + XCTAssertEqual(encoded(object), #"{"inf":null,"list":[1.0,null],"nan":null}"#) + XCTAssertEqual(log.warnings.count, 3) + } + + func testNSNumbersKeepTheirType() { + // A bridged NSNumber also casts to Bool and Int, so the order of checks matters. + let object = JSONConversion.object( + fromAnyKeys: [ + "true": NSNumber(value: true), + "one": NSNumber(value: 1), + "zero": NSNumber(value: 0), + "two": NSNumber(value: 2.0), + "float": NSNumber(value: Float(0.1)), + "big": NSNumber(value: UInt64.max), + "min": NSNumber(value: Int64.min), + "decimal": NSDecimalNumber(string: "10.25"), + "notANumber": NSDecimalNumber.notANumber, + "swiftTrue": true, + "swiftOne": 1, + "cgfloat": CGFloat(1.5), + ], + log: log + ) + XCTAssertEqual( + encoded(object), + #"{"big":18446744073709551615,"cgfloat":1.5,"decimal":10.25,"float":0.1,"min":-9223372036854775808,"# + + #""notANumber":null,"one":1,"swiftOne":1,"swiftTrue":true,"true":true,"two":2.0,"zero":0}"# + ) + } + + func testBoxedOptionalsAreUnwrapped() { + let some: Int? = 5 + let none: Int? = nil + let nested: Int?? = .some(nil) + let object = JSONConversion.object(from: ["some": some as Any, "none": none as Any, "nested": nested as Any], log: log) + XCTAssertEqual(encoded(object), #"{"nested":null,"none":null,"some":5}"#) + XCTAssertEqual(JSONConversion.value(nil, log: log), .null) + } + + func testADictionaryContainingItselfStopsAtTheDepthLimit() throws { + let loop = NSMutableDictionary() + loop["self"] = loop + defer { loop.removeAllObjects() } + + let object = JSONConversion.object(fromAnyKeys: try XCTUnwrap(loop as? [AnyHashable: Any]), log: log) + var depth = 0 + var current = JSONValue.object(object) + while case .object(let inner) = current { + current = inner["self"] ?? .string("missing") + depth += 1 + } + XCTAssertEqual(current, .null) + XCTAssertEqual(depth, 33) + XCTAssertEqual(log.warnings.count, 1) + XCTAssertTrue(log.warnings.first?.contains("nested deeper") ?? false) + } + + func testTheResultIsACopy() throws { + let items = NSMutableArray(array: ["a"]) + let properties = NSMutableDictionary(dictionary: ["items": items]) + let object = JSONConversion.object(fromAnyKeys: try XCTUnwrap(properties as? [AnyHashable: Any]), log: log) + items.add("b") + properties["late"] = 1 + XCTAssertEqual(encoded(object), #"{"items":["a"]}"#) + } + + func testDictionariesWithNumberKeysGetStringKeys() { + let object = JSONConversion.object(fromAnyKeys: [NSNumber(value: 1): "one", "b": ["nested": [NSNumber(value: 2): true]]], log: log) + XCTAssertEqual(encoded(object), #"{"1":"one","b":{"nested":{"2":true}}}"#) + } + + func testDatesURLsUUIDsSetsAndData() throws { + let object = JSONConversion.object( + from: [ + "date": Date(timeIntervalSince1970: 1_760_000_000.5), + "url": try XCTUnwrap(URL(string: "https://example.com/a?b=c")), + "uuid": try XCTUnwrap(UUID(uuidString: "3B241101-E2BB-4255-8CAF-4136C566A962")), + "set": Set(["b", "a"]), + "data": Data([1, 255]), + ], + log: log + ) + XCTAssertEqual( + encoded(object), + #"{"data":[1,255],"date":"2025-10-09T08:53:20.500Z","set":["a","b"],"url":"https://example.com/a?b=c","# + + #""uuid":"3b241101-e2bb-4255-8caf-4136c566a962"}"# + ) + } + + func testPlainTurnsJSONBackIntoFoundationValues() { + let object = JSONObject([ + ("s", .string("x")), + ("n", .number("3")), + ("d", .number("2.5")), + ("b", .bool(true)), + ("null", .null), + ("obj", .object(JSONObject([("k", .array([.number("1")]))]))), + ]) + let plain = JSONConversion.plain(object) + XCTAssertEqual(plain["s"] as? String, "x") + XCTAssertEqual(plain["n"] as? Int, 3) + XCTAssertEqual(plain["d"] as? Double, 2.5) + XCTAssertEqual(plain["b"] as? Bool, true) + XCTAssertTrue(plain["null"] is NSNull) + XCTAssertEqual((plain["obj"] as? [String: Any])?["k"] as? [Int], [1]) + } +} diff --git a/clients/ios/Tests/SudoQueryTests/JSONTests.swift b/clients/ios/Tests/SudoQueryTests/JSONTests.swift new file mode 100644 index 0000000..6ffee56 --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/JSONTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import SudoQuery + +final class JSONTests: XCTestCase { + private func encoded(_ value: JSONValue) -> String { + String(decoding: JSON.encode(value), as: UTF8.self) + } + + func testStringsAreEscapedLikeJSONStringify() { + XCTAssertEqual( + encoded(.string("quote \" backslash \\ slash / tab \t newline \n return \r backspace \u{8} formfeed \u{C} control \u{1} \u{1F}")), + #""quote \" backslash \\ slash / tab \t newline \n return \r backspace \b formfeed \f control \u0001 \u001f""# + ) + XCTAssertEqual(encoded(.string("₹ 中文 😀 \u{2028} \u{7F}")), "\"₹ 中文 😀 \u{2028} \u{7F}\"") + } + + func testObjectsKeepInsertionOrderAndWriteNulls() { + var object = JSONObject([("b", .number("1")), ("a", .null)]) + object["b"] = .bool(true) // replaced in place + object["c"] = .array([.string("x"), .number("-0.5"), .object(JSONObject())]) + XCTAssertEqual(encoded(.object(object)), #"{"b":true,"a":null,"c":["x",-0.5,{}]}"#) + + object["a"] = nil + XCTAssertEqual(object.keys, ["b", "c"]) + XCTAssertEqual(object.merging(JSONObject([("c", .null), ("d", .string("new"))])).keys, ["b", "c", "d"]) + } + + func testEncodedStringsRoundTripThroughJSONSerialization() throws { + var generator = SplitMix64(seed: 7) + for _ in 0..<500 { + let count = Int.random(in: 0...24, using: &generator) + let scalars = (0.. SQSudoQueryConfig { + let config = SQSudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: endpoint ?? server.url().absoluteString) + config.batchSize = 1 + config.flushInterval = 0 + return config + } + + func testTheClassesHaveTheirObjectiveCNames() { + XCTAssertEqual(NSStringFromClass(SQSudoQuery.self), "SQSudoQuery") + XCTAssertEqual(NSStringFromClass(SQSudoQueryConfig.self), "SQSudoQueryConfig") + XCTAssertNotNil(NSClassFromString("SQSudoQuery")) + XCTAssertNotNil(NSClassFromString("SQSudoQueryConfig")) + } + + func testEverySelectorIsThere() { + let classSelectors = [ + "initializeWithConfig:error:", "isInitialized", "track:", "track:properties:", "setUser:", "removeUser", "user", + "setSuperProperty:value:", "superProperties", "clearSuperProperties", "flush", "reset", "sdkVersion", + ] + for name in classSelectors { + XCTAssertNotNil(class_getClassMethod(SQSudoQuery.self, NSSelectorFromString(name)), name) + } + let configSelectors = [ + "initWithOrgId:workspaceId:endpoint:", "orgId", "workspaceId", "endpoint", "token", "setToken:", "headers", "setHeaders:", + "source", "setSource:", "sessionId", "setSessionId:", "batchSize", "setBatchSize:", "flushInterval", "setFlushInterval:", + "sessionTimeout", "setSessionTimeout:", "debugLogging", "setDebugLogging:", + ] + for name in configSelectors { + XCTAssertTrue(SQSudoQueryConfig.instancesRespond(to: NSSelectorFromString(name)), name) + } + XCTAssertNotNil(class_getClassMethod(SQSudoQueryConfig.self, NSSelectorFromString("errorDomain"))) + XCTAssertEqual(SQSudoQuery.version(), 0) // still NSObject's +version + } + + func testObjectiveCDefaultsMatchSwift() { + let config = SQSudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: "https://c.example.com/v1/events/batch") + XCTAssertEqual(config.source, SudoQueryConfig.defaultSource) + XCTAssertEqual(config.batchSize, SudoQueryConfig.defaultBatchSize) + XCTAssertEqual(config.flushInterval, SudoQueryConfig.defaultFlushInterval) + XCTAssertEqual(config.sessionTimeout, SudoQueryConfig.defaultSessionTimeout) + XCTAssertNil(config.token) + XCTAssertNil(config.sessionId) + XCTAssertFalse(config.debugLogging) + XCTAssertEqual(SQSudoQueryConfig.errorDomain, "io.juspay.sudoquery.config") + XCTAssertEqual(SQSudoQuery.sdkVersion, SudoQuery.version) + } + + func testAnInvalidConfigFailsWithAnNSErrorAndDoesNotStart() { + XCTAssertThrowsError(try SQSudoQuery.initialize(with: config(endpoint: "ftp://nope"))) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, SQSudoQueryConfig.errorDomain) + XCTAssertEqual(nsError.code, SudoQueryConfigErrorCode.invalidEndpoint.rawValue) + } + XCTAssertFalse(SQSudoQuery.isInitialized) + } + + func testTrackPropertiesFromObjectiveCKeepTheirTypes() throws { + try SQSudoQuery.initialize(with: config()) + XCTAssertTrue(SQSudoQuery.isInitialized) + + let properties: NSDictionary = [ + NSNumber(value: 1): "numeric key", + "b": NSNumber(value: true), + "i": NSNumber(value: 1), + "d": NSNumber(value: 0.5), + "f": NSNumber(value: Float(0.1)), + "dec": NSDecimalNumber(string: "10.25"), + "n": NSNull(), + ] + _ = sdk.perform(NSSelectorFromString("track:properties:"), with: "objc_event", with: properties) + + let request = try XCTUnwrap(server.takeRequest(timeout: 10)) + XCTAssertTrue( + request.bodyText.contains(#""name":"objc_event""#) && request.bodyText.contains(#""properties":{"1":"numeric key","b":true,"d":0.5,"dec":10.25,"f":0.1,"i":1,"n":null}"#), + request.bodyText + ) + } + + func testNilArgumentsFromObjectiveC() throws { + try SQSudoQuery.initialize(with: config()) + + _ = sdk.perform(NSSelectorFromString("setUser:"), with: "u1") + XCTAssertEqual(SQSudoQuery.user, "u1") + _ = sdk.perform(NSSelectorFromString("setUser:"), with: nil) + XCTAssertNil(SQSudoQuery.user) + + _ = sdk.perform(NSSelectorFromString("setSuperProperty:value:"), with: "k", with: nil) + XCTAssertTrue(SQSudoQuery.superProperties["k"] is NSNull) + + _ = sdk.perform(NSSelectorFromString("track:properties:"), with: "no_properties", with: nil) + SQSudoQuery.flush() + XCTAssertNotNil(server.takeRequest(timeout: 10)) + } + + func testHeadersThatArentStringsAreReportedInsteadOfCrashing() { + let config = config() + // What Objective-C can pass for an NSDictionary *, e.g. from JSON. + let headers: NSDictionary = ["X-Version": NSNumber(value: 42)] + _ = config.perform(NSSelectorFromString("setHeaders:"), with: headers) + XCTAssertThrowsError(try SQSudoQuery.initialize(with: config)) { error in + XCTAssertEqual((error as NSError).code, SudoQueryConfigErrorCode.invalidHeader.rawValue) + } + + config.headers = ["X-Version": "42"] + XCTAssertNoThrow(try SQSudoQuery.initialize(with: config)) + } + + func testSuperPropertyBooleansComeBackAsBooleans() { + SQSudoQuery.setSuperProperty("flag", value: true) + let value = SQSudoQuery.superProperties["flag"] as AnyObject + XCTAssertEqual(CFGetTypeID(value), CFBooleanGetTypeID()) + SQSudoQuery.clearSuperProperties() + } +} diff --git a/clients/ios/Tests/SudoQueryTests/QueueStoreTests.swift b/clients/ios/Tests/SudoQueryTests/QueueStoreTests.swift new file mode 100644 index 0000000..5f05200 --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/QueueStoreTests.swift @@ -0,0 +1,173 @@ +import XCTest +@testable import SudoQuery + +final class QueueStoreTests: XCTestCase { + private let time = FakeTime() + private let log = RecordingLog() + private var root: URL! + + override func setUp() { + super.setUp() + root = makeTempDirectory() + } + + override func tearDown() { + removeDirectory(root) + super.tearDown() + } + + private func store(maxEvents: Int = 3, maxBytes: Int64 = QueueStore.maxBatchBytes, maxQueueBytes: Int64 = QueueStore.maxQueueBytes) throws -> QueueStore { + let store = QueueStore(root: root, maxEventsPerBatch: maxEvents, time: time, log: log, maxBytesPerBatch: maxBytes, maxQueueBytes: maxQueueBytes) + try store.recover() + return store + } + + private func event(_ n: Int) -> [UInt8] { + Array(#"{"n":\#(n)}"#.utf8) + } + + private func numbers(_ store: QueueStore, _ file: BatchFile) -> [Int] { + (store.read(file) ?? []).compactMap { ((try? JSONSerialization.jsonObject(with: $0)) as? [String: Any])?["n"] as? Int } + } + + private var stagingNames: [String] { + (try? FileManager.default.contentsOfDirectory(atPath: root.appendingPathComponent("staging").path)) ?? [] + } + + private func writeReady(_ name: String, _ text: String) throws { + try Data(text.utf8).write(to: root.appendingPathComponent("ready").appendingPathComponent(name)) + } + + func testABatchIsCommittedWhenItReachesTheEventLimit() throws { + let store = try store(maxEvents: 3) + XCTAssertFalse(try store.append(event(1))) + XCTAssertFalse(try store.append(event(2))) + XCTAssertTrue(try store.append(event(3))) + XCTAssertFalse(try store.append(event(4))) + + let ready = store.readyBatches() + XCTAssertEqual(ready.count, 1) + XCTAssertEqual(ready.first.map { numbers(store, $0) }, [1, 2, 3]) + XCTAssertEqual(stagingNames.count, 1) + store.closeOpenBatch() + } + + func testABatchIsCommittedBeforeItWouldExceedTheByteLimit() throws { + let store = try store(maxEvents: 100, maxBytes: 20) + _ = try store.append(event(1)) // 8 bytes with the newline + _ = try store.append(event(2)) // 16 + XCTAssertTrue(try store.append(event(3))) // would be 24: commits the first two, starts a new batch + store.commit() + XCTAssertEqual(store.readyBatches().map { numbers(store, $0) }, [[1, 2], [3]]) + } + + func testCommitReturnsFalseWhenNothingWasAppended() throws { + let store = try store() + XCTAssertFalse(store.commit()) + _ = try store.append(event(1)) + XCTAssertTrue(store.commit()) + XCTAssertFalse(store.commit()) + } + + func testRecoverCommitsBatchesAPreviousProcessLeftOpenAndKeepsTheSequenceGoing() throws { + let first = try store(maxEvents: 10) + _ = try first.append(event(1)) + _ = try first.append(event(2)) + // The process dies here: the batch is still in staging/. Add an empty and a foreign file too. + first.closeOpenBatch() + let staging = root.appendingPathComponent("staging") + FileManager.default.createFile(atPath: staging.appendingPathComponent("0000000099-1.ndjson").path, contents: Data()) + try Data("x".utf8).write(to: staging.appendingPathComponent("notes.txt")) + + let second = try store(maxEvents: 10) + XCTAssertEqual(second.readyBatches().map { numbers(second, $0) }, [[1, 2]]) + XCTAssertEqual(stagingNames, []) + + _ = try second.append(event(3)) + second.commit() + XCTAssertEqual(second.readyBatches().map { numbers(second, $0) }, [[1, 2], [3]]) + XCTAssertEqual(second.readyBatches().map(\.seq), [1, 2]) + } + + func testLinesCutShortByACrashAreSkipped() throws { + let store = try store() + let name = QueueStore.fileName(seq: 1, createdMillis: T0) + try writeReady(name, "{\"n\":1}\n{\"n\":2}\n{\"n\":3, \"cut") + let file = try XCTUnwrap(store.readyBatches().first) + XCTAssertEqual(numbers(store, file), [1, 2]) + XCTAssertEqual(log.warnings.count, 1) + XCTAssertTrue(log.warnings.first?.contains("Skipped 1") ?? false) + } + + func testReadReturnsNilForABatchThatIsGone() throws { + let store = try store() + let url = root.appendingPathComponent("ready/0000000001-1.ndjson") + XCTAssertNil(store.read(BatchFile(url: url, name: url.lastPathComponent, seq: 1, createdMillis: 1))) + XCTAssertEqual(log.warnings, []) + } + + func testReadReturnsTheEventsExactlyAsWritten() throws { + let store = try store(maxEvents: 2) + let first = Array(#"{"b":1,"a":"/x"}"#.utf8) + let second = Array(#"{"z":[1.0,null]}"#.utf8) + _ = try store.append(first) + _ = try store.append(second) + let file = try XCTUnwrap(store.readyBatches().first) + XCTAssertEqual(store.read(file), [Data(first), Data(second)]) + XCTAssertEqual(file.name, "0000000001-\(T0).ndjson") + } + + func testBatchesAreOrderedBySequenceNotByCreationTime() throws { + let store = try store(maxEvents: 1) + time.now = T0 + 10_000 + _ = try store.append(event(1)) + time.now = T0 // the clock moved back + _ = try store.append(event(2)) + XCTAssertEqual(store.readyBatches().map { numbers(store, $0) }, [[1], [2]]) + } + + func testExpiredBatchesAreDroppedAndFutureDatedOnesKept() throws { + let store = try store() + try writeReady(QueueStore.fileName(seq: 1, createdMillis: T0 - QueueStore.maxAgeMillis - 1), "{\"n\":1}\n") + try writeReady(QueueStore.fileName(seq: 2, createdMillis: T0 - 1000), "{\"n\":2}\n") + try writeReady(QueueStore.fileName(seq: 3, createdMillis: T0 + 86_400_000), "{\"n\":3}\n") + + store.enforceLimits() + + XCTAssertEqual(store.readyBatches().map { numbers(store, $0) }, [[2], [3]]) + XCTAssertEqual(log.warnings.count, 1) + XCTAssertTrue(log.warnings.first?.contains("older than") ?? false) + } + + func testAnOversizedQueueDropsItsOldestBatchesDownTo80Percent() throws { + let store = try store(maxEvents: 1, maxQueueBytes: 100) + for _ in 0..<10 { + _ = try store.append(Array(#"{"pad":"xxxxxxxxx"}"#.utf8)) // 10 batches of 20 bytes = 200 + } + + store.enforceLimits() + + XCTAssertEqual(store.readyBatches().map(\.seq), [7, 8, 9, 10]) // 80 bytes <= 80 % of 100 + } + + func testNamesAreParsedStrictly() { + XCTAssertEqual(QueueStore.fileName(seq: 42, createdMillis: 7), "0000000042-7.ndjson") + XCTAssertEqual(QueueStore.parseName("0000000042-7.ndjson")?.seq, 42) + XCTAssertEqual(QueueStore.parseName("0000000042-7.ndjson")?.createdMillis, 7) + for bad in ["notes.txt", "1-2-3.ndjson", "-1.ndjson", "1-.ndjson", "a-1.ndjson", "1-1.ndjson.tmp", "1234567890123456789-1.ndjson", "١-1.ndjson"] { + XCTAssertNil(QueueStore.parseName(bad), bad) + } + } + + func testADeletedStagingDirectoryIsCreatedAgain() throws { + let store = try store(maxEvents: 1) + try FileManager.default.removeItem(at: root.appendingPathComponent("staging")) + XCTAssertTrue(try store.append(event(1))) + XCTAssertEqual(store.readyBatches().map { numbers(store, $0) }, [[1]]) + } + + func testTheQueueCanBeExcludedFromBackup() throws { + try FileIO.excludeFromBackup(root) + XCTAssertEqual(try root.resourceValues(forKeys: [.isExcludedFromBackupKey]).isExcludedFromBackup, true) + } +} diff --git a/clients/ios/Tests/SudoQueryTests/Resources/golden/batch.json b/clients/ios/Tests/SudoQueryTests/Resources/golden/batch.json new file mode 100644 index 0000000..6f17e09 --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/Resources/golden/batch.json @@ -0,0 +1 @@ +{"events":[{"envelop_version":"1.0","id":"3b241101-e2bb-4255-8caf-4136c566a962","name":"button_click","org_id":"acme-k3x9qa","workspace_id":"ios-demo1","session_id":"9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","anon_id":"6e8bc430-9c3a-4d6b-9f3e-2a1b0c9d8e7f","actor_id":null,"source":"ios","occured_at":"2026-10-14T09:30:12.345Z","properties":{"app_version":"1.0.0","button_id":"submit","page":"/checkout"},"correlation_id":null,"trace_id":null,"system_properties":null}],"system_properties":{"geo":null,"timezone":"Asia/Kolkata"}} diff --git a/clients/ios/Tests/SudoQueryTests/Resources/golden/event.json b/clients/ios/Tests/SudoQueryTests/Resources/golden/event.json new file mode 100644 index 0000000..4d4ba07 --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/Resources/golden/event.json @@ -0,0 +1 @@ +{"envelop_version":"1.0","id":"3b241101-e2bb-4255-8caf-4136c566a962","name":"button_click","org_id":"acme-k3x9qa","workspace_id":"ios-demo1","session_id":"9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","anon_id":"6e8bc430-9c3a-4d6b-9f3e-2a1b0c9d8e7f","actor_id":null,"source":"ios","occured_at":"2026-10-14T09:30:12.345Z","properties":{"app_version":"1.0.0","button_id":"submit","page":"/checkout"},"correlation_id":null,"trace_id":null,"system_properties":null} diff --git a/clients/ios/Tests/SudoQueryTests/SudoQueryConfigTests.swift b/clients/ios/Tests/SudoQueryTests/SudoQueryConfigTests.swift new file mode 100644 index 0000000..c7652f2 --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/SudoQueryConfigTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import SudoQuery + +final class SudoQueryConfigTests: XCTestCase { + private let endpoint = "https://collector.example.com/v1/events/batch" + + func testDefaults() throws { + let config = try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: endpoint) + XCTAssertEqual(config.source, "ios") + XCTAssertEqual(config.batchSize, 50) + XCTAssertEqual(config.flushIntervalMillis, 30_000) + XCTAssertEqual(config.sessionTimeoutMillis, 30 * 60 * 1000) + XCTAssertNil(config.token) + XCTAssertNil(config.sessionId) + XCTAssertEqual(config.headers, [:]) + XCTAssertFalse(config.debugLogging) + XCTAssertEqual(config.endpointURL.absoluteString, endpoint) + } + + func testIntervalsAreSecondsAndZeroTurnsTheTimerOff() throws { + let config = try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: endpoint, flushInterval: 0, sessionTimeout: 1.5) + XCTAssertEqual(config.flushIntervalMillis, 0) + XCTAssertEqual(config.sessionTimeoutMillis, 1_500) + } + + func testInvalidValuesAreRejected() { + assertRejected(.blankOrgId) { try SudoQueryConfig(orgId: " ", workspaceId: "ios-demo1", endpoint: endpoint) } + assertRejected(.blankWorkspaceId) { try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "", endpoint: endpoint) } + assertRejected(.invalidEndpoint) { try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: "collector/v1/events/batch") } + assertRejected(.invalidEndpoint) { try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: "ftp://c.example.com") } + assertRejected(.invalidEndpoint) { try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: "https://") } + assertRejected(.invalidEndpoint) { try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: "https://c.example.com/a b") } + assertRejected(.batchSizeOutOfRange) { try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: self.endpoint, batchSize: 0) } + assertRejected(.batchSizeOutOfRange) { try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: self.endpoint, batchSize: 501) } + assertRejected(.invalidFlushInterval) { try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: self.endpoint, flushInterval: -1) } + assertRejected(.invalidFlushInterval) { try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: self.endpoint, flushInterval: .nan) } + assertRejected(.invalidSessionTimeout) { try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: self.endpoint, sessionTimeout: 0) } + assertRejected(.invalidHeader) { try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: self.endpoint, headers: ["X-Bad": "a\r\nInjected: 1"]) } + assertRejected(.invalidHeader) { try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: self.endpoint, headers: ["": "x"]) } + } + + func testErrorsBridgeToNSError() { + do { + _ = try SudoQueryConfig(orgId: "acme-k3x9qa", workspaceId: "ios-demo1", endpoint: "ftp://c.example.com") + XCTFail("expected an error") + } catch { + let nsError = error as NSError + XCTAssertEqual(nsError.domain, "io.juspay.sudoquery.config") + XCTAssertEqual(nsError.code, SudoQueryConfigErrorCode.invalidEndpoint.rawValue) + XCTAssertEqual(nsError.localizedDescription, "endpoint must be an http(s) URL: ftp://c.example.com") + } + } + + func testSlugsAreCheckedLikeTheCollector() { + for slug in ["acme-k3x9qa", "ios-demo1", "a2345z", String(repeating: "a", count: 30)] { + XCTAssertTrue(SudoQuery.isSlug(slug), slug) + } + for bad in ["breez", "Acme-k3x9qa", "1cme-k3x9qa", "acme-k3x9q-", "acme_k3x9qa", "a2345", String(repeating: "a", count: 31), "acmé-k3x9qa"] { + XCTAssertFalse(SudoQuery.isSlug(bad), bad) + } + } + + private func assertRejected(_ code: SudoQueryConfigErrorCode, file: StaticString = #filePath, line: UInt = #line, _ make: () throws -> SudoQueryConfig) { + do { + _ = try make() + XCTFail("expected \(code)", file: file, line: line) + } catch let error as SudoQueryConfigError { + XCTAssertEqual(error.code, code, error.message, file: file, line: line) + } catch { + XCTFail("unexpected \(error)", file: file, line: line) + } + } +} diff --git a/clients/ios/Tests/SudoQueryTests/SudoQueryFacadeTests.swift b/clients/ios/Tests/SudoQueryTests/SudoQueryFacadeTests.swift new file mode 100644 index 0000000..25ffd5a --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/SudoQueryFacadeTests.swift @@ -0,0 +1,149 @@ +import XCTest +@testable import SudoQuery + +final class SudoQueryFacadeTests: XCTestCase { + private let log = RecordingLog() + private let time = FakeTime() + private var server: LocalHTTPServer! + private var root: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + server = try LocalHTTPServer() + root = makeTempDirectory() + SudoQuery.resetForTests(environment: testEnvironment(root: root, log: log, time: time), log: log) + } + + override func tearDown() { + SudoQuery.resetForTests() + server.close() + removeDirectory(root) + super.tearDown() + } + + private func config(batchSize: Int = 2, endpoint: String? = nil, orgId: String = "acme-k3x9qa") throws -> SudoQueryConfig { + try SudoQueryConfig(orgId: orgId, workspaceId: "ios-demo1", endpoint: endpoint ?? server.url().absoluteString, batchSize: batchSize, flushInterval: 0) + } + + private func events(_ request: LocalHTTPServer.Request) -> [[String: Any]] { + ((try? JSONSerialization.jsonObject(with: request.body)) as? [String: Any])?["events"] as? [[String: Any]] ?? [] + } + + func testCallsBeforeInitializeAreIgnoredWithoutCrashing() { + SudoQuery.track("early", properties: ["k": 1]) + SudoQuery.setUser("u1") + SudoQuery.removeUser() + SudoQuery.flush() + SudoQuery.reset() + XCTAssertFalse(SudoQuery.isInitialized) + XCTAssertNil(SudoQuery.user) + XCTAssertEqual(log.warnings, ["SudoQuery.track was called before SudoQuery.initialize; such calls are ignored"]) + } + + func testSuperPropertiesWorkBeforeInitializeAndAreCopies() { + SudoQuery.setSuperProperty("plan", "pro") + SudoQuery.setSuperProperty("tags", ["a", "b"]) + XCTAssertEqual(canonicalJSON(SudoQuery.superProperties), #"{"plan":"pro","tags":["a","b"]}"#) + + var copy = SudoQuery.superProperties + copy["plan"] = "free" + XCTAssertEqual(SudoQuery.superProperties["plan"] as? String, "pro") + + SudoQuery.clearSuperProperties() + XCTAssertTrue(SudoQuery.superProperties.isEmpty) + } + + func testTheFirstInitializeWins() throws { + SudoQuery.initialize(try config()) + let first = SudoQuery.clientForTests + SudoQuery.initialize(try config(endpoint: "https://other.example.com/v1/events/batch")) + XCTAssertTrue(SudoQuery.isInitialized) + XCTAssertTrue(first === SudoQuery.clientForTests) + } + + func testIdsThatArentSlugsWarnButStillStart() throws { + SudoQuery.initialize(try config(orgId: "Acme")) + XCTAssertTrue(SudoQuery.isInitialized) + XCTAssertEqual(log.warnings.count, 1) + XCTAssertTrue(log.warnings.first?.contains("slug shape") ?? false) + } + + func testTrackedEventsReachTheCollectorWithIdentityAndSuperProperties() throws { + server.enqueue(code: 200, body: #"{"filtered":0,"collected":2,"total":2}"#) + SudoQuery.initialize(try config(batchSize: 2)) + SudoQuery.setSuperProperty("app_version", "1.0.0") + SudoQuery.setUser("u1") + XCTAssertEqual(SudoQuery.user, "u1") + + SudoQuery.track("checkout_started", properties: ["cart_value": 499.5, "app_version": "override"]) + SudoQuery.track("checkout_completed") + + let request = try XCTUnwrap(server.takeRequest(timeout: 10), "no request reached the server") + XCTAssertEqual(request.header("x-org-id"), "acme-k3x9qa") + XCTAssertEqual(request.header("x-workspace-id"), "ios-demo1") + XCTAssertTrue(request.header("User-Agent")?.hasPrefix("sudoquery-ios/") ?? false) + + let events = events(request) + XCTAssertEqual(events.map { text($0, "name") }, ["checkout_started", "checkout_completed"]) + XCTAssertEqual(events.map { text($0, "actor_id") }, ["u1", "u1"]) + XCTAssertEqual(text(events[0], "anon_id"), text(events[1], "anon_id")) + XCTAssertEqual(text(events[0], "session_id"), text(events[1], "session_id")) + // Super properties first, the event's own keys over them. + XCTAssertTrue(request.bodyText.contains(#""properties":{"app_version":"override","cart_value":499.5}"#), request.bodyText) + XCTAssertTrue(request.bodyText.contains(#""properties":{"app_version":"1.0.0"}"#), request.bodyText) + XCTAssertTrue(request.bodyText.contains(#""system_properties":{"geo":null,"timezone":"#), request.bodyText) + } + + func testResetClearsTheUserAndSuperProperties() throws { + SudoQuery.initialize(try config()) + SudoQuery.setUser("u1") + SudoQuery.setSuperProperty("plan", "pro") + SudoQuery.reset() + XCTAssertNil(SudoQuery.user) + XCTAssertTrue(SudoQuery.superProperties.isEmpty) + XCTAssertTrue(try XCTUnwrap(SudoQuery.clientForTests).awaitIdle()) + } + + func testTheTimestampAndThePropertiesAreTakenOnTheCallersThread() throws { + SudoQuery.initialize(try config(batchSize: 1)) + let items = NSMutableArray(array: ["a"]) + SudoQuery.track("copied", properties: ["items": items]) + items.add("b") + time.now = T0 + 60_000 + + let request = try XCTUnwrap(server.takeRequest(timeout: 10)) + let event = try XCTUnwrap(events(request).first) + XCTAssertEqual(text(event, "occured_at"), "2025-10-09T08:53:20.000Z") + XCTAssertEqual(canonicalJSON(event["properties"] as Any), #"{"items":["a"]}"#) + } + + func testEventsWaitingForTheWriterAreCapped() throws { + SudoQuery.initialize(try config(batchSize: 500)) + let client = try XCTUnwrap(SudoQuery.clientForTests) + let release = DispatchSemaphore(value: 0) + client.onWriterQueueForTests { release.wait() } + + for index in 0...SudoQueryClient.maxQueuedTracks { + SudoQuery.track("burst", properties: ["index": index]) + } + release.signal() + SudoQuery.flush() + XCTAssertTrue(client.awaitIdle()) + + XCTAssertEqual(log.warnings.filter { $0.contains("More than 1000 events") }.count, 1) + var sent = 0 + while let request = server.takeRequest(timeout: 1) { + sent += events(request).count + } + XCTAssertEqual(sent, SudoQueryClient.maxQueuedTracks) + } +} + +func testEnvironment(root: URL, log: RecordingLog, time: FakeTime? = nil) -> ClientEnvironment { + var environment = ClientEnvironment() + environment.storageRoot = { root } + environment.makeLog = { _ in log } + environment.observesLifecycle = false + if let time { environment.time = time } + return environment +} diff --git a/clients/ios/Tests/SudoQueryTests/Support/LocalHTTPServer.swift b/clients/ios/Tests/SudoQueryTests/Support/LocalHTTPServer.swift new file mode 100644 index 0000000..214f744 --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/Support/LocalHTTPServer.swift @@ -0,0 +1,187 @@ +import Foundation +#if canImport(Darwin) +import Darwin +#endif +@testable import SudoQuery + +/// A loopback HTTP/1.1 server for tests, in the role of Android's MockWebServer: it answers each +/// request with the next queued response (200 `{}` once none is queued) and records the request. +/// The xctest hosts allow plain HTTP, so no ATS exception is needed. +final class LocalHTTPServer: @unchecked Sendable { + struct Request: Sendable { + let method: String + let path: String + let headers: [(name: String, value: String)] + let body: Data + + func header(_ name: String) -> String? { + headers.first { $0.name.lowercased() == name.lowercased() }?.value + } + + var bodyText: String { + String(decoding: body, as: UTF8.self) + } + } + + struct Response: Sendable { + var code = 200 + var headers: [(name: String, value: String)] = [] + var body = "{}" + } + + let port: UInt16 + private let socketFD: Int32 + private let state = Locked((requests: [Request](), untaken: [Request](), responses: [Response](), closed: false)) + private let arrivals = DispatchSemaphore(value: 0) + private let acceptQueue = DispatchQueue(label: "LocalHTTPServer.accept") + + init() throws { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0, listen(fd, 16) == 0 else { + let code = errno + Darwin.close(fd) + throw POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO) + } + + var actual = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + _ = withUnsafeMutablePointer(to: &actual) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &length) } + } + socketFD = fd + port = UInt16(bigEndian: actual.sin_port) + acceptQueue.async { [self] in acceptLoop() } + } + + deinit { + close() + } + + func url(_ path: String = "/v1/events/batch") -> URL { + URL(string: "http://127.0.0.1:\(port)\(path)")! + } + + func enqueue(_ response: Response) { + state.withLock { $0.responses.append(response) } + } + + func enqueue(code: Int, body: String = "{}", headers: [(name: String, value: String)] = []) { + enqueue(Response(code: code, headers: headers, body: body)) + } + + var requestCount: Int { + state.current.requests.count + } + + /// The next request not taken yet, waiting up to `timeout` for it to arrive. + func takeRequest(timeout: TimeInterval = 5) -> Request? { + guard arrivals.wait(timeout: .now() + timeout) == .success else { return nil } + return state.withLock { state in + state.untaken.isEmpty ? nil : state.untaken.removeFirst() + } + } + + /// Stops listening at once, so later connections are refused. + func close() { + let first = state.withLock { state -> Bool in + defer { state.closed = true } + return !state.closed + } + guard first else { return } + shutdown(socketFD, SHUT_RDWR) + Darwin.close(socketFD) + } + + private func acceptLoop() { + while !state.current.closed { + var descriptor = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0) + guard poll(&descriptor, 1, 100) > 0, !state.current.closed else { continue } + let client = accept(socketFD, nil, nil) + if client >= 0 { serve(client) } + } + } + + private func serve(_ client: Int32) { + defer { Darwin.close(client) } + var yes: Int32 = 1 + setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &yes, socklen_t(MemoryLayout.size)) + var timeout = timeval(tv_sec: 5, tv_usec: 0) + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + + var buffer = Data() + var chunk = [UInt8](repeating: 0, count: 4096) + let separator = Data("\r\n\r\n".utf8) + var headerEnd = buffer.range(of: separator) + while headerEnd == nil { + let count = read(client, &chunk, chunk.count) + if count <= 0 { return } + buffer.append(contentsOf: chunk[0..= 2 else { return } + let headers = lines.compactMap { line -> (name: String, value: String)? in + guard let colon = line.firstIndex(of: ":") else { return nil } + return (String(line[.. Response in + state.requests.append(request) + state.untaken.append(request) + return state.responses.isEmpty ? Response() : state.responses.removeFirst() + } + + var text = "HTTP/1.1 \(response.code) \(Self.reason(response.code))\r\n" + text += "Content-Length: \(response.body.utf8.count)\r\n" + text += "Connection: close\r\n" + for (name, value) in response.headers { + text += "\(name): \(value)\r\n" + } + text += "\r\n" + text += response.body + let bytes = Array(text.utf8) + _ = bytes.withUnsafeBytes { write(client, $0.baseAddress, $0.count) } + arrivals.signal() + } + + private static func reason(_ code: Int) -> String { + switch code { + case 200: return "OK" + case 202: return "Accepted" + case 204: return "No Content" + case 301: return "Moved Permanently" + case 307: return "Temporary Redirect" + case 400: return "Bad Request" + case 413: return "Payload Too Large" + case 429: return "Too Many Requests" + case 500: return "Internal Server Error" + case 502: return "Bad Gateway" + default: return "Status" + } + } +} diff --git a/clients/ios/Tests/SudoQueryTests/Support/TestSupport.swift b/clients/ios/Tests/SudoQueryTests/Support/TestSupport.swift new file mode 100644 index 0000000..77fc49a --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/Support/TestSupport.swift @@ -0,0 +1,270 @@ +import Foundation +import XCTest +@testable import SudoQuery + +/// 2025-10-09T08:53:20Z, a fixed "now" for tests. +let T0: Int64 = 1_760_000_000_000 + +final class FakeTime: TimeSource { + private let state = Locked((now: T0, elapsed: Int64(0))) + + var now: Int64 { + get { state.current.now } + set { state.withLock { $0.now = newValue } } + } + + var elapsed: Int64 { + get { state.current.elapsed } + set { state.withLock { $0.elapsed = newValue } } + } + + func nowMillis() -> Int64 { now } + + func elapsedMillis() -> Int64 { elapsed } +} + +final class RecordingLog: SDKLog { + private let state = Locked((warnings: [String](), errors: [String]())) + + var warnings: [String] { state.current.warnings } + var errors: [String] { state.current.errors } + + func debug(_ message: String) {} + + func warn(_ message: String, error: (any Error)?) { + state.withLock { $0.warnings.append(message) } + } + + func error(_ message: String, error: (any Error)?) { + state.withLock { $0.errors.append(message) } + } +} + +final class FakeUploads: UploadControl { + private let state = Locked((requests: 0, resets: 0, backingOff: false, idleWaits: 0)) + + var requests: Int { state.current.requests } + var resets: Int { state.current.resets } + var idleWaits: Int { state.current.idleWaits } + + var isBackingOff: Bool { + get { state.current.backingOff } + set { state.withLock { $0.backingOff = newValue } } + } + + func requestUpload() { + state.withLock { $0.requests += 1 } + } + + func resetBackoff() { + state.withLock { + $0.resets += 1 + $0.backingOff = false + } + } + + func whenIdle(_ done: @escaping @Sendable () -> Void) { + state.withLock { $0.idleWaits += 1 } + done() + } +} + +/// Replies with the queued outcomes in order, then with 200. Records every body it was given. +final class FakeTransport: Transport { + private enum Outcome { + case reply(HTTPResult) + case fail(any Error) + } + + private let state = Locked((bodies: [Data](), outcomes: [Outcome]())) + + var bodies: [Data] { state.current.bodies } + var bodyStrings: [String] { bodies.map { String(decoding: $0, as: UTF8.self) } } + + func reply(_ code: Int, body: String = "{}", retryAfterSeconds: Int64? = nil) { + state.withLock { $0.outcomes.append(.reply(HTTPResult(code: code, retryAfterSeconds: retryAfterSeconds, body: body))) } + } + + func failWith(_ error: any Error) { + state.withLock { $0.outcomes.append(.fail(error)) } + } + + func post(_ body: Data, completion: @escaping @Sendable (Result) -> Void) { + let outcome = state.withLock { state -> Outcome in + state.bodies.append(body) + guard !state.outcomes.isEmpty else { + return .reply(HTTPResult(code: 200, retryAfterSeconds: nil, body: #"{"filtered":0,"collected":1,"total":1}"#)) + } + return state.outcomes.removeFirst() + } + switch outcome { + case .reply(let result): completion(.success(result)) + case .fail(let error): completion(.failure(error)) + } + } + + /// The events of the `index`th request. + func events(_ index: Int) -> [[String: Any]] { + let body = bodies[index] + let object = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] + return object?["events"] as? [[String: Any]] ?? [] + } +} + +/// Keeps every request in flight until the test answers it with `release`. +final class HoldingTransport: Transport { + private let held = Locked<[@Sendable (Result) -> Void]>([]) + + var heldCount: Int { held.current.count } + + func post(_ body: Data, completion: @escaping @Sendable (Result) -> Void) { + held.withLock { $0.append(completion) } + } + + func release(code: Int = 200) { + let next = held.withLock { $0.isEmpty ? nil : $0.removeFirst() } + next?(.success(HTTPResult(code: code, retryAfterSeconds: nil, body: "{}"))) + } +} + +/// Polls `condition` until it holds or `timeout` passes. +func eventually(timeout: TimeInterval = 5, _ condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + if Date() > deadline { return false } + Thread.sleep(forTimeInterval: 0.01) + } + return true +} + +/// Runs scheduled work only when a test moves time forward with `advance(to:)`. +final class ManualScheduler: WorkScheduler { + private struct Entry { + let id: Int + let dueMillis: Int64 + let queue: DispatchQueue + let work: @Sendable () -> Void + } + + private let time: FakeTime + private let state = Locked((nextId: 0, entries: [Entry]())) + + init(time: FakeTime) { + self.time = time + } + + var pendingCount: Int { state.current.entries.count } + + func schedule(afterMillis delayMillis: Int64, on queue: DispatchQueue, _ work: @escaping @Sendable () -> Void) -> any Cancellable { + let due = time.elapsed + delayMillis + let id = state.withLock { state -> Int in + state.nextId += 1 + state.entries.append(Entry(id: state.nextId, dueMillis: due, queue: queue, work: work)) + return state.nextId + } + return ManualCancellable { [state] in + state.withLock { $0.entries.removeAll { $0.id == id } } + } + } + + /// Sets the elapsed time and runs the work that came due, each on its queue. + func advance(to elapsed: Int64) { + time.elapsed = elapsed + let due = state.withLock { state -> [Entry] in + let due = state.entries.filter { $0.dueMillis <= elapsed } + state.entries.removeAll { $0.dueMillis <= elapsed } + return due + } + for entry in due { + entry.queue.async { entry.work() } + } + } +} + +struct ManualCancellable: Cancellable { + let onCancel: @Sendable () -> Void + + func cancel() { + onCancel() + } +} + +/// A seeded generator, so backoff jitter is the same on every run. +struct SplitMix64: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + state = seed + } + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } +} + +func seededRandom(_ seed: UInt64 = 1) -> @Sendable (ClosedRange) -> Int64 { + let generator = Locked(SplitMix64(seed: seed)) + return { range in generator.withLock { Int64.random(in: range, using: &$0) } } +} + +/// A fresh empty directory, removed by `removeTempDirectories()`. +func makeTempDirectory(_ name: String = "sudoquery") -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(name)-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +func removeDirectory(_ url: URL) { + try? FileManager.default.removeItem(at: url) +} + +/// The objects in an NDJSON file. +func ndjsonObjects(_ url: URL) -> [[String: Any]] { + guard let data = try? Data(contentsOf: url) else { return [] } + return data.split(separator: UInt8(ascii: "\n")).compactMap { + (try? JSONSerialization.jsonObject(with: Data($0))) as? [String: Any] + } +} + +/// `value` as compact JSON with sorted keys and unescaped slashes, for comparing parsed JSON. +func canonicalJSON(_ value: Any) -> String { + guard let data = try? JSONSerialization.data( + withJSONObject: value, + options: [.sortedKeys, .withoutEscapingSlashes, .fragmentsAllowed] + ) else { return "" } + return String(decoding: data, as: UTF8.self) +} + +/// A string field, or nil when it is JSON null or missing. +func text(_ object: [String: Any], _ key: String) -> String? { + object[key] as? String +} + +extension Uploader { + /// Sends every ready batch now and waits until the uploader is idle again. + func drainAndWait(timeout: TimeInterval = 5, file: StaticString = #filePath, line: UInt = #line) { + let done = DispatchSemaphore(value: 0) + drainNow { done.signal() } + XCTAssertEqual(done.wait(timeout: .now() + timeout), .success, "the uploader did not finish", file: file, line: line) + } + + /// Asks for an upload, as the writer does, and waits until the uploader is idle again. + func requestAndWait(timeout: TimeInterval = 5, file: StaticString = #filePath, line: UInt = #line) { + let done = DispatchSemaphore(value: 0) + requestUpload() + whenIdle { done.signal() } + XCTAssertEqual(done.wait(timeout: .now() + timeout), .success, "the uploader did not finish", file: file, line: line) + } + + /// Waits until the uploader is idle. + func waitUntilIdle(timeout: TimeInterval = 5, file: StaticString = #filePath, line: UInt = #line) { + let done = DispatchSemaphore(value: 0) + whenIdle { done.signal() } + XCTAssertEqual(done.wait(timeout: .now() + timeout), .success, "the uploader did not finish", file: file, line: line) + } +} diff --git a/clients/ios/Tests/SudoQueryTests/UploaderTests.swift b/clients/ios/Tests/SudoQueryTests/UploaderTests.swift new file mode 100644 index 0000000..5c0d594 --- /dev/null +++ b/clients/ios/Tests/SudoQueryTests/UploaderTests.swift @@ -0,0 +1,285 @@ +import XCTest +@testable import SudoQuery + +final class UploaderTests: XCTestCase { + private let time = FakeTime() + private let log = RecordingLog() + private let transport = FakeTransport() + private var scheduler: ManualScheduler! + private var root: URL! + private var store: QueueStore! + private var uploader: Uploader! + + override func setUpWithError() throws { + try super.setUpWithError() + root = makeTempDirectory() + scheduler = ManualScheduler(time: time) + store = QueueStore(root: root, maxEventsPerBatch: 2, time: time, log: log) + try store.recover() + uploader = Uploader(store: store, transport: transport, time: time, scheduler: scheduler, log: log, timezone: { "Asia/Kolkata" }, random: seededRandom()) + } + + override func tearDown() { + uploader.shutdown() + store.closeOpenBatch() + removeDirectory(root) + super.tearDown() + } + + /// Commits `count` batches of two events each, numbered from 1. + private func queueBatches(_ count: Int) throws { + let first = (store.readyBatches().count) * 2 + 1 + for n in first..<(first + count * 2) { + _ = try store.append(Array(#"{"n":\#(n)}"#.utf8)) + } + } + + private func sentNumbers(_ request: Int) -> [Int] { + transport.events(request).compactMap { $0["n"] as? Int } + } + + func testSendsBatchesOldestFirstAndDeletesThem() throws { + try queueBatches(2) + uploader.drainAndWait() + + XCTAssertEqual(sentNumbers(0), [1, 2]) + XCTAssertEqual(sentNumbers(1), [3, 4]) + XCTAssertEqual(store.readyBatches().count, 0) + XCTAssertFalse(uploader.isBackingOff) + } + + func testTheBatchCarriesTheTimezoneAtBatchLevel() throws { + try queueBatches(1) + uploader.drainAndWait() + let body = try XCTUnwrap(transport.bodyStrings.first) + XCTAssertTrue(body.hasSuffix(#"],"system_properties":{"geo":null,"timezone":"Asia/Kolkata"}}"#), body) + } + + func testAny2xxCountsAsDelivered() throws { + try queueBatches(2) + transport.reply(202) + transport.reply(204, body: "") + uploader.drainAndWait() + XCTAssertEqual(store.readyBatches().count, 0) + XCTAssertEqual(transport.bodies.count, 2) + } + + func testARejectedBatchIsDroppedAndTheQueueMovesOn() throws { + for code in [400, 413, 422] { + try queueBatches(2) + transport.reply(code, body: #"{"error":"missing field `anon_id`"}"#) + uploader.drainAndWait() + XCTAssertEqual(store.readyBatches().count, 0, "HTTP \(code)") + } + XCTAssertEqual(transport.bodies.count, 6) + XCTAssertEqual(log.errors.count, 3) + XCTAssertTrue(log.errors.first?.contains("missing field `anon_id`") ?? false) + XCTAssertFalse(uploader.isBackingOff) + } + + func testARejectionLogsAtMost300CharactersOfAPlainTextBody() throws { + try queueBatches(1) + transport.reply(413, body: String(repeating: "y", count: 1_000)) + uploader.drainAndWait() + let error = try XCTUnwrap(log.errors.first) + XCTAssertTrue(error.hasSuffix(": " + String(repeating: "y", count: 300)), error) + } + + func testOtherFailuresKeepTheBatchStopTheQueueAndBackOff() throws { + for code in [301, 401, 403, 404, 408, 429, 500, 502, 503] { + uploader.resetBackoff() + try queueBatches(1) + let before = transport.bodies.count + transport.reply(code) + uploader.drainAndWait() + + XCTAssertEqual(transport.bodies.count, before + 1, "HTTP \(code)") + XCTAssertTrue(uploader.isBackingOff, "HTTP \(code)") + uploader.resetBackoff() + uploader.drainAndWait() // delivered on the next attempt + XCTAssertEqual(store.readyBatches().count, 0, "HTTP \(code)") + } + } + + func testANetworkErrorKeepsTheBatchAndStopsTheQueue() throws { + try queueBatches(2) + transport.failWith(URLError(.cannotConnectToHost)) + uploader.drainAndWait() + + XCTAssertEqual(transport.bodies.count, 1) + XCTAssertEqual(store.readyBatches().count, 2) + XCTAssertTrue(uploader.isBackingOff) + XCTAssertEqual(log.warnings.count, 1) + XCTAssertTrue(log.warnings.first?.contains("Could not reach the collector") ?? false) + } + + func testAResendIsByteForByteTheSameRequest() throws { + try queueBatches(1) + transport.reply(502) + uploader.drainAndWait() + uploader.resetBackoff() + uploader.drainAndWait() + XCTAssertEqual(transport.bodies.count, 2) + XCTAssertEqual(transport.bodies.first, transport.bodies.last) + } + + func testBackoffDoublesFrom5sUpTo5MinutesHalfOfItRandomized() throws { + try queueBatches(1) + var delays: [Int64] = [] + for _ in 1...10 { + transport.failWith(URLError(.notConnectedToInternet)) + time.elapsed = 0 + uploader.drainAndWait() + delays.append(waitTime()) + } + let ceilings: [Int64] = [5_000, 10_000, 20_000, 40_000, 80_000, 160_000, 300_000, 300_000, 300_000, 300_000] + for (delay, ceiling) in zip(delays, ceilings) { + XCTAssertTrue((ceiling / 2...ceiling).contains(delay), "\(delay) vs \(ceiling)") + } + XCTAssertEqual(log.warnings.count, 1) // later failures in the streak are debug only + } + + func testRetryAfterIsHonouredUpToAnHour() throws { + try queueBatches(1) + transport.reply(429, retryAfterSeconds: 120) + uploader.drainAndWait() + XCTAssertGreaterThanOrEqual(waitTime(), 120_000) + + uploader.resetBackoff() + transport.reply(503, retryAfterSeconds: 7_200) + uploader.drainAndWait() + XCTAssertEqual(waitTime(), 3_600_000) + } + + func testAnEmptyBatchFileIsDeletedWithoutARequest() throws { + _ = try store.append(Array(#"{"n":1}"#.utf8)) + store.commit() + let file = try XCTUnwrap(store.readyBatches().first) + try Data().write(to: file.url) + uploader.drainAndWait() + XCTAssertEqual(transport.bodies.count, 0) + XCTAssertEqual(store.readyBatches().count, 0) + } + + func testRetriesByItselfOnceTheBackoffEnds() throws { + try queueBatches(1) + transport.reply(503) + uploader.requestAndWait() + XCTAssertEqual(transport.bodies.count, 1) + XCTAssertEqual(store.readyBatches().count, 1) + + uploader.requestAndWait() // ignored while backing off + XCTAssertEqual(transport.bodies.count, 1) + XCTAssertEqual(scheduler.pendingCount, 1) + + let retryAt = waitTime() + scheduler.advance(to: retryAt - 1) // not yet + uploader.waitUntilIdle() + XCTAssertEqual(transport.bodies.count, 1) + + scheduler.advance(to: retryAt) + uploader.waitUntilIdle() + XCTAssertEqual(transport.bodies.count, 2) + XCTAssertEqual(store.readyBatches().count, 0) + } + + func testResetBackoffLetsARequestThroughAtOnce() throws { + try queueBatches(1) + transport.reply(503) + uploader.requestAndWait() + uploader.resetBackoff() + uploader.requestAndWait() + XCTAssertEqual(transport.bodies.count, 2) + XCTAssertEqual(store.readyBatches().count, 0) + } + + func testWhenIdleWaitsForTheDrainAndReturnsWhileBackingOff() throws { + let fired = Locked(0) + uploader.whenIdle { fired.withLock { $0 += 1 } } + uploader.waitUntilIdle() + XCTAssertEqual(fired.current, 1) // nothing to do: idle at once + + try queueBatches(2) + transport.reply(500) + uploader.requestAndWait() // stops at the first batch and backs off, which counts as idle + XCTAssertEqual(transport.bodies.count, 1) + XCTAssertTrue(uploader.isBackingOff) + } + + func testWhenIdleWaitsForTheRequestInFlight() throws { + let holding = HoldingTransport() + let uploader = Uploader(store: store, transport: holding, time: time, scheduler: scheduler, log: log, random: seededRandom()) + defer { uploader.shutdown() } + try queueBatches(1) + + let done = DispatchSemaphore(value: 0) + uploader.requestUpload() + uploader.whenIdle { done.signal() } + XCTAssertTrue(eventually { holding.heldCount == 1 }) + XCTAssertEqual(done.wait(timeout: .now() + 0.3), .timedOut) // the batch hasn't been answered yet + + holding.release() + XCTAssertEqual(done.wait(timeout: .now() + 5), .success) + XCTAssertEqual(store.readyBatches().count, 0) + } + + func testANewFailureAfterAResetReplacesTheWakeUp() throws { + try queueBatches(1) + transport.reply(503) + uploader.requestAndWait() + let firstRetry = waitTime() + XCTAssertEqual(scheduler.pendingCount, 1) + + uploader.resetBackoff() + transport.reply(503, retryAfterSeconds: 600) + uploader.requestAndWait() + XCTAssertEqual(scheduler.pendingCount, 1) // the earlier wake-up was cancelled + XCTAssertEqual(waitTime(), 600_000) + + scheduler.advance(to: firstRetry) + uploader.waitUntilIdle() + XCTAssertEqual(transport.bodies.count, 2) + + scheduler.advance(to: 600_000) + uploader.waitUntilIdle() + XCTAssertEqual(transport.bodies.count, 3) + XCTAssertEqual(store.readyBatches().count, 0) + } + + func testNothingIsSentAfterShutdown() throws { + try queueBatches(1) + transport.reply(503) + uploader.requestAndWait() + uploader.shutdown() + + scheduler.advance(to: 3_600_000) + uploader.requestAndWait() + XCTAssertEqual(transport.bodies.count, 1) + XCTAssertEqual(store.readyBatches().count, 1) + } + + func testRequestsDuringADrainAreSentInOneMoreDrain() throws { + try queueBatches(1) + uploader.requestUpload() + try queueBatches(1) + uploader.requestUpload() + uploader.requestUpload() + uploader.waitUntilIdle() + XCTAssertEqual(transport.bodies.count, 2) + XCTAssertEqual(store.readyBatches().count, 0) + } + + /// How long the uploader will wait before retrying, read through its public backoff check. + private func waitTime() -> Int64 { + let start = time.elapsed + var low: Int64 = 0 + var high: Int64 = 2 * 60 * 60 * 1000 + while low < high { + let mid = (low + high) / 2 + time.elapsed = start + mid + if uploader.isBackingOff { low = mid + 1 } else { high = mid } + } + time.elapsed = start + return low + } +}