Skip to content

event system

Joseph Samir edited this page Jun 21, 2026 · 2 revisions

Event System

In ApiManager, we saw how the SDK acts like a messenger, talking to the Convert servers to fetch configuration data and send back tracking reports. This handles communication with the outside world.

But how do different parts inside the SDK talk to each other? For example, when the configuration data is fetched successfully, how does the rest of the SDK know "the data is here, we're ready to go!"?

The Problem: Internal Announcements

Imagine a large office building (the SDK). Different departments (the Managers like DataManager, ConfigFetchService, etc.) are working on their tasks. When one department finishes something important, how do they notify other departments that might need to know?

  • The Mail Room (ConfigFetchService) receives a big package (the project configuration). How does it announce this arrival?
  • The Decision Desk (BucketingManager) assigns someone to a specific project (buckets a visitor). How can external code know about this decision to record it?
  • The Front Desk (ConvertSwiftSDK) needs to know when the entire office is officially open for business (SDK is initialized and ready).

They need a structured way to make announcements and for interested departments to listen to those announcements.

What is EventBus? The Office Intercom System

Meet the EventBus! Think of it as the internal intercom or announcement system for the Convert SDK. It allows different parts of the SDK to broadcast messages (events) and other parts (or even you, the developer) to listen for specific messages.

It is implemented as a Swift actor (public actor EventBus) so its subscriber table is actor-isolated — concurrent on/off/fire calls are race-free with no manual locks (AR12 — "all shared mutable state lives in a Swift actor").

It works on a simple Publish/Subscribe (or "Pub/Sub") model:

  1. Publishing (Firing Events): When something significant happens, a component calls eventBus.fire(_:payload:) with a SystemEvent case. This is like making an announcement over the intercom. For example:

    • ConfigStore fires .ready when initialization is complete.
    • ConfigStore fires .configUpdated when new configuration data is fetched.
    • ExperienceManager fires .bucketing when a visitor is assigned to a variation.
    • ConvertContext fires .conversion when trackConversion is called.
  2. Subscribing (Listening with on): External code (or other components) registers interest in specific events using sdk.on(_:callback:). When that event fires, the listener's callback is executed as a @MainActor task.

This system allows different parts of the SDK to communicate and react to happenings without being tightly coupled or needing direct references to each other.

How it's Used

1. Awaiting Readiness (sdk.ready()):

The most common way you'll interact with the event system is via sdk.ready().

When you create a ConvertSwiftSDK instance, initialization starts in a detached Task. Once the first config load resolves, ConfigStore.setConfig(_:) fires .ready via the shared EventBus, and resumes all waitForReady() continuations. Calling try await sdk.ready() suspends on a CheckedContinuation that is resumed by this exact signal.

2. Subscribing to SDK Events:

You can listen for events by calling on on the SDK instance:

let token = await sdk.on(.bucketing) { payload in
    if case let .bucketing(info) = payload {
        print("Bucketed visitor \(info.visitorId) into variation \(info.variationId)")
    }
}
// Later, cancel the subscription:
await sdk.off(token)
  • on(_:callback:) returns an EventListenerToken (an opaque Sendable wrapper around a UUID). Pass it to off(_:) to cancel.
  • The callback receives an EventPayloadValue enum — a typed payload carrying structured data for that specific event.
  • Each callback is dispatched as an independent @MainActor task (fire-and-forget), so callers on any actor or thread can subscribe without blocking.

3. Available Events (SystemEvent):

The SDK uses the SystemEvent enum — a frozen JS-parity contract (FR52). The set of cases and their raw values must never be changed. Raw values are the exact wire strings verified against the JS SDK source:

Case Raw value Triggered by
.ready "ready" SDK initialization complete (ConfigStore.setConfig)
.configUpdated "config.updated" Configuration refreshed (post-ready ConfigStore.setConfig)
.apiQueueReleased "api.queue.released" Tracking queue flushed successfully (EventQueue)
.bucketing "bucketing" Visitor bucketed into a variation (ExperienceManager)
.conversion "conversion" Conversion goal tracked (ConvertContext.trackConversion)
.segments "segments" Segments updated (ConvertContext.setDefaultSegments / setCustomSegments)
.locationActivated "location.activated" Location rules matched
.locationDeactivated "location.deactivated" Location rules no longer matched
.audiences "audiences" Audience membership resolved
.dataStoreQueueReleased "datastore.queue.released" On-disk data store queue flushed

Note on fire: The EventBus.fire(_:payload:) method is package-scoped (not public). SDK consumers cannot spoof system events onto the bus — only in-package components can fire. The public API surface is on/off only.

Typed Payloads (EventPayloadValue)

Every event delivers a typed EventPayloadValue enum value to the callback. Each case wraps a Sendable struct carrying the structured data for that event:

SystemEvent case EventPayloadValue case Payload struct Key fields
.ready .ready(ReadyPayload) ReadyPayload (empty)
.configUpdated .configUpdated(ConfigUpdatedPayload) ConfigUpdatedPayload snapshot: ProjectConfig?
.apiQueueReleased .apiQueueReleased(ApiQueueReleasedPayload) ApiQueueReleasedPayload eventCount: Int
.bucketing .bucketing(BucketingPayload) BucketingPayload experienceId, variationId, visitorId
.conversion .conversion(ConversionPayload) ConversionPayload goalId, visitorId
.segments .segments(SegmentsPayload) SegmentsPayload visitorId, segments: Segments
.locationActivated .locationActivated(LocationActivatedPayload) LocationActivatedPayload properties: [String: String]
.locationDeactivated .locationDeactivated(LocationDeactivatedPayload) LocationDeactivatedPayload (empty)
.audiences .audiences(AudiencesPayload) AudiencesPayload audienceIds: [String], visitorId
.dataStoreQueueReleased .dataStoreQueueReleased(DataStoreQueueReleasedPayload) DataStoreQueueReleasedPayload (empty)

Use Swift pattern matching to extract the typed payload:

let token = await sdk.on(.conversion) { payload in
    if case let .conversion(info) = payload {
        print("Goal \(info.goalId) converted by visitor \(info.visitorId)")
    }
}

Under the Hood: The Intercom Mechanism

1. Keeping the Subscriber Table (actor-isolated):

EventBus stores its subscribers in a private var subscribers dictionary, actor-isolated:

subscribers: [SystemEvent: [EventListenerToken: @Sendable (EventPayloadValue) -> Void]]

The keying by EventListenerToken (not by index) means removing one subscription (off) is O(n events) but idempotent and does not disturb unrelated subscriptions.

2. Subscribing (on):

When on(_ event:, callback:) is called (from ConvertSwiftSDK.on(_:callback:) which forwards to the actor), it inserts the callback into subscribers[event] under a fresh EventListenerToken and returns that token.

3. Firing (fire):

When fire(_:payload:) is called (package-internal), the EventBus actor:

  1. Looks up all callbacks for the event.
  2. For each callback, spawns an independent Task { @MainActor in callback(payload) } — fire-and-forget; callers never block on callback completion.
  3. A failing callback does NOT crash the SDK (the task is isolated and terminates independently).

4. No Deferred Events:

Unlike the JS/Android SDKs, the iOS EventBus does NOT have a deferred/replay mechanism for .ready. Readiness is instead exposed via ConfigStore.waitForReady() — a CheckedContinuation-based async gate that resolves immediately if already ready. Calling sdk.ready() after initialization has already completed returns without suspending.

Sequence Diagram: sdk.ready() Flow

sequenceDiagram
    participant App as Your App
    participant SDK as ConvertSwiftSDK
    participant Store as ConfigStore (actor)
    participant Bus as EventBus (actor)
    participant CFS as ConfigFetchService

    App->>SDK: ConvertSwiftSDK(configuration:)
    Note over SDK: Launches detached Task for config load
    App->>+Store: waitForReady() [via sdk.ready()]
    Note over Store: Suspension — adds CheckedContinuation

    SDK->>+CFS: fetchLiveConfig()
    CFS-->>-SDK: ProjectConfig
    SDK->>+Store: setConfig(config)
    Store->>+Bus: fire(.ready, payload: .ready(ReadyPayload()))
    Bus-->>-Store: dispatched (MainActor tasks)
    Store->>Store: resume all continuations
    Store-->>-App: (waitForReady returns)

    App->>App: SDK is ready — call createContext(), runExperience(), etc.
Loading

Concurrency Notes (Swift 6 / AR12)

  • EventBus is a Swift actor. All mutations to the subscriber table happen actor-isolated, so no @unchecked Sendable or manual locking is needed.
  • Callbacks are @Sendable closures. They run on the @MainActor, so UI updates inside a callback are safe without an extra DispatchQueue.main.async.
  • EventListenerToken is a Sendable value type — safe to pass across task boundaries.
  • ConvertSwiftSDK itself is an all-let Sendable final class. Its let eventBus: EventBus is immutable after init, so passing the SDK across tasks is data-race safe.

Conclusion

The EventBus is the iOS SDK's internal pub/sub backbone, implemented as a Swift actor for race-free concurrency under Swift 6 strict concurrency. Developers interact with it through sdk.on(_:callback:) and sdk.off(_:).

Key takeaways:

  1. Why an internal event system is needed for communication between SDK modules.
  2. The EventBus actor as the iOS equivalent of the "office intercom" pattern.
  3. How components fire events (like .ready, .bucketing, .conversion).
  4. How your code can subscribe using sdk.on(_:callback:) and receive typed EventPayloadValue payloads.
  5. The SystemEvent enum is a frozen JS-parity contract — its 10 cases and raw values are immutable.
  6. Readiness is exposed via sdk.ready() (ConfigStore.waitForReady()) rather than a deferred-event replay.

Clone this wiki locally