Skip to main content
Run multiple independent Userpilot instances in the same process — for example when your app integrates Userpilot directly and also embeds a vendor SDK that bundles Userpilot for its own users. Each instance keeps its own analytics, autocapture routing, storage, and experience UI without interfering with the others.
NoteIf you only have one Userpilot instance in your app, nothing changes. Keep using the Userpilot instance returned by Userpilot(config:). The SDK still resolves unattributed autocapture and screen-tracking events through the instance that claimed the default role (isDefault, which defaults to true).

Quick Reference

ScenarioUse
Single instance (host app only)Userpilot(config:)isDefault defaults to true, so the host claims default automatically
Vendor SDK that embeds UserpilotUserpilot(config:) inside the vendor facade with .defaultInstance(false)
Re-initialize idempotentlyUserpilot(config:) again — returns the existing instance for the same token
Userpilot.Config.isDefault defaults to true. The host application does not need to call .defaultInstance(true) — a plain Userpilot.Config(token:) already claims the default role. Embedded vendor SDKs must call .defaultInstance(false) so they do not compete for that role. The two initialization entry points that used to exist (Userpilot(config:) and Userpilot.create(config:)) are collapsed into a single Userpilot(config:). An instance claims the default role on registration when the role is unclaimed. Subsequent calls with the same token return the existing instance. A second instance with a different token that also leaves isDefault at its default cannot displace an existing claimant — the SDK logs a warning and un-anchored events keep routing to whoever already holds the role.

Event Routing

Every event — autocapture interaction, autocapture screen, or a manual screen(_:) / track(_:) — routes to one Userpilot instance using a single three-tier rule:
  1. Explicit userpilot: argument, when the API exposes one (for example View.userpilotScreen(_:userpilot:)).
  2. Anchored owner, when something in the UI hierarchy claims the originating UI (Config.attach(viewControllerClasses:), Config.attach(windows:), Config.attach(bundles:)).
  3. Default — the instance that claimed isDefault (defaults to true).
If none of those resolve to a live instance, the event is a silent no-op. There is no fan-out. In the single-instance case the only registered instance claims the default automatically (isDefault defaults to true), so nothing in your integration changes.

Single-Instance Integration

let config = Userpilot.Config(token: "YOUR_TOKEN")
    .logging(enabled: true)
    .enableScreenAutoCapture()
    .enableInteractionAutoCapture()

let userpilot = Userpilot(config: config)   // isDefault defaults to true → claims default
userpilot.identify(userId: "user-123")
userpilot.track(eventName: "Added to Cart")
No .defaultInstance(true) is required — isDefault already defaults to true, so the host app’s plain Config claims the default role on init.

Multi-Instance Integration

Vendor SDK

If you ship an SDK that uses Userpilot internally, initialize from inside your SDK facade. The factory is idempotent — re-calls with the same token return the existing instance.
public final class AcmeSDK {
    public static let shared = AcmeSDK()

    private let acmeUserpilot: Userpilot

    private init() {
        let config = Userpilot.Config(token: "ACME")
            .defaultInstance(false)
            .enableScreenAutoCapture()
            .enableInteractionAutoCapture()
            .attach(bundles: [Bundle(for: AcmeSDK.self)])

        acmeUserpilot = Userpilot(config: config)
    }

    public func identifyAcmeUser(_ id: String) {
        acmeUserpilot.identify(userId: id)
    }
}
Key points:
  • isDefault defaults to true. Every Config attempts to claim the default role on registration unless you opt out. Vendor SDKs must call .defaultInstance(false) so they do not compete for that single slot. When every instance opts out, there is no default and un-anchored events are dropped.
  • Default resolution is claim-based, not registration-order-based — the instance that successfully holds the isDefault claim becomes the SDK’s default fallback. Init order between host and vendor does not matter when the vendor opts out; only one claimant is allowed at a time, and a second conflicting claim is rejected.
  • attach(bundles:) teaches the autocapture router which UI events belong to your tenant.
  • Keep a strong reference to your instance (for example a stored property on your facade). The registry holds instances weakly.

Host app + embedded vendor

Once the vendor SDK opts out and anchors its UI, the host app does nothing special for default resolution:
let config = Userpilot.Config(token: "HOST")
    .enableScreenAutoCapture()
    .enableInteractionAutoCapture()
    // no .defaultInstance(true) needed — isDefault defaults to true

let hostUserpilot = Userpilot(config: config)
AcmeSDK.shared.identifyAcmeUser("acme-99")
Both instances are now live and independent. Init order does not affect which tenant is default as long as the vendor opts out with .defaultInstance(false).

The Default Instance

Only one instance can hold the default role at a time. An instance with isDefault left at its default (true) claims that role on registration when the role is unclaimed. If the role is already held, the new claim is rejected (a warning is logged) and the existing claimant keeps the role. This is claim-based, not registration-order-based: registering first does not make an instance the default unless it also opts in, and there is no first-registered fallback when every instance opts out. Correct setup: the host leaves isDefault at its default; every embedded vendor calls .defaultInstance(false). The host then holds the default role regardless of init order. Misconfiguration: if two instances both leave isDefault = true, whichever registers while the role is still unclaimed becomes the default; any later conflicting claim is rejected. Do not rely on init order — always opt vendor instances out. If no instance claims the default role (every instance called .defaultInstance(false)), un-anchored events are dropped rather than attributed to an arbitrary tenant. Keep the default of true on the host app so it owns un-anchored events. Surviving instances are not auto-promoted when the default is torn down. Call Userpilot(config:) again with a config that leaves isDefault at its default to reclaim the role. For manual API calls, keep and pass the explicit Userpilot instance you created.

Autocapture Routing

