Skip to content

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

Merged
yusuftor merged 12 commits into
developfrom
feature/event-tracking-behavior
Jun 23, 2026
Merged

Add EventTrackingBehavior for GDPR-compliant event control (v4.16.0)#479
yusuftor merged 12 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) to replace the deprecated isExternalDataCollectionEnabled bool, making event suppression more granular and GDPR-friendly. The runtime property Superwall.shared.eventTrackingBehavior follows the same pattern as logLevel.

  • PlacementsQueue drops the configManager dependency in favour of a captured trackingBehavior field plus a new setTrackingBehavior method; buffer clearing on behaviour transitions and the flushInternal guard address the previously-flagged data-leak scenarios.
  • SuperwallOptions migrates the CodingKey from the old Bool field to the new string-encoded enum value, which changes the on-wire serialisation format; the deprecated computed property preserves .none when the bool is written false.
  • Superwall.eventTrackingBehavior setter (new) dispatches setTrackingBehavior and track(configAttributes) as independent unstructured Tasks, creating a window where a config-attributes event may race through before the queue applies the .none gate — directly affecting the stated GDPR use case.

Confidence Score: 4/5

Safe to merge with the understanding that setting eventTrackingBehavior = .none may 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.eventTrackingBehavior setter fires track(configAttributes) via an unstructured Task that races against setTrackingBehavior(.none) on the PlacementsQueue actor. For users setting .none in 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 eventTrackingBehavior setter's unconditional track(configAttributes) Task needs a guard for newValue == .none.

Important Files Changed

Filename Overview
Sources/SuperwallKit/Storage/PlacementsQueue.swift Replaces injected configManager dependency with a captured trackingBehavior and a new setTrackingBehavior method; adds flushInternal guard and buffer-clearing on behavior changes. Previous thread issues (DI bypass, buffer leak on .superwallOnly, flush-time bypass for .none) are all addressed.
Sources/SuperwallKit/Superwall.swift Adds eventTrackingBehavior runtime property mirroring the logLevel pattern; setter has a Task-ordering race where track(configAttributes) may fire before setTrackingBehavior(.none) updates the queue, leaking one event on GDPR opt-out.
Sources/SuperwallKit/Config/Options/EventTrackingBehavior.swift New @objc-bridged enum with .all, .superwallOnly, .none cases; well-documented and Sendable/Encodable conformances look correct.
Sources/SuperwallKit/Config/Options/SuperwallOptions.swift Adds eventTrackingBehavior property and deprecates isExternalDataCollectionEnabled with backwards-compatible computed property; CodingKey migration from the old Bool key to the new string-encoded enum key changes the on-wire format.
Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift Adds passEventTrackingBehaviorToWebView to push the current behavior into the web paywall after load; called safely via @MainActor task from the setter and from the existing async didLoadWebView utility task.
Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift 15 new unit tests cover all three enum cases, runtime behavior-switching, and deprecated API backwards-compatibility; well-structured with a shared makeQueue helper.
Tests/SuperwallKitTests/Network/NetworkMock.swift Adds sentEvents accumulator and sendEvents override to make PlacementsQueue unit 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
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 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
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/Superwall.swift:91-95
**`track(configAttributes)` may bypass the `.none` opt-out due to a Task ordering race**

When `eventTrackingBehavior` is set to `.none`, `options.eventTrackingBehavior` is updated synchronously but `placementsQueue.setTrackingBehavior(.none)` (Task A) and `track(configAttributes)` (Task C) are both dispatched as independent unstructured Tasks. If Task C reaches `PlacementsQueue.enqueue` before Task A updates the actor's local `trackingBehavior`, the config-attributes event is sent — defeating the GDPR opt-out guarantee. Since `track(configAttributes)` traverses more async hops than `setTrackingBehavior`, the window is narrow but real. The simplest fix is to skip the `track(configAttributes)` call when `newValue == .none`.

Reviews (4): Last reviewed commit: "Send event_tracking_behavior to paywall ..." | 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 thread Sources/SuperwallKit/Storage/PlacementsQueue.swift
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.

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>

@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 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 @MainActor method on PaywallMessageHandler that base64-encodes {"event_name":"event_tracking_behavior","behavior":<description>} and pushes it via window.paywall.accept64.
  • Send behavior on paywall loaddidLoadWebView now passes the current behavior to the web view after track(webViewLoad) completes.
  • Send behavior on runtime changeSuperwall.shared.eventTrackingBehavior's setter dispatches a Task { @MainActor } to forward the new value to the currently-presented paywall's messageHandler.

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.

Pullfrog  | View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/Superwall.swift
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>
@yusuftor yusuftor merged commit d117369 into develop Jun 23, 2026
3 checks passed
@yusuftor yusuftor deleted the feature/event-tracking-behavior branch June 23, 2026 13:20

@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 3be0457, which closes the Task-ordering race on the runtime eventTrackingBehavior setter flagged in the prior review round.

  • Skip the config-attributes track on .noneSuperwall.shared.eventTrackingBehavior's setter now early-returns before dispatching track(configAttributes) when newValue == .none. The setTrackingBehavior(.none) and web-view-notify Tasks are dispatched above the return, so they still run; only the internal ConfigAttributes event that previously could race through ahead of the queue's .none gate is suppressed.
  • Cover the gating intentnone_blocksConfigAttributes asserts the queue-level gate blocks a ConfigAttributes event even if it reaches the queue (backing up the setter early-return), and superwallOnly_allowsConfigAttributes confirms 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.

Pullfrog  | View workflow run | Using Claude Opus𝕏

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