Skip to content

Add EventTrackingBehavior for GDPR-compliant event control (v4.16.0)#479

Open
yusuftor wants to merge 10 commits into
developfrom
feature/event-tracking-behavior
Open

Add EventTrackingBehavior for GDPR-compliant event control (v4.16.0)#479
yusuftor wants to merge 10 commits into
developfrom
feature/event-tracking-behavior

Conversation

@yusuftor

@yusuftor yusuftor commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds new EventTrackingBehavior enum with .all / .superwallOnly / .none cases to replace the deprecated isExternalDataCollectionEnabled bool
  • The behavior is configurable at SDK init time via SuperwallOptions.eventTrackingBehavior and at runtime via Superwall.shared.eventTrackingBehavior (same pattern as logLevel)
  • Deprecates SuperwallOptions.isExternalDataCollectionEnabled with a backwards-compatible computed property: false.superwallOnly, true.all
  • .superwallOnly preserves existing behaviour (blocks user-initiated .track() calls, trigger fires, and user-attribute updates while letting internal SDK events through)
  • .none blocks all events — equivalent to never calling Superwall.configure from an event-tracking perspective, intended for GDPR consent flows

Changelog entry

Added EventTrackingBehavior enum and SuperwallOptions.eventTrackingBehavior property. Deprecated isExternalDataCollectionEnabled.

Test plan

  • 15 new unit tests in PlacementsQueueTests covering all three enum cases and deprecated API backwards-compatibility
  • All 15 new tests pass
  • Full test suite passes (exit code 0)
  • SwiftLint: 0 violations
  • Demo project builds and runs on iOS
  • Demo project builds and runs on Mac Catalyst
  • Demo project builds and runs on visionOS
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.

🤖 Generated with Claude Code

Greptile Summary

This PR introduces EventTrackingBehavior (.all / .superwallOnly / .none) as a replacement for the deprecated isExternalDataCollectionEnabled bool, with runtime control via Superwall.shared.eventTrackingBehavior and full backwards-compatibility for the old API.

  • PlacementsQueue is refactored to own a local trackingBehavior copy (removing the configManager reference), with setTrackingBehavior providing a clean actor-safe mutation point called by the Superwall setter.
  • .none transitions eagerly clear the event buffer via both setTrackingBehavior and the flushInternal guard, but the analogous buffer-drain for .superwallOnly is missing — events queued before a runtime switch from .all to .superwallOnly can still be flushed.
  • 15 unit tests cover per-case gating and deprecated-API mapping; no test exercises the .all.superwallOnly runtime-switch buffer scenario.

Confidence Score: 4/5

Mostly safe to merge; one runtime behavior gap in PlacementsQueue should be addressed before shipping.

When a caller switches from .all to .superwallOnly at runtime, events already buffered in PlacementsQueue.elements (user-initiated tracks, trigger fires, user attribute updates) are transmitted on the next flush despite the new behavior explicitly blocking them. The .none case received explicit buffer-clearing treatment; the .superwallOnly case did not. Everything else — the new enum, the deprecated-API shim, the actor refactor, and the unit tests — looks correct.

Sources/SuperwallKit/Storage/PlacementsQueue.swift — specifically the setTrackingBehavior and flushInternal pair.

Important Files Changed