Autocapture events are attributed to the instance that owns the originating UI. The SDK walks the responder chain from the touched view up to the nearest UIViewController and checks each registered instance’s claimed scope, in this priority order:
  1. attach(viewControllerClasses:) — view controller class match (subclass-aware).
  2. attach(windows:) — the view controller’s containing window matches.
  3. attach(bundles:)Bundle(for: VC.class) identifier matches.
  4. Default fallback — routes to the instance that claimed isDefault, or is dropped when no default exists.
Vendor situationRecommended attach
Vendor UI lives in its own framework.attach(bundles: [Bundle(for: AcmeSDK.self)])
Vendor presents on a custom UIWindow.attach(windows: [acmeWindow])
Vendor UI is mixed into the host bundle (rare).attach(viewControllerClasses: [AcmeViewController.self]) per view controller
Pure SwiftUI vendor view inside a generic hosting controller.attach(windows:) on the vendor overlay window, or .attach(viewControllerClasses:) on a wrapper view controller
.attach(...) calls are additive — call them as many times as needed and they stack into a single Config.

SwiftUI ownership

UIHostingController lives in com.apple.SwiftUI, so its bundle never matches a vendor claim. The SDK walks viewController.parent outward from hosting and container view controllers until it finds an ownable view controller. This usually works when SwiftUI views are nested inside a UIKit container in your bundle. For pure SwiftUI vendor UI with no UIKit boundary, prefer .attach(windows:) on a vendor-owned overlay window, or pass userpilot: explicitly on SwiftUI screen-tracking modifiers.

SwiftUI Screen Events

The View.userpilotScreen(_:userpilot:) modifier accepts an optional explicit userpilot: argument:
struct AcmeSettings: View {
    let acmeUserpilot: Userpilot

    var body: some View {
        VStack { /* … */ }
            .userpilotScreen("Acme.Settings", userpilot: acmeUserpilot)
    }
}
If userpilot: is passed, the event publishes on that instance. Otherwise it publishes on the default instance. If neither resolves, the event is a no-op.

Forwarding Events to the Host

By default, autocapture events publish only on the instance that owns the originating UI. A vendor-owned screen or tap is reported to the vendor tenant only. If the host app wants autocapture events that originate inside embedded vendor SDKs, opt in on the host (default) instance:
let hostConfig = Userpilot.Config(token: "HOST")
    .enableScreenAutoCapture()
    .enableInteractionAutoCapture()
    .allowReceiveEventsFromExternalSource()

let hostUserpilot = Userpilot(config: hostConfig)
When enabled:
  • Autocapture events that resolve to a non-default instance are published to that instance and forwarded to the default instance.
  • The flag is read only on the resolved default instance. Setting it on a vendor instance has no effect.
  • Forwarded events are delivered unchanged through the host’s publisher — associated with the host’s user/session and reported to the host backend. There is no extra tagging.
  • The host’s own events are never duplicated to itself, and forwarding never re-routes, so there is no fan-out loop.
Routing still decides the owning tenant; forwarding only adds a copy to the default when the default opted in.

Idempotent Initialization

Userpilot(config:) is a get-or-create factory. Calling it twice for the same token returns the same instance. The isDefault flag on a repeat call is ignored when the instance already exists — the original default claim (or lack thereof) is preserved:
let a = Userpilot(config: .init(token: "HOST"))   // claims default (isDefault defaults true)
let b = Userpilot(config: .init(token: "HOST"))
// a and b share the same dependency container. The supplied config on `b` is discarded.
This protects against accidental double initialization from SceneDelegate / AppDelegate overlap.

Experience Presentation

Each Userpilot instance owns its own dedicated overlay UIWindow. When an experience triggers, it presents on that window, not on the host app’s key window:
  • Two instances can present experiences simultaneously on different window levels.
  • Touches outside any presented experience pass through so the underlying app stays interactive.
  • Window levels are deterministic and stable across launches.

Push Notifications

PushNotificationAutoConfig registers one monitor per Userpilot instance:
  • Token registration fans out to every monitor so each instance can forward the token to its backend.
  • Notification responses use the payload app_token to route directly to the matching Userpilot instance. Older payloads without app_token fall back to trying registered monitors until one claims the response.

Limitations

  • Static autocapture flags. UIKit method swizzles install once per process for the union of all instances’ enabled features. Autocapture uses each instance’s config to filter events at delivery time.
  • The default fallback is not thread-locked to a tenant. It always points at the default claimant (isDefault). To act on a specific tenant, keep and pass the explicit Userpilot instance you created. When no instance holds the default role, there is no first-registered fallback.
  • stopAutoCapture() / resumeAutoCapture() are per-instance methods.
  • Userpilot.enableAutomaticPushConfig() is process-wide — calling it from any instance enables it for all.
  • SwiftUI ownership can be ambiguous with no UIKit container in your bundle. Prefer .attach(windows:) or pass userpilot: explicitly.
  • Two instances with the same token never happens through the public factory — Userpilot(config:) is idempotent.

Complete Example

// AppDelegate.swift (host app)
let hostConfig = Userpilot.Config(token: "HOST")
    .logging(enabled: true)
    .enableScreenAutoCapture()
    .enableInteractionAutoCapture()
let hostUserpilot = Userpilot(config: hostConfig)   // isDefault defaults to true → claims default

// AcmeSDK.swift (vendor SDK)
let vendorConfig = Userpilot.Config(token: "ACME")
    .defaultInstance(false)
    .enableScreenAutoCapture()
    .enableInteractionAutoCapture()
    .attach(bundles: [Bundle(for: AcmeSDK.self)])
let acmeUserpilot = Userpilot(config: vendorConfig)

hostUserpilot.identify(userId: "host-42")
acmeUserpilot.identify(userId: "acme-99")
acmeUserpilot.track(eventName: "vendor_action")