Tracking Control

Consent: static networkTracking, per-call enableTracking, and runtime setTrackingEnabled

The iOS SDK has three independent layers that control whether outbound tracking events are sent. Use them to honor consent without losing your experiment logic — bucketing, rule evaluation, sticky persistence, and goal dedup keep working regardless of the tracking state. Only the network side is silenced.

A fourth, separate suppression exists for QA — an active preview target silences a single context completely, state writes included. It is not a consent control and does not compose with the three layers below; see Preview mode silences a context entirely.

Layer 1 — Static init-time flag (ConvertConfiguration.networkTracking)

Set at construction time and immutable for the SDK's lifetime:

import ConvertSwiftSDK

let config = ConvertConfiguration(sdkKey: "YOUR_SDK_KEY", networkTracking: false)
let sdk = ConvertSwiftSDK(configuration: config)

When networkTracking is false, no events are ever enqueued for the lifetime of that ConvertSwiftSDK instance. This is the reliable kill-switch for all tracking — experiences, features, and conversions. The SDK still buckets visitors and returns decisions; only the send is suppressed.

This flag seeds the runtime toggle (Layer 3): await sdk.isTrackingEnabled() returns this value until setTrackingEnabled(_:) changes it.

Layer 2 — Per-call enableTracking

Suppress the exposure event for a single decision call:

// bucket without emitting the exposure event for this one call
let variation = await context.runExperience("homepage-redesign", enableTracking: false)
await context.runExperiences(enableTracking: false)

// the feature methods take the same flag
let feature = await context.runFeature("checkout-banner", enableTracking: false)
await context.runFeatures(enableTracking: false)

When enableTracking: false, bucketing, sticky persistence, audience rules, and the .bucketing event bus signal still fire — only the outbound exposure event to Convert is skipped.

All four decision entry points take the flag. On the feature methods it suppresses the outbound exposure only. The sticky decision is still written and the .bucketing signal still fires, because sticky bucketing is a correctness invariant rather than a tracking concern.

Where Layers 1 and 3 are applied differs between the two paths. Nothing different leaves the device; it matters only if you assert on queue contents in a test. runExperience / runExperiences fold all three layers together in ConvertContext before delegating, so a suppressed exposure never reaches the event sink. The feature methods fold in only the per-call flag and the context's preview state. With networkTracking: false — or the runtime toggle closed — and enableTracking left at its default, a feature's entry does reach the sink. The production EventQueue in ConvertSwiftSDKCore then drops it at its own trackingEnabled guard: one seam later, same outcome.

trackConversion has no per-call tracking flag (FR23). To suppress conversions selectively, use the static networkTracking: false or the runtime toggle.

Narrowing a feature call with experienceKeys

The feature methods take a second per-call parameter, which decides which experiences are allowed to carry the feature at all:

// only `pricing-test` may carry this feature; any other carrier is skipped outright
let feature = await context.runFeature("checkout-banner", experienceKeys: ["pricing-test"])
let features = await context.runFeatures(experienceKeys: ["pricing-test"])

experienceKeys is an allow-list, not an exclude-list — pass the keys you do want evaluated. It is a stronger suppression than enableTracking: false: an excluded carrier is skipped before any bucketing decision is taken for it, so it produces no exposure event, no sticky-decision write, and no .bucketing signal. enableTracking: false still buckets and still persists; experienceKeys exclusion does neither.

  • nil (the default) and [] both mean no filter — every carrier is eligible, exactly as before the parameter existed. An explicit experienceKeys: nil call and a call with no argument return identical results.
  • A key matching no experience in the config is inert. A list in which no key matches leaves every feature .disabled.
  • Narrowing never changes the shape of a runFeatures result. Every feature the config declares is still returned, in config order rather than the order you passed the keys in, and a feature whose only carrier you excluded comes back .disabled — never omitted.
  • A repeated key is de-duplicated: the carrier buckets, and enqueues, exactly once.

This parameter exists only on runFeature / runFeatures. The experience methods take enableTracking alone — you select an experience there by passing its key.

Layer 3 — Runtime toggle (setTrackingEnabled / isTrackingEnabled)

An actor-backed flag you can flip at any point after initialization, for mid-session consent withdrawal:

// suppress all subsequent events (e.g. on GDPR consent withdrawal)
await sdk.setTrackingEnabled(false)

// re-enable after consent re-grant
await sdk.setTrackingEnabled(true)

