-
Notifications
You must be signed in to change notification settings - Fork 57
Add EventTrackingBehavior for GDPR-compliant event control (v4.16.0) #479
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c4c3d6b
6d59aa7
dcfa0b0
29018b0
7b36651
d8e4497
4b645d9
8e8ae9b
eb116c9
5e4abbf
04704cb
3be0457
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| // | ||
| // EventTrackingBehavior.swift | ||
| // | ||
| // | ||
| // Created by Yusuf Tör on 22/06/2026. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| /// Controls which events are sent to the Superwall servers. | ||
| /// | ||
| /// Use ``SuperwallOptions/eventTrackingBehavior`` or set ``Superwall/eventTrackingBehavior`` | ||
| /// at runtime to change event collection at any time. | ||
| /// | ||
| /// - `.all`: All events are tracked (default). | ||
| /// - `.superwallOnly`: Only internal Superwall events are tracked. User-initiated | ||
| /// ``Superwall/track(event:params:)`` calls, trigger fires, and user-attribute updates | ||
| /// are suppressed. Equivalent to the deprecated `isExternalDataCollectionEnabled = false`. | ||
| /// - `.none`: No events are sent to the Superwall servers. | ||
| @objc(SWKEventTrackingBehavior) | ||
| public enum EventTrackingBehavior: Int, CustomStringConvertible, Encodable, Sendable { | ||
| /// All events are tracked. This is the default. | ||
| case all = 0 | ||
|
|
||
| /// Only internal Superwall events are tracked. | ||
| /// | ||
| /// User-initiated tracking calls, trigger-fire events, and user-attribute | ||
| /// updates are suppressed. All other internal SDK events continue to be sent. | ||
| case superwallOnly = 1 | ||
|
|
||
| /// No events are sent to the Superwall servers. | ||
| case none = 2 | ||
|
|
||
| public var description: String { | ||
| switch self { | ||
| case .all: return "all" | ||
| case .superwallOnly: return "superwallOnly" | ||
| case .none: return "none" | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,5 +18,5 @@ let sdkVersion = """ | |
| */ | ||
|
|
||
| let sdkVersion = """ | ||
| 4.15.4 | ||
| 4.16.0 | ||
| """ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| // | ||
| // File.swift | ||
| // | ||
| // | ||
| // | ||
| // Created by brian on 8/16/21. | ||
| // | ||
|
|
@@ -16,7 +16,8 @@ actor PlacementsQueue { | |
| private var elements: [JSON] = [] | ||
| private var timer: Timer? | ||
| private unowned let network: Network | ||
| private unowned let configManager: ConfigManager | ||
| private var trackingBehavior: EventTrackingBehavior | ||
| private let timerInterval: Double | ||
|
|
||
| @MainActor | ||
| private var resignActiveObserver: AnyCancellable? | ||
|
|
@@ -31,23 +32,23 @@ actor PlacementsQueue { | |
| configManager: ConfigManager | ||
| ) { | ||
| self.network = network | ||
| self.configManager = configManager | ||
| // Capture synchronously while configManager is guaranteed alive. | ||
| self.trackingBehavior = configManager.options.eventTrackingBehavior | ||
| switch configManager.options.networkEnvironment { | ||
| case .release: | ||
| self.timerInterval = 20.0 | ||
| default: | ||
| self.timerInterval = 1.0 | ||
| } | ||
| Task { [weak self] in | ||
| await self?.setupTimer() | ||
| await self?.addObserver() | ||
| } | ||
| } | ||
|
|
||
| private func setupTimer() { | ||
| let timeInterval: Double | ||
| switch configManager.options.networkEnvironment { | ||
| case .release: | ||
| timeInterval = 20.0 | ||
| default: | ||
| timeInterval = 1.0 | ||
| } | ||
| let timer = Timer( | ||
| timeInterval: timeInterval, | ||
| timeInterval: timerInterval, | ||
| repeats: true | ||
| ) { [weak self] _ in | ||
| guard let self = self else { | ||
|
|
@@ -76,25 +77,41 @@ actor PlacementsQueue { | |
| data: JSON, | ||
| from placement: Trackable | ||
| ) { | ||
| guard externalDataCollectionAllowed(from: placement) else { | ||
| guard trackingAllowed(from: placement) else { | ||
| return | ||
| } | ||
| elements.append(data) | ||
| } | ||
|
|
||
| private func externalDataCollectionAllowed(from placement: Trackable) -> Bool { | ||
| if Superwall.shared.options.isExternalDataCollectionEnabled { | ||
| return true | ||
| func setTrackingBehavior(_ behavior: EventTrackingBehavior) { | ||
| trackingBehavior = behavior | ||
| if behavior != .all { | ||
| elements.removeAll() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ℹ️ This eager clear closes the Technical details# `.superwallOnly` runtime switch has the same flush race that was closed for `.none`
## Affected sites
- `Sources/SuperwallKit/Superwall.swift:78-83` — setter writes `options.eventTrackingBehavior` synchronously, then dispatches `Task { await placementsQueue.setTrackingBehavior(newValue) }` (unordered).
- `Sources/SuperwallKit/Storage/PlacementsQueue.swift:86-91` — `setTrackingBehavior` clears `elements` on `behavior != .all`, but only when the Task actually runs.
- `Sources/SuperwallKit/Storage/PlacementsQueue.swift:109-113` — `flushInternal` only early-returns for `.none`; under a stale `.all` cache it drains and transmits user-initiated events.
## Required outcome
- Under a runtime switch to `.superwallOnly`, user-initiated events buffered before the switch must not be transmitted, regardless of how the `setTrackingBehavior` Task and an in-flight `flushInternal` interleave on the actor.
## Suggested approach
- The new test exercises `setTrackingBehavior` → `flushInternal` in deterministic order, so it does not surface this race. The gap is narrow (requires a flush already scheduled in the window between the option write and the Task) and may be acceptable for the GDPR use case, where `.none` is the consent-decline path and `.superwallOnly` is typically set at init. If runtime `.superwallOnly` opt-out is a supported flow, consider whether `flushInternal` should re-evaluate per-event gating against the current `trackingBehavior` rather than draining whatever was buffered.
## Open questions for the human
- Is runtime switching to `.superwallOnly` (as opposed to init-time) a supported flow that needs the same atomicity guarantee as `.none`? If `.superwallOnly` is only ever set at configure time, this race cannot occur and no change is needed. |
||
| } | ||
| if placement is InternalSuperwallEvent.TriggerFire | ||
| || placement is InternalSuperwallEvent.UserAttributes | ||
| || placement is UserInitiatedPlacement.Track { | ||
| } | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| private func trackingAllowed(from placement: Trackable) -> Bool { | ||
| switch trackingBehavior { | ||
| case .all: | ||
| return true | ||
| case .superwallOnly: | ||
| if placement is InternalSuperwallEvent.TriggerFire | ||
| || placement is InternalSuperwallEvent.UserAttributes | ||
| || placement is UserInitiatedPlacement.Track { | ||
| return false | ||
| } | ||
| return true | ||
| case .none: | ||
| return false | ||
| } | ||
| return true | ||
| } | ||
|
yusuftor marked this conversation as resolved.
|
||
|
|
||
| func flushInternal(depth: Int = 10) { | ||
| if trackingBehavior == .none { | ||
| elements.removeAll() | ||
| return | ||
| } | ||
|
|
||
| var eventsToSend: [JSON] = [] | ||
|
|
||
| var i = 0 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.