Add EventTrackingBehavior for GDPR-compliant event control (v4.16.0)#479
Conversation
Introduces `EventTrackingBehavior` (.all / .superwallOnly / .none) to replace the deprecated `isExternalDataCollectionEnabled` bool. The new enum is settable both at configure time via `SuperwallOptions.eventTrackingBehavior` and at runtime via `Superwall.shared.eventTrackingBehavior`, following the same pattern as `logLevel`. `.none` suppresses all events (GDPR consent flow use case); `.superwallOnly` mirrors the old `false` value by blocking only user-initiated events, trigger fires, and attribute updates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Important
The analytics wire format changes: SuperwallOptions.encode now drops the boolean isExternalDataCollectionEnabled key and emits a string eventTrackingBehavior instead. This reaches the backend via ConfigAttributes. Confirm the server accepts the new key/shape before merging.
Reviewed changes — replaces the deprecated boolean isExternalDataCollectionEnabled with a tri-state EventTrackingBehavior enum controlling which events reach the Superwall servers, configurable at init and at runtime.
- Add
EventTrackingBehaviorenum —@objc(SWKEventTrackingBehavior),Int-backed.all/.superwallOnly/.none, conforming toCustomStringConvertible,Encodable,Sendable. SuperwallOptions.eventTrackingBehavior— new init-time option (default.all);isExternalDataCollectionEnabledbecomes a deprecated computed shim (true↔.all,false→.superwallOnly, getter== .all).Superwall.shared.eventTrackingBehavior— runtime setter mirroring the existinglogLevel/delegatepattern, firing aConfigAttributestrack on change.PlacementsQueuegating —externalDataCollectionAllowed→trackingAllowed, aswitchover the enum;.superwallOnlyblocksTriggerFire/UserAttributes/UserInitiatedPlacement.Track,.noneblocks everything.- Encoding contract change —
encode(to:)swaps the boolisExternalDataCollectionEnabledkey foreventTrackingBehavior.description(string). - Version bump 4.15.4 → 4.16.0 across
Constants.swift, podspec, andCHANGELOG.md; 15 newPlacementsQueueTestsplus aNetworkMock.sentEventscapture.
⚠️ Analytics payload key for event-tracking changed shape
The ConfigAttributes event (sent on configure and on every runtime change to this setting) serializes SuperwallOptions, and that payload no longer contains isExternalDataCollectionEnabled. It now carries eventTrackingBehavior as a string. If the backend reads the old boolean key for dashboards, audience filters, or reporting, those will silently stop populating, and the new string key may be ignored until the server is updated.
Technical details
# Analytics payload key for event-tracking changed shape
## Affected sites
- `Sources/SuperwallKit/Config/Options/SuperwallOptions.swift:394-395` — `CodingKeys` renamed `isExternalDataCollectionEnabled` → `eventTrackingBehavior`.
- `Sources/SuperwallKit/Config/Options/SuperwallOptions.swift:430` — encodes `eventTrackingBehavior.description` (e.g. `"all"`) instead of the bool.
- Flows to backend via `InternalSuperwallEvent.ConfigAttributes.getSuperwallParameters()` → `options.toDictionary()` (`TrackableSuperwallEvent.swift:212-227`).
## Required outcome
- Confirm the Superwall backend ingests the new `eventTrackingBehavior` string key, or keep emitting the legacy boolean alongside it during a transition window so existing server-side consumers don't regress.
## Open questions for the human
- Does any server-side consumer (audience filters, dashboard, reporting) still key off `isExternalDataCollectionEnabled`? If so, should both keys be emitted for one release?ℹ️ Tri-state → boolean round-trip silently re-enables collection on .none
The deprecated shim getter returns false for both .superwallOnly and .none, but the setter maps false → .superwallOnly. Legacy code doing a read-modify-write — e.g. options.isExternalDataCollectionEnabled = options.isExternalDataCollectionEnabled — against a .none config silently promotes it to .superwallOnly, re-enabling internal SDK event collection. For a GDPR consent feature this is a sharp edge worth a doc note, even though it's inherent to collapsing three states onto a bool.
Technical details
# Tri-state → boolean round-trip silently re-enables collection on `.none`
## Affected sites
- `Sources/SuperwallKit/Config/Options/SuperwallOptions.swift:316-323` — getter (`== .all`) returns `false` for both `.superwallOnly` and `.none`; setter maps `false` → `.superwallOnly` (never `.none`).
## Required outcome
- Document on `isExternalDataCollectionEnabled` that the bool cannot represent `.none`, so round-tripping through it on a `.none` config upgrades to `.superwallOnly`. No code change required if the deprecation is short-lived.ℹ️ Nitpicks
Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift:232—makeQueuemutates theSuperwall.sharedsingleton'seventTrackingBehaviorand never restores it.@Suite(.serialized)orders within the suite, but the last-set value leaks to any other suite touchingSuperwall.shared.options. Consider resetting to.allin a deinit/teardown.
Claude Opus | 𝕏
… deprecated setter - trackingAllowed now reads configManager.options.eventTrackingBehavior instead of Superwall.shared to honour the DI contract (matches how networkEnvironment is read) - Deprecated isExternalDataCollectionEnabled setter now preserves .none when set to false, preventing a silent downgrade to .superwallOnly for stricter callers - Tests no longer mutate Superwall.shared global state; behavior is scoped to the injected SuperwallOptions instance - Add deprecatedFalse_preservesNone test to cover the new preservation behaviour Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Events enqueued while tracking was allowed could still be transmitted on the next timer tick or willResignActive flush after the user opts out via the GDPR consent flow. flushInternal now clears elements immediately and returns early when behavior is .none, preventing any pre-buffered events from being sent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues — one minor test-coverage suggestion inline.
Reviewed changes — this run covers commit dcfa0b0, which addresses the two correctness findings and the nitpick from the prior pullfrog review.
- Preserve
.nonein deprecated setter —isExternalDataCollectionEnabled's setter now only mapsfalse → .superwallOnlywhen the current value isn't already.none, closing the silent-downgrade footgun; covered by the newdeprecatedFalse_preservesNonetest. - Inject
configManagerinto the queue gate —PlacementsQueue.trackingAllowedreadsconfigManager.options.eventTrackingBehaviorinstead of theSuperwall.sharedglobal, restoring the DI contract. - Rewire tests to injected
options—makeQueueand the deprecated/runtime tests now construct a localSuperwallOptionsand pass it to bothNetworkMockandConfigManager, eliminating the prior global-state leak.
The wire-format concern (encode now emits the string eventTrackingBehavior key and drops the boolean) and the flushInternal buffered-event gap on .none from the prior reviews remain open but are untouched by this commit, so they're out of scope for this delta.
Claude Opus | 𝕏
The test was operating on a local SuperwallOptions instance rather than Superwall.shared, so it wasn't actually covering the runtime setter path it claimed to. The property round-trip it tested is already implicit in the deprecated-getter tests. Removed the test and its MARK section rather than keeping a misleading name. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — this run covers commits 29018b0 and 7b36651, which close the buffered-event flush gap and retire the redundant runtime-setter test from the prior pullfrog reviews.
- Guard
flushInternalon.none—PlacementsQueue.flushInternalnow discards the bufferedelementsand returns early wheneventTrackingBehavioris.none, so events queued before a runtime opt-out are no longer transmitted on the next timer tick orwillResignActiveflush. This closes the GDPR opt-out gap raised in the prior reviews. - Cover the discard path — new
none_discardsAlreadyBufferedEventstest enqueues under.all, switches the injectedconfigManager.optionsto.none, flushes, and asserts nothing was sent. The sharedSuperwallOptionsreference makes the mutation reachflushInternal's read correctly. - Remove misnamed runtime test —
runtimeSetter_updatesOptionsand its// MARK: - Runtime Superwall.shared propertyheading were deleted, resolving the prior inline thread; the test only re-asserted plainSuperwallOptionsbehavior already covered elsewhere.
The two remaining open items from earlier reviews — the analytics wire-format change (encode emits the string eventTrackingBehavior key and drops the boolean) and the optional doc note about the tri-state→bool round-trip — are untouched by these commits and remain out of scope for this delta.
Claude Opus | 𝕏
Stale events belong to the setter, not to flushInternal. When eventTrackingBehavior is set to .none at runtime, the setter now immediately calls placementsQueue.clearBuffer() so buffered events are discarded the moment the user opts out. flushInternal no longer needs to know about tracking behavior at all. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Important
Moving the .none enforcement out of flushInternal and into a fire-and-forget clearBuffer Task reopens the GDPR opt-out gap that commit 29018b0 closed. The buffer is now protected only if clearBuffer wins a race against any in-flight flush.
Reviewed changes — this run covers commits 29018b0, 7b36651, and d8e4497, which reworked where the .none opt-out empties the buffered-event queue.
- Move opt-out enforcement to the runtime setter —
Superwall.shared.eventTrackingBehavior's setter now fires aTask { await placementsQueue.clearBuffer() }when set to.none, and the prior.noneguard at the top offlushInternalwas removed. - Add
PlacementsQueue.clearBuffer()— a new actor method that emptieselements, replacing the flush-time discard. - Retarget the test —
none_discardsAlreadyBufferedEventswas renamed toclearBuffer_discardsAllBufferedEventsand now exercisesclearBuffer()in isolation rather than the runtime opt-out path.
⚠️ Test no longer covers the runtime opt-out path it's meant to protect
The renamed clearBuffer_discardsAllBufferedEvents test calls clearBuffer() directly and asserts the buffer empties — but that only proves the new method works in isolation. The behavior that actually matters for GDPR (setting .none at runtime empties the buffer before any flush can transmit) is now untested. The previous none_discardsAlreadyBufferedEvents test drove the scenario through the behavior switch + flush; that end-to-end coverage was lost in the rename.
Technical details
# Test no longer covers the runtime opt-out path
## Affected sites
- `Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift:173-184` — `clearBuffer_discardsAllBufferedEvents` calls `clearBuffer()` directly, bypassing the setter and any concurrent flush.
## Required outcome
- A test that drives the actual opt-out path: enqueue under `.all`, set `eventTrackingBehavior = .none` (or otherwise exercise the setter + flush ordering), and assert nothing reaches `NetworkMock.sentEvents` even when a flush runs.
## Open questions for the human
- If the flush-time guard is restored (see the inline comment on `Superwall.swift`), a unit test asserting `flushInternal` sends nothing under `.none` would cover the guarantee deterministically without depending on Task ordering.Claude Opus | 𝕏
clearBuffer() and flushInternal() are both dispatched as unordered Tasks on the actor, so flushInternal can drain and send buffered events before clearBuffer gets a turn. The guard makes the behavior check and the drain atomic within the same actor turn, eliminating the window. clearBuffer() at the setter stays as an eager cleanup but the guard is what provides the correctness guarantee. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — this run covers commit 4b645d9, which closes the .none flush race flagged in the prior pullfrog review.
- Restore the flush-time
.noneguard —PlacementsQueue.flushInternalagain early-returns and discardselementswheneventTrackingBehavioris.none, making the discard atomic with the drain. The setter's eagerclearBuffer()Taskremains as cleanup, but the guarantee no longer depends on it winning a race against an in-flight flush (timer tick /willResignActive). - Add deterministic opt-out coverage — new
none_flushAfterOptOutSendsNothingtest enqueues under.all, switches the injectedconfigManager.optionsto.none, callsflushInternal, and asserts nothing reachesNetworkMock.sentEvents— exercising the flush-time guard directly without depending onTaskordering.clearBuffer_discardsAllBufferedEventsis retained for the isolatedclearBuffer()path.
The two earlier open items — the analytics wire-format change (encode emits the string eventTrackingBehavior key and drops the boolean) and the optional doc note on the tri-state→bool round-trip — are untouched by this commit and remain out of scope for this delta.
Claude Opus | 𝕏
Both setupTimer and flushInternal accessed configManager (unowned) from Tasks that can outlive the test's QueueSetup, causing SIGABRT when the unowned reference became dangling. Fix: capture what's needed from configManager synchronously during init (timerInterval, trackingBehavior) and drop the stored reference from all async paths. trackingBehavior is now a local actor property updated via setTrackingBehavior(), which Superwall.shared calls on every eventTrackingBehavior change instead of the former clearBuffer(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues — the SIGABRT fix is sound, one stale-comment nit inline.
Reviewed changes — this run covers commit 8e8ae9b, which restructures PlacementsQueue's state ownership to fix a SIGABRT crash.
- Drop the
unowned configManagerfield —PlacementsQueueno longer storesconfigManager; the timer tick andwillResignActiveflush previously dereferenced itunownedfrom async paths after it could be deallocated. The queue now capturestrackingBehaviorandtimerIntervalsynchronously ininitwhileconfigManageris guaranteed alive. - Replace
clearBuffer()withsetTrackingBehavior(_:)— the new actor method updates the cachedtrackingBehaviorand discardselementson.none;trackingAllowed/flushInternalread the cache instead ofconfigManager.options, and theSuperwall.sharedsetter calls it to keep the cache in sync at runtime. - Rework the opt-out test —
none_flushAfterOptOutSendsNothingbecamenone_setTrackingBehaviorDiscardsBufferAndBlocksFlush, drivingsetTrackingBehavior(.none)→flushInternaland asserting nothing is sent;clearBuffer_discardsAllBufferedEventswas removed with the method.
The cached-behavior approach narrows runtime sync to the Superwall.shared.eventTrackingBehavior setter, but since options on Superwall is internal there's no other public runtime mutation path, so the guarantee holds. The two earlier open items — the analytics wire-format change (encode emits the string eventTrackingBehavior key and drops the boolean) and the optional tri-state→bool round-trip doc note — are untouched by this commit and remain out of scope for this delta.
Claude Opus | 𝕏
After the configManager refactor, the queue only holds an unowned reference to network. Update the comment accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Events already in the buffer when switching to .superwallOnly would still be transmitted on the next flush because the buffer stores raw JSON with no type information and can't be selectively filtered. Clearing on any non-.all transition prevents user-initiated events from escaping a runtime opt-out at the cost of at most maxEventCount internal events, which will naturally reappear. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ The
.superwallOnlybuffer-clear addresses the prior leak, but inherits the same runtime flush race that was fixed for.none.
Reviewed changes — this run covers commit 5e4abbf, which broadens the eager buffer discard so it fires on any transition away from .all, not just .none.
- Discard buffer on
.superwallOnlytoo —PlacementsQueue.setTrackingBehaviornow clearselementswhenever the new behavior is anything other than.all(behavior != .all), so user-initiated events buffered under.allno longer leak through a runtime switch to.superwallOnly. Sinceelementsis untypedJSON, discarding the whole buffer is the pragmatic choice over post-hoc filtering. - Cover the new path —
superwallOnly_setTrackingBehaviorDiscardsBufferenqueues two internal events under.all, switches to.superwallOnly, flushes, and asserts nothing reachesNetworkMock.sentEvents.
Claude Opus | 𝕏
| func setTrackingBehavior(_ behavior: EventTrackingBehavior) { | ||
| trackingBehavior = behavior | ||
| if behavior != .all { | ||
| elements.removeAll() |
There was a problem hiding this comment.
ℹ️ This eager clear closes the .all→.superwallOnly leak, but it inherits the runtime flush race that was fixed for .none in 4b645d9. The Superwall.shared.eventTrackingBehavior setter writes options synchronously and then dispatches setTrackingBehavior via an unordered Task; a flushInternal already scheduled on the actor (timer tick / willResignActive) reads the still-.all cached trackingBehavior, passes the != .none guard, and drains buffered user-initiated events before this clear runs. For .none, flushInternal's trackingBehavior == .none early-return catches that window; .superwallOnly has no analogous flush-time guard, so the eager clear alone can lose the race.
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.When the paywall web view loads, the current eventTrackingBehavior is sent via accept64 so the JS layer can disable its own collector calls. The same message is sent immediately on runtime behavior changes so any currently-open paywall stops tracking without requiring a reload. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — this run covers commit 04704cb, which notifies the paywall web view of the current eventTrackingBehavior so the rendered paywall can suppress its own client-side tracking under a GDPR opt-out.
- Add
passEventTrackingBehaviorToWebView(_:)— new@MainActormethod onPaywallMessageHandlerthat base64-encodes{"event_name":"event_tracking_behavior","behavior":<description>}and pushes it viawindow.paywall.accept64. - Send behavior on paywall load —
didLoadWebViewnow passes the current behavior to the web view aftertrack(webViewLoad)completes. - Send behavior on runtime change —
Superwall.shared.eventTrackingBehavior's setter dispatches aTask { @MainActor }to forward the new value to the currently-presented paywall'smessageHandler.
The two earlier open items — the analytics wire-format change (encode emits the string eventTrackingBehavior key and drops the boolean) and the optional tri-state→bool round-trip doc note — are untouched by this commit and remain out of scope for this delta.
ℹ️ Web-view passthrough has no test coverage and no guaranteed ordering vs. template injection
The new passthrough is a thin fire-and-forget shim whose consumer (the paywall web view's JS) lives outside this repo, so the lack of a unit test is acceptable — flagging only for awareness. One ordering nuance worth noting for the load path: in didLoadWebView the behavior message is sent from a Task(priority: .utility) that runs concurrently with the main scriptSrc template/htmlSubstitutions injection, so the two window.paywall.accept64 calls have no guaranteed arrival order. This matches the existing post-load message pattern, so it's only a concern if the web view requires the behavior to be known before templates render.
Claude Opus | 𝕏
When eventTrackingBehavior is set to .none, the config-attributes event and the placementsQueue opt-out were dispatched as independent unstructured Tasks. If the config-attributes track reached PlacementsQueue.enqueue before the queue flipped its local trackingBehavior, a flush could transmit it, defeating the opt-out. Skip the track call entirely for .none. Add PlacementsQueue tests confirming ConfigAttributes is blocked under .none and allowed under .superwallOnly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — this run covers commit 3be0457, which closes the Task-ordering race on the runtime eventTrackingBehavior setter flagged in the prior review round.
- Skip the config-attributes track on
.none—Superwall.shared.eventTrackingBehavior's setter now early-returns before dispatchingtrack(configAttributes)whennewValue == .none. ThesetTrackingBehavior(.none)and web-view-notify Tasks are dispatched above the return, so they still run; only the internalConfigAttributesevent that previously could race through ahead of the queue's.nonegate is suppressed. - Cover the gating intent —
none_blocksConfigAttributesasserts the queue-level gate blocks aConfigAttributesevent even if it reaches the queue (backing up the setter early-return), andsuperwallOnly_allowsConfigAttributesconfirms the same event is permitted under.superwallOnly, documenting why the skip is.none-only.
The early-return is a clean primary guard, complementary to the existing flushInternal .none guard and eager buffer discard that already cover any event that did slip into the queue. The earlier open items remain out of scope for this delta and untouched here: the analytics wire-format change (encode emits the string eventTrackingBehavior key and drops the boolean), the tri-state→bool round-trip doc note, and the .superwallOnly runtime flush race.
Claude Opus | 𝕏

Summary
EventTrackingBehaviorenum with.all/.superwallOnly/.nonecases to replace the deprecatedisExternalDataCollectionEnabledboolSuperwallOptions.eventTrackingBehaviorand at runtime viaSuperwall.shared.eventTrackingBehavior(same pattern aslogLevel)SuperwallOptions.isExternalDataCollectionEnabledwith a backwards-compatible computed property:false→.superwallOnly,true→.all.superwallOnlypreserves existing behaviour (blocks user-initiated.track()calls, trigger fires, and user-attribute updates while letting internal SDK events through).noneblocks all events — equivalent to never callingSuperwall.configurefrom an event-tracking perspective, intended for GDPR consent flowsChangelog entry
Added
EventTrackingBehaviorenum andSuperwallOptions.eventTrackingBehaviorproperty. DeprecatedisExternalDataCollectionEnabled.Test plan
PlacementsQueueTestscovering all three enum cases and deprecated API backwards-compatibilityCHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.🤖 Generated with Claude Code
Greptile Summary
This PR introduces
EventTrackingBehavior(.all/.superwallOnly/.none) to replace the deprecatedisExternalDataCollectionEnabledbool, making event suppression more granular and GDPR-friendly. The runtime propertySuperwall.shared.eventTrackingBehaviorfollows the same pattern aslogLevel.PlacementsQueuedrops theconfigManagerdependency in favour of a capturedtrackingBehaviorfield plus a newsetTrackingBehaviormethod; buffer clearing on behaviour transitions and theflushInternalguard address the previously-flagged data-leak scenarios.SuperwallOptionsmigrates theCodingKeyfrom the old Bool field to the new string-encoded enum value, which changes the on-wire serialisation format; the deprecated computed property preserves.nonewhen the bool is writtenfalse.Superwall.eventTrackingBehaviorsetter (new) dispatchessetTrackingBehaviorandtrack(configAttributes)as independent unstructured Tasks, creating a window where a config-attributes event may race through before the queue applies the.nonegate — directly affecting the stated GDPR use case.Confidence Score: 4/5
Safe to merge with the understanding that setting
eventTrackingBehavior = .nonemay still transmit one config-attributes event on the transition due to the async task ordering.The core logic is sound and the previously-flagged buffer-leak issues are well-addressed. The outstanding concern is that the
Superwall.eventTrackingBehaviorsetter firestrack(configAttributes)via an unstructured Task that races againstsetTrackingBehavior(.none)on the PlacementsQueue actor. For users setting.nonein a GDPR consent flow, this can cause one internal event to slip through on the exact transition. All other paths are correctly gated.Sources/SuperwallKit/Superwall.swift — the
eventTrackingBehaviorsetter's unconditionaltrack(configAttributes)Task needs a guard fornewValue == .none.Important Files Changed
trackingBehaviorand a newsetTrackingBehaviormethod; addsflushInternalguard and buffer-clearing on behavior changes. Previous thread issues (DI bypass, buffer leak on.superwallOnly, flush-time bypass for.none) are all addressed.eventTrackingBehaviorruntime property mirroring thelogLevelpattern; setter has a Task-ordering race wheretrack(configAttributes)may fire beforesetTrackingBehavior(.none)updates the queue, leaking one event on GDPR opt-out.@objc-bridged enum with.all,.superwallOnly,.nonecases; well-documented and Sendable/Encodable conformances look correct.eventTrackingBehaviorproperty and deprecatesisExternalDataCollectionEnabledwith backwards-compatible computed property; CodingKey migration from the old Bool key to the new string-encoded enum key changes the on-wire format.passEventTrackingBehaviorToWebViewto push the current behavior into the web paywall after load; called safely via@MainActortask from the setter and from the existing asyncdidLoadWebViewutility task.makeQueuehelper.sentEventsaccumulator andsendEventsoverride to makePlacementsQueueunit tests possible without network calls.Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant App participant Superwall participant PlacementsQueue participant Network App->>Superwall: "eventTrackingBehavior = .none" Superwall->>Superwall: "options.eventTrackingBehavior = .none (sync)" Superwall-->>PlacementsQueue: Task A: setTrackingBehavior(.none) Superwall-->>Superwall: Task C: track(configAttributes) Note over PlacementsQueue,Superwall: Race: Task C may reach enqueue before Task A updates trackingBehavior alt Task A wins (expected) PlacementsQueue->>PlacementsQueue: "trackingBehavior = .none, elements.removeAll()" Superwall->>PlacementsQueue: enqueue(configAttributes) — blocked ✓ else Task C wins (race) Superwall->>PlacementsQueue: enqueue(configAttributes) — allowed ✗ PlacementsQueue->>Network: sendEvents (leaks after opt-out) PlacementsQueue->>PlacementsQueue: "trackingBehavior = .none (too late)" end%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant App participant Superwall participant PlacementsQueue participant Network App->>Superwall: "eventTrackingBehavior = .none" Superwall->>Superwall: "options.eventTrackingBehavior = .none (sync)" Superwall-->>PlacementsQueue: Task A: setTrackingBehavior(.none) Superwall-->>Superwall: Task C: track(configAttributes) Note over PlacementsQueue,Superwall: Race: Task C may reach enqueue before Task A updates trackingBehavior alt Task A wins (expected) PlacementsQueue->>PlacementsQueue: "trackingBehavior = .none, elements.removeAll()" Superwall->>PlacementsQueue: enqueue(configAttributes) — blocked ✓ else Task C wins (race) Superwall->>PlacementsQueue: enqueue(configAttributes) — allowed ✗ PlacementsQueue->>Network: sendEvents (leaks after opt-out) PlacementsQueue->>PlacementsQueue: "trackingBehavior = .none (too late)" endComments Outside Diff (2)
Sources/SuperwallKit/Storage/PlacementsQueue.swift, line 101-119 (link).nonegateWhen
eventTrackingBehavioris set to.noneat runtime (e.g., after the user declines a GDPR consent prompt), thetrackingAllowedgate prevents new events from enteringelements, but events already buffered inelementsare still sent on the next timer tick orwillResignActiveflush. For the stated GDPR consent-flow use case, this means up tomaxEventCount(50) previously-buffered events can be transmitted after the user has opted out.flushInternalshould skip or discard the buffer when the current behavior is.none.Prompt To Fix With AI
Sources/SuperwallKit/Storage/PlacementsQueue.swift, line 101-119 (link).noneis setflushInternaldrainselementswithout checking the currenteventTrackingBehavior. When a user declines consent and the behavior is switched to.noneat runtime, any events already queued inelements(up tomaxEventCount= 50) are sent on the very next timer tick orwillResignActivenotification — exactly the outcome the GDPR opt-out feature is meant to prevent. Adding a guard at the top offlushInternal(or discardingelementson the.nonepath) would close this gap.Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (4): Last reviewed commit: "Send event_tracking_behavior to paywall ..." | Re-trigger Greptile