// read the current state
let isOn = await sdk.isTrackingEnabled()

Completion-handler overloads are available for UIKit / Objective-C callers:

sdk.setTrackingEnabled(false) {
    // called on MainActor once the actor flag is closed
}
sdk.isTrackingEnabled { isOn in print("tracking:", isOn) }

When false:

  • Every enqueue on the event sink is dropped — events are not buffered and do not replay on re-enable.
  • runExperience / runExperiences suppress the bucketing enqueue (decisioning is unaffected).
  • trackConversion suppresses both the conversion and transaction enqueues. The per-visitor dedup mark is still written before the gate, so a suppressed conversion is still counted as "triggered" — a re-enabled later call will not re-fire the conversion event.
  • The local SystemEvent.conversion bus signal still fires on first trigger regardless of the network gate (JS parity — only delivery to Convert is suppressed).

When re-enabled: the gate re-opens for new events only. Events generated while disabled were never buffered; they are not recovered. Previously persisted events (from before the disable) continue draining normally through the queue.

The ConvertSwiftSDK handle stays an all-let Sendable final class — the mutable bit lives inside the actor TrackingState held by let.

How the three layers combine

Layers 1 and 3 are an AND gate for delivery. An event is delivered only when networkTracking == true (Layer 1) and setTrackingEnabled is true (Layer 3) and the per-call enableTracking is true (Layer 2).

networkTracking (init)Runtime setTrackingEnabledPer-call enableTrackingExposure event shipped?
truetrue (default)true (default)Yes
truetruefalseNo
truefalseanyNo
falseanyanyNo

Preview mode silences a context entirely

Calling await context.setPreview(experienceId:variationId:) — the QA path behind a Convert preview link — puts that context into zero-trace mode until the target is cleared. This is deliberately stronger than the three consent layers, and it is scoped to one context: a sibling context created from the same SDK is unaffected.

While a preview target is set on a context:

CallWhat is suppressed
runExperience / runExperiencesThe outbound event, the sticky-decision write, and the .bucketing bus signal. The sticky read is unaffected, so decisions stay coherent.
runFeature / runFeaturesThe same three — outbound event, decision persistence, .bucketing bus signal.
trackConversionEverything: the per-visitor dedup mark is not written, neither enqueue happens, and the .conversion bus signal does not fire.
setDefaultSegments / setCustomSegmentsEverything: no persist, no .segments bus signal.

Three differences from Layers 1 and 3 are worth internalizing:

  • It covers every experience on the context, not just the forced one. Siblings still evaluate and render normally so the screen stays coherent — they simply record nothing.
  • It suppresses local bus signals and state writes too. Layers 1 and 3 keep firing .conversion locally and keep writing dedup marks and sticky decisions; preview does not. That is what makes a preview session leave no residue on the visitor.
  • It outranks Layer 2, so a caller cannot opt back in. The per-call enableTracking is combined with the context's preview state, never substituted for it: passing enableTracking: true to runFeature / runFeatures / runExperience on a previewing context does not re-open delivery, persistence, or the bus signal. The caller's value can only narrow what is recorded, never widen it.

The target is cleared automatically whenever a setPreview call cannot resolve its experience or variation, so a bad link returns the context to fully normal behavior rather than stranding it in preview. See QA & Preview.

Consent scenarios

ScenarioWhat to do
Consent unknown at launchConvertConfiguration(sdkKey:, networkTracking: false) — start silent; call await sdk.setTrackingEnabled(true) once consent resolves.
Consent withdrawn mid-sessionawait sdk.setTrackingEnabled(false). In-flight queue continues draining; new events stay silent.
Do not initialize at allIf the user has not consented, simply do not construct ConvertSwiftSDK. No identifier is generated, no bucketing runs, no event is sent.
Suppress a single feature callKeep the SDK-level toggles true and pass enableTracking: false on the specific runFeature call. The decision and its sticky write are unaffected.
Suppress a single experienceKeep the SDK-level toggles true and pass enableTracking: false on the specific runExperience call.
Keep a feature's other carriers out of it entirelyPass experienceKeys: on the runFeature / runFeatures call, listing only the experiences you want evaluated. An omitted carrier takes no decision, writes nothing, and sends nothing.

Relationship to the offline queue

Disabling tracking (via Layer 1 or 3) stops events from being enqueued. It does not flush or clear events already persisted to the on-disk queue from a previous session — those continue through the normal background delivery path. See Offline Behavior.

Related pages


Did this page help you?