Filename Overview
Sources/SuperwallKit/Storage/PlacementsQueue.swift Core event-queue actor; configManager dependency removed in favour of local state. Buffer is cleared on .none transition but not on .superwallOnly, leaving a window where blocked event types can still be flushed.
Sources/SuperwallKit/Config/Options/EventTrackingBehavior.swift New public @objc enum with three cases (all/superwallOnly/none); conformances (CustomStringConvertible, Encodable, Sendable) are correct and the raw-Int values satisfy ObjC bridging requirements.
Sources/SuperwallKit/Config/Options/SuperwallOptions.swift Adds eventTrackingBehavior property and deprecates isExternalDataCollectionEnabled with a backwards-compatible computed property; the setter correctly preserves .none when false is written, and encoding is updated to emit the string description.
Sources/SuperwallKit/Superwall.swift New runtime eventTrackingBehavior property mirrors the logLevel pattern; dispatches setTrackingBehavior asynchronously to the actor queue and fires a configAttributes event, both of which are safe given the actor's serial execution model.
Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift 15 new unit tests covering all three behaviors and deprecated-API backwards compat; notably missing a test for the .all → .superwallOnly buffer-draining scenario.
Tests/SuperwallKitTests/Network/NetworkMock.swift Adds sentEvents array and override for sendEvents to support new queue tests; straightforward and correct.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant App
    participant Superwall
    participant Options as SuperwallOptions
    participant Queue as PlacementsQueue
    participant Network

    App->>Superwall: "eventTrackingBehavior = .none"
    Superwall->>Options: set .none (sync)
    Superwall->>Queue: Task setTrackingBehavior(.none)
    Queue->>Queue: elements.removeAll()
    Note over Queue: buffer cleared, future enqueues rejected

    App->>Superwall: "eventTrackingBehavior = .superwallOnly"
    Superwall->>Options: set .superwallOnly (sync)
    Superwall->>Queue: Task setTrackingBehavior(.superwallOnly)
    Note over Queue: trackingBehavior updated, elements NOT cleared
    Queue->>Queue: timer fires, flushInternal
    Note over Queue: flushInternal only guards .none
    Queue->>Network: sends buffered TriggerFire and UserAttributes events
Loading
%%{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 Options as SuperwallOptions
    participant Queue as PlacementsQueue
    participant Network

    App->>Superwall: "eventTrackingBehavior = .none"
    Superwall->>Options: set .none (sync)
    Superwall->>Queue: Task setTrackingBehavior(.none)
    Queue->>Queue: elements.removeAll()
    Note over Queue: buffer cleared, future enqueues rejected

    App->>Superwall: "eventTrackingBehavior = .superwallOnly"
    Superwall->>Options: set .superwallOnly (sync)
    Superwall->>Queue: Task setTrackingBehavior(.superwallOnly)
    Note over Queue: trackingBehavior updated, elements NOT cleared
    Queue->>Queue: timer fires, flushInternal
    Note over Queue: flushInternal only guards .none
    Queue->>Network: sends buffered TriggerFire and UserAttributes events
Loading

Comments Outside Diff (2)

  1. Sources/SuperwallKit/Storage/PlacementsQueue.swift, line 101-119 (link)

    P1 Already-queued events bypass the .none gate

    When eventTrackingBehavior is set to .none at runtime (e.g., after the user declines a GDPR consent prompt), the trackingAllowed gate prevents new events from entering elements, but events already buffered in elements are still sent on the next timer tick or willResignActive flush. For the stated GDPR consent-flow use case, this means up to maxEventCount (50) previously-buffered events can be transmitted after the user has opted out. flushInternal should skip or discard the buffer when the current behavior is .none.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/Storage/PlacementsQueue.swift
    Line: 101-119
    
    Comment:
    **Already-queued events bypass the `.none` gate**
    
    When `eventTrackingBehavior` is set to `.none` at runtime (e.g., after the user declines a GDPR consent prompt), the `trackingAllowed` gate prevents *new* events from entering `elements`, but events already buffered in `elements` are still sent on the next timer tick or `willResignActive` flush. For the stated GDPR consent-flow use case, this means up to `maxEventCount` (50) previously-buffered events can be transmitted after the user has opted out. `flushInternal` should skip or discard the buffer when the current behavior is `.none`.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. Sources/SuperwallKit/Storage/PlacementsQueue.swift, line 101-119 (link)

    P1 Buffered events flushed after .none is set

    flushInternal drains elements without checking the current eventTrackingBehavior. When a user declines consent and the behavior is switched to .none at runtime, any events already queued in elements (up to maxEventCount = 50) are sent on the very next timer tick or willResignActive notification — exactly the outcome the GDPR opt-out feature is meant to prevent. Adding a guard at the top of flushInternal (or discarding elements on the .none path) would close this gap.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/Storage/PlacementsQueue.swift
    Line: 101-119
    
    Comment:
    **Buffered events flushed after `.none` is set**
    
    `flushInternal` drains `elements` without checking the current `eventTrackingBehavior`. When a user declines consent and the behavior is switched to `.none` at runtime, any events already queued in `elements` (up to `maxEventCount` = 50) are sent on the very next timer tick or `willResignActive` notification — exactly the outcome the GDPR opt-out feature is meant to prevent. Adding a guard at the top of `flushInternal` (or discarding `elements` on the `.none` path) would close this gap.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
Sources/SuperwallKit/Storage/PlacementsQueue.swift:86-91
**Buffered events leak through when switching to `.superwallOnly`**

`setTrackingBehavior` clears `elements` only for `.none`, but not for `.superwallOnly`. Any `UserInitiatedPlacement.Track`, `TriggerFire`, or `UserAttributes` events already sitting in the buffer when the caller switches from `.all` to `.superwallOnly` will still be transmitted on the very next timer tick or `willResignActive` flush, because `flushInternal`'s only early-exit guard is `trackingBehavior == .none`. Since `elements` stores `JSON` with no type information, the buffer can't be selectively filtered post-hoc — the pragmatic fix is the same one used for `.none`: discard the entire buffer on the transition. This sacrifices at most ~50 queued internal events (which will naturally re-appear), but prevents previously-collected user-initiated events from escaping a runtime opt-out.

Reviews (3): Last reviewed commit: "Update stale QueueSetup comment to refle..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 EventTrackingBehavior enum@objc(SWKEventTrackingBehavior), Int-backed .all/.superwallOnly/.none, conforming to CustomStringConvertible, Encodable, Sendable.
  • SuperwallOptions.eventTrackingBehavior — new init-time option (default .all); isExternalDataCollectionEnabled becomes a deprecated computed shim (true↔.all, false→.superwallOnly, getter == .all).
  • Superwall.shared.eventTrackingBehavior — runtime setter mirroring the existing logLevel/delegate pattern, firing a ConfigAttributes track on change.
  • PlacementsQueue gatingexternalDataCollectionAllowedtrackingAllowed, a switch over the enum; .superwallOnly blocks TriggerFire/UserAttributes/UserInitiatedPlacement.Track, .none blocks everything.
  • Encoding contract changeencode(to:) swaps the bool isExternalDataCollectionEnabled key for eventTrackingBehavior.description (string).
  • Version bump 4.15.4 → 4.16.0 across Constants.swift, podspec, and CHANGELOG.md; 15 new PlacementsQueueTests plus a NetworkMock.sentEvents capture.

⚠️ 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:232makeQueue mutates the Superwall.shared singleton's eventTrackingBehavior and never restores it. @Suite(.serialized) orders within the suite, but the last-set value leaks to any other suite touching Superwall.shared.options. Consider resetting to .all in a deinit/teardown.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Comment thread Sources/SuperwallKit/Storage/PlacementsQueue.swift
yusuftor and others added 3 commits June 22, 2026 17:06
… 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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 .none in deprecated setterisExternalDataCollectionEnabled's setter now only maps false → .superwallOnly when the current value isn't already .none, closing the silent-downgrade footgun; covered by the new deprecatedFalse_preservesNone test.
  • Inject configManager into the queue gatePlacementsQueue.trackingAllowed reads configManager.options.eventTrackingBehavior instead of the Superwall.shared global, restoring the DI contract.
  • Rewire tests to injected optionsmakeQueue and the deprecated/runtime tests now construct a local SuperwallOptions and pass it to both NetworkMock and ConfigManager, 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift Outdated
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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 flushInternal on .nonePlacementsQueue.flushInternal now discards the buffered elements and returns early when eventTrackingBehavior is .none, so events queued before a runtime opt-out are no longer transmitted on the next timer tick or willResignActive flush. This closes the GDPR opt-out gap raised in the prior reviews.
  • Cover the discard path — new none_discardsAlreadyBufferedEvents test enqueues under .all, switches the injected configManager.options to .none, flushes, and asserts nothing was sent. The shared SuperwallOptions reference makes the mutation reach flushInternal's read correctly.
  • Remove misnamed runtime testruntimeSetter_updatesOptions and its // MARK: - Runtime Superwall.shared property heading were deleted, resolving the prior inline thread; the test only re-asserted plain SuperwallOptions behavior 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.

Pullfrog  | View workflow run | Using 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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 setterSuperwall.shared.eventTrackingBehavior's setter now fires a Task { await placementsQueue.clearBuffer() } when set to .none, and the prior .none guard at the top of flushInternal was removed.
  • Add PlacementsQueue.clearBuffer() — a new actor method that empties elements, replacing the flush-time discard.
  • Retarget the testnone_discardsAlreadyBufferedEvents was renamed to clearBuffer_discardsAllBufferedEvents and now exercises clearBuffer() 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/Superwall.swift Outdated
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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 .none guardPlacementsQueue.flushInternal again early-returns and discards elements when eventTrackingBehavior is .none, making the discard atomic with the drain. The setter's eager clearBuffer() Task remains 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_flushAfterOptOutSendsNothing test enqueues under .all, switches the injected configManager.options to .none, calls flushInternal, and asserts nothing reaches NetworkMock.sentEvents — exercising the flush-time guard directly without depending on Task ordering. clearBuffer_discardsAllBufferedEvents is retained for the isolated clearBuffer() 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.

Pullfrog  | View workflow run | Using 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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 configManager fieldPlacementsQueue no longer stores configManager; the timer tick and willResignActive flush previously dereferenced it unowned from async paths after it could be deallocated. The queue now captures trackingBehavior and timerInterval synchronously in init while configManager is guaranteed alive.
  • Replace clearBuffer() with setTrackingBehavior(_:) — the new actor method updates the cached trackingBehavior and discards elements on .none; trackingAllowed/flushInternal read the cache instead of configManager.options, and the Superwall.shared setter calls it to keep the cache in sync at runtime.
  • Rework the opt-out testnone_flushAfterOptOutSendsNothing became none_setTrackingBehaviorDiscardsBufferAndBlocksFlush, driving setTrackingBehavior(.none)flushInternal and asserting nothing is sent; clearBuffer_discardsAllBufferedEvents was 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift Outdated
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>
Comment on lines +86 to +91
func setTrackingBehavior(_ behavior: EventTrackingBehavior) {
trackingBehavior = behavior
if behavior == .none {
elements.removeAll()
}
if placement is InternalSuperwallEvent.TriggerFire
|| placement is InternalSuperwallEvent.UserAttributes
|| placement is UserInitiatedPlacement.Track {
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Buffered events leak through when switching to .superwallOnly

setTrackingBehavior clears elements only for .none, but not for .superwallOnly. Any UserInitiatedPlacement.Track, TriggerFire, or UserAttributes events already sitting in the buffer when the caller switches from .all to .superwallOnly will still be transmitted on the very next timer tick or willResignActive flush, because flushInternal's only early-exit guard is trackingBehavior == .none. Since elements stores JSON with no type information, the buffer can't be selectively filtered post-hoc — the pragmatic fix is the same one used for .none: discard the entire buffer on the transition. This sacrifices at most ~50 queued internal events (which will naturally re-appear), but prevents previously-collected user-initiated events from escaping a runtime opt-out.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Storage/PlacementsQueue.swift
Line: 86-91

Comment:
**Buffered events leak through when switching to `.superwallOnly`**

`setTrackingBehavior` clears `elements` only for `.none`, but not for `.superwallOnly`. Any `UserInitiatedPlacement.Track`, `TriggerFire`, or `UserAttributes` events already sitting in the buffer when the caller switches from `.all` to `.superwallOnly` will still be transmitted on the very next timer tick or `willResignActive` flush, because `flushInternal`'s only early-exit guard is `trackingBehavior == .none`. Since `elements` stores `JSON` with no type information, the buffer can't be selectively filtered post-hoc — the pragmatic fix is the same one used for `.none`: discard the entire buffer on the transition. This sacrifices at most ~50 queued internal events (which will naturally re-appear), but prevents previously-collected user-initiated events from escaping a runtime opt-out.

How can I resolve this? If you propose a fix, please make it concise.

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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The .superwallOnly buffer-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 .superwallOnly tooPlacementsQueue.setTrackingBehavior now clears elements whenever the new behavior is anything other than .all (behavior != .all), so user-initiated events buffered under .all no longer leak through a runtime switch to .superwallOnly. Since elements is untyped JSON, discarding the whole buffer is the pragmatic choice over post-hoc filtering.
  • Cover the new pathsuperwallOnly_setTrackingBehaviorDiscardsBuffer enqueues two internal events under .all, switches to .superwallOnly, flushes, and asserts nothing reaches NetworkMock.sentEvents.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

func setTrackingBehavior(_ behavior: EventTrackingBehavior) {
trackingBehavior = behavior
if behavior != .all {
elements.removeAll()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant