# Set up Union in your iOS app You are integrating the Union iOS SDK (https://github.com/useunion/union-ios, v0.3.3). This is the generic version of the prompt; the one in the Union panel (Settings → SDK setup) is filled in with the project's write key, privacy mode and integrations. ## Goal Integrate the Union iOS SDK into this app. Union is product analytics and crash reporting for mobile apps: live sessions, screens, feature funnels, releases, retention, crashes and revenue. The SDK is a Swift Package with no third-party dependencies, no IDFA and no ATT prompt. Work in four phases and do not skip ahead: **explore → install and configure → propose a feature plan and wait for approval → instrument and verify.** The rules below are enforced by the Union server. Most mistakes are not errors — the event is accepted and the numbers are quietly wrong — so follow them exactly rather than approximately. ## 1. Explore before you edit Read the project and report back, before changing anything: - The app entry point (SwiftUI `App` struct, or `AppDelegate` / `SceneDelegate`) and the deployment target. The SDK needs **iOS 16+**; if the target is lower, stop and say so. - How screens are built and navigated (SwiftUI `NavigationStack`, tabs, sheets, UIKit view controllers). - Sign-in and sign-out, and what stable user id the backend uses. - Purchases: StoreKit 2 `product.purchase(…)`, StoreKit 1, RevenueCat, or none. - Deep links (`onOpenURL`, `scene(_:openURLContexts:)`, universal links). - Survicate, if present. - Any existing analytics (Firebase, Mixpanel, Amplitude, PostHog, custom). Their call sites are the best map of the moments worth measuring — but do not copy their event names blindly; they must follow the naming rules below. Do not remove the other SDK unless asked. ## 2. Install Add the Swift Package `https://github.com/useunion/union-ios` (from: "0.3.3") and link the `Union` library to the **app target only** (not extensions or test targets). With an `.xcodeproj`, add it through the project's package dependencies; with a `Package.swift`, add: ```swift .package(url: "https://github.com/useunion/union-ios", from: "0.3.3") ``` Do not add CocoaPods or Carthage variants — there are none. ## 3. Configure Call `configure` exactly once, as early as possible — in the SwiftUI `App` initializer or at the top of `application(_:didFinishLaunchingWithOptions:)`: ```swift import Union // privacyMode must match the project's setting in Union (Settings → Privacy): // .productAnalytics or .strictAnonymous. It cannot be changed after the project is created. Union.configure(writeKey: "av_…", privacyMode: .productAnalytics) ``` - Replace `av_…` with the project's client write key from Union → Settings → SDK write keys. It is meant to ship in the app binary. - One key serves every build. The SDK detects the environment itself (DEBUG → `development`, TestFlight → `testflight`, App Store → `production`) and sends it with every batch. Do **not** set `Options.environment` unless the user asks for it. - For local debugging, raise the log level in DEBUG builds only: ```swift var options = Options() #if DEBUG options.logLevel = .debug #endif Union.configure(writeKey: "av_…", privacyMode: .productAnalytics, options: options) ``` - `configure` already sends installs, updates, sessions, foreground/background and device context. Do not send your own `app_opened`, `session_started` or similar — they duplicate what the panel already counts. - Deep links: call `Union.handleDeepLink(url)` from every place the app receives a URL. It records scheme, host and path only, never the query or fragment. ## 4. Screens - SwiftUI: add `.trackScreen("Name")` to the root view of each screen (not to reusable subviews, rows or cells). UIKit: set `options.automaticScreenTracking = true`, or call `Union.screen("Name")` in `viewDidAppear`. - Names are stable and PascalCase, one per screen: `"Checkout"`, `"WorkoutDetail"`. **Never put data in a screen name** (`"Profile-\(userId)"`, a title, a date) — every distinct string becomes a separate screen. - Sheets and full-screen covers that the user perceives as a screen get their own name. Alerts, menus and toasts do not. ## 5. Identity **If the project is Product Analytics:** call `Union.identify(userId:)` right after sign-in (and at launch when a session is restored) with the backend's **stable, opaque user id** — never an email address, phone number or name as the id. Call `Union.reset()` on sign-out. - Traits (`identify(userId:traits:)`, ≤ 8 keys, values ≤ 256 characters) are optional. If you add `email` or `name`, the app is collecting that data and it must appear in the App Store privacy answers — tell the user rather than deciding for them. **If the project is Strict Anonymous:** Do **not** call `Union.identify` or `Union.setCrashKey`, and do not read `Union.installId` / `Union.installUUID` (they are `nil`). The server rejects identity in this mode with `privacy_violation`. Screens, events, features and crashes all still work. ## 6. Feature plan — propose it, then wait for approval A feature in Union is a funnel built from events the code **declares**. A declaration from a TestFlight or App Store build creates the feature in the panel permanently (development builds only pre-fill the editor). So before instrumenting anything, write a plan and **stop until the user approves it**. Present it as a table: `feature key | event name | role | where it fires (file / function) | properties`. Then implement it with the feature handle: ```swift let checkout = Union.feature("checkout") checkout.screen("Checkout") // discovery checkout.start("checkout_started") // start checkout.use("shipping_selected") // use checkout.success("order_placed", properties: ["items": 3]) // success checkout.failure("payment_failed", properties: ["reason": "card_declined"]) // failure ``` Roles — use them precisely: - `discovery` — saw that the feature exists, has not tried it yet (a screen view, opening a menu). `feature.screen(…)` sets it for you. - `start` — made an attempt whose outcome is not known yet. **This is the funnel's denominator**; a feature without `start` has no funnel, only a counter. - `use` — working inside the feature; may happen many times per attempt. It is **not** a funnel step and never the default bucket. - `success` — the feature gave the user what they came for. Once per attempt, not once per tap. - `failure` — the attempt failed **from the user's point of view**: permission denied, no results, a save that did not happen. Not every caught error — those go to `Union.recordError`. Rules the server enforces: - **One event name = one (feature, role) pair**, everywhere in the code. The same name as `success` in one place and `failure` in another is a conflict the server shows and never resolves. - Giving up is its **own event name** (`onboarding_completed` and `onboarding_abandoned`), not one event with a `skipped` property. - A feature is something a user **can fail to adopt**. Never name a feature after a data model or a core surface every user touches on day one (`items`, `home`, `users`) — it reports 100% adoption and says nothing. Send those events without a feature key. - Every feature needs a real path to `start` and `success`. If the attempt can visibly fail, give that path a `failure` event, or the funnel reads as if nobody ever fails. - A declared feature that nothing emits is worse than none: it reads as zero adoption. Declare only what you actually instrument. - Aim for 3–8 features for a typical app. Fewer, well-bounded features beat many thin ones. - Feature keys follow the event-name rules below. A typo creates a second feature, so define each key once (a constant or a static handle) and reuse it. - Events that belong to no feature are fine: `Union.track("settings_changed", properties: ["setting": "units"])`. ## 7. Event names and properties - Names: lowercase `snake_case`, matching `^[a-z][a-z0-9_]*$`, ≤ 64 characters, `object_action` in the past tense (`workout_finished`, `photo_shared`). The `$` prefix is reserved for the SDK — never use it. - Properties: ≤ 32 keys per event, keys in the same `snake_case` form; values are a string (≤ 256 characters), a finite number or a bool. No nested objects, arrays or dates (send an ISO string or a number). - Keep values **low-cardinality** (an enum-like `plan: "pro"`, a count, a duration in seconds). Ids, timestamps and free text in properties make every value unique and useless for breakdowns. - An invalid event is logged and **dropped**, never truncated or partially sent. The SDK never throws or crashes the app. - `Union.track(_:properties:screen:)` can say where an event happened (`screen: "Checkout"`) without counting a screen view. ## 8. What must never be sent - Anything the user typed: search queries, messages, notes, form fields, names of things they created. - Tokens, passwords, API keys, receipts, full URLs or query strings. - Precise location, contacts, photos metadata, advertising identifiers. - Survey answers, comments or scores. - Free text in `Union.recordError(_:reason:)` — use a short code (`"card_declined"`), not an exception message that may contain user data. If a moment is only interesting because of its content, send the fact and a category (`note_created`, `length_bucket: "long"`), never the content. ## Purchases Union links purchases, renewals and refunds to a person. Do this whichever purchase stack the app uses: With **StoreKit 2**, pass Union's install id as the `appAccountToken` on every purchase: ```swift let result = try await product.purchase( options: Union.installUUID.map { [.appAccountToken($0)] } ?? [] ) ``` - The token **must be a UUID**; the App Store silently accepts other values and links nothing. If the app already sets `appAccountToken` to something else, stop and ask — there can be only one. - It is not retroactive: purchases made before it ships can never be linked. It must ship before the first release that sells anything. With **RevenueCat**, set the subscriber attribute after `Purchases.configure` and whenever the user changes: ```swift if let id = Union.installId { Purchases.shared.attribution.setAttributes(["union_install_id": id]) } ``` - Call `Union.identify(userId:)` with the same id the app passes to RevenueCat as `app_user_id` (`Purchases.logIn`). - Revenue itself arrives through RevenueCat's webhook to Union — do not send purchase events from the app. If the project has App Store Server Notifications connected in Union, those own the revenue numbers and RevenueCat is not counted; set the token either way. ## Survicate (NPS) Only if the app uses Survicate: ```swift if let id = Union.installId { SurvicateSdk.shared.setUserTrait(UserTrait(withName: "union_install_id", value: id)) } ``` - That trait puts each answer on the right person. Set it after `Union.configure`. - From Survicate's survey listener, send exactly these two events — the server looks the names up literally, so a typo fails silently: ```swift Union.track("survey_displayed", properties: ["survey_id": surveyId]) Union.track("survey_closed", properties: ["survey_id": surveyId]) ``` - Never send the answer, score or comment to Union. Scores arrive from Survicate's webhook; answer text is never stored. ## Crashes, hangs and errors Crash and hang reporting is on by default once `configure` runs — do not add another crash reporter's handlers on top without asking (if the app already has Crashlytics or Sentry, report it and let the user decide). A crash is uploaded on the **next launch**, so it appears later, not immediately. - `Union.recordError("PaymentFailed", reason: "card_declined")` or `Union.recordError(error)` for caught errors that matter. Not for expected control flow. - `Union.leaveBreadcrumb("retrying_upload")` before risky operations — a name only, never a value. - `Union.setCrashKey("plan", "pro")` for up to 8 labels on every later report. Same rules as properties: no ids, no personal data. Upload dSYMs so crash frames get function names. Use whichever CI the project has; the key is a **server key** stored as the secret `UNION_SERVER_KEY` (created in Union → Settings → Server keys) — never paste it into the project or the prompt. The script is pinned by SHA-256 90401dd15c53…, and must be downloaded and verified exactly as shown: - **Xcode Cloud** (`ci_scripts/ci_post_xcodebuild.sh`): Add UNION_SERVER_KEY to the workflow as a secret environment variable. Runs after every archive action. ```sh #!/bin/sh set -e if [ "$CI_XCODEBUILD_ACTION" = "archive" ]; then curl -fsSL https://useunion.dev/dsyms/v2.sh -o "/tmp/union-dsyms.sh" \ && echo "90401dd15c53b179127a03eb1ffb47c6dd24d3937a755fd9816712ee6434e521 /tmp/union-dsyms.sh" | shasum -a 256 -c - \ && bash "/tmp/union-dsyms.sh" "$CI_ARCHIVE_PATH" fi ``` - **fastlane** (`fastlane/Fastfile`): After build_app (gym), which leaves the zipped dSYMs at DSYM_OUTPUT_PATH. Set UNION_SERVER_KEY in your CI secrets. ```ruby lane :release do build_app(scheme: "MyApp") sh(<<~SH) curl -fsSL https://useunion.dev/dsyms/v2.sh -o "/tmp/union-dsyms.sh" \ && echo "90401dd15c53b179127a03eb1ffb47c6dd24d3937a755fd9816712ee6434e521 /tmp/union-dsyms.sh" | shasum -a 256 -c - \ && bash "/tmp/union-dsyms.sh" "#{lane_context[SharedValues::DSYM_OUTPUT_PATH]}" SH upload_to_testflight end ``` - **Build phase** (Run Script phase, last in the target): Release builds only. Turn off User Script Sandboxing for the target (the script reads the dSYM and uses the network), and provide UNION_SERVER_KEY from the environment — never paste it into the project. ```sh [ "$CONFIGURATION" = "Release" ] || exit 0 curl -fsSL https://useunion.dev/dsyms/v2.sh -o "$DERIVED_FILE_DIR/union-dsyms.sh" \ && echo "90401dd15c53b179127a03eb1ffb47c6dd24d3937a755fd9816712ee6434e521 $DERIVED_FILE_DIR/union-dsyms.sh" | shasum -a 256 -c - \ && bash "$DERIVED_FILE_DIR/union-dsyms.sh" "$DWARF_DSYM_FOLDER_PATH" ``` Set up only the one that matches the project, and tell the user to add the secret. ## Server-side events Events that happen on the app's backend (a subscription renewed server-side, an invoice paid) go to `POST https://in.useunion.dev/v1/server` with a **server key** (`sk_…`) — from the backend only, never from the app and never committed. The SDK never calls that endpoint. If the backend needs to attribute an event to the device, the app sends `Union.installId` to its own backend. Only mention this to the user; do not implement backend code unless asked. ## App privacy The SDK ships a `PrivacyInfo.xcprivacy`. Tell the user which App Store privacy answers apply — you cannot change them in code: - Product Analytics: Product Interaction, Device ID and User ID — linked to the user, used for analytics, **not** tracking. Plus anything added as traits (email, name). - Strict Anonymous: Product Interaction, not linked to the user. Remove the Device ID and User ID entries from the app's own privacy manifest answers. - Crash data counts as Other Diagnostic Data. - Union uses no IDFA and needs no App Tracking Transparency prompt. Do not describe the data as "fully anonymous" in the app's privacy copy unless the project is Strict Anonymous. ## Verify 1. The project builds (`xcodebuild` for the app scheme, or build in Xcode) with no new warnings from the integration. 2. Run a DEBUG build in the simulator. With `logLevel = .debug` the SDK logs to the unified log under subsystem `app.union.sdk` (visible in the Xcode console). Invalid events are logged there with the reason — fix every one. 3. Background the app (or wait 10 s) to flush. In the Union panel, switch the environment to **Sandbox** — a DEBUG build is `development`, which the Production view never shows — and open **Live**. The session should appear within seconds. 4. Walk through each planned feature once and confirm its events show up under **Data** with the declared feature and role. ## Final report Finish with: - The files you changed. - The event catalog as a table: event | feature | role | properties | where it fires. - What you deliberately did **not** instrument, and why. - What is left for a human: App Store privacy answers, the `UNION_SERVER_KEY` CI secret for dSYM upload, connecting purchases in Union Settings, and anything in the plan they still need to decide. --- More: https://useunion.dev/docs (server-side events, dSYM upload). SDK source: https://github.com/useunion/union-ios