-
Notifications
You must be signed in to change notification settings - Fork 0
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!"?
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.
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:
-
Publishing (Firing Events): When something significant happens, a component calls
eventBus.fire(_:payload:)with aSystemEventcase. This is like making an announcement over the intercom. For example:-
ConfigStorefires.readywhen initialization is complete. -
ConfigStorefires.configUpdatedwhen new configuration data is fetched. -
ExperienceManagerfires.bucketingwhen a visitor is assigned to a variation. -
ConvertContextfires.conversionwhentrackConversionis called.
-
-
Subscribing (Listening with
on): External code (or other components) registers interest in specific events usingsdk.on(_:callback:). When that event fires, the listener's callback is executed as a@MainActortask.
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.
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 anEventListenerToken(an opaqueSendablewrapper around aUUID). Pass it tooff(_:)to cancel. - The callback receives an
EventPayloadValueenum — a typed payload carrying structured data for that specific event. - Each callback is dispatched as an independent
@MainActortask (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: TheEventBus.fire(_:payload:)method ispackage-scoped (notpublic). SDK consumers cannot spoof system events onto the bus — only in-package components can fire. The public API surface ison/offonly.
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)")
}
}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:
- Looks up all callbacks for the event.
- For each callback, spawns an independent
Task { @MainActor in callback(payload) }— fire-and-forget; callers never block on callback completion. - 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.
-
EventBusis a Swiftactor. All mutations to the subscriber table happen actor-isolated, so no@unchecked Sendableor manual locking is needed. - Callbacks are
@Sendableclosures. They run on the@MainActor, so UI updates inside a callback are safe without an extraDispatchQueue.main.async. -
EventListenerTokenis aSendablevalue type — safe to pass across task boundaries. -
ConvertSwiftSDKitself is an all-letSendable final class. Itslet eventBus: EventBusis immutable afterinit, so passing the SDK across tasks is data-race safe.
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:
- Why an internal event system is needed for communication between SDK modules.
- The
EventBusactor as the iOS equivalent of the "office intercom" pattern. - How components
fireevents (like.ready,.bucketing,.conversion). - How your code can subscribe using
sdk.on(_:callback:)and receive typedEventPayloadValuepayloads. - The
SystemEventenum is a frozen JS-parity contract — its 10 cases and raw values are immutable. - Readiness is exposed via
sdk.ready()(ConfigStore.waitForReady()) rather than a deferred-event replay.
Copyrights © 2026 All Rights Reserved by Convert Insights, Inc.
Getting Started
iOS SDK
- Quickstart
- Installation
- Initialization
- Configuration
- Return Types & Models
- Code Examples
- Offline Behavior
- Tracking Control
- App Privacy & Data Collection
- Objective-C Interop
Core Concepts
- Experiences & Variations
- Feature Flags
- Bucketing Algorithm
- Rule Evaluation
- Segments
- Data Management
- Event System
- API Communication
How-To Guides
- Running Experiences
- Running Features
- Tracking Conversions
- Visitor Context
- Persistent Storage
- Troubleshooting
Contributing