Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/v4-reload-flags-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'posthog-ios': major
---

**Breaking:** `reloadFeatureFlags(_:)` now passes a `PostHogFeatureFlagsLoaded` to its callback, so you can tell whether the reload failed.

`errorsLoading` is `true` when the request failed or the SDK isn't set up, and `flags` and `variants` then hold the last known values. Update closures from `{ ... }` to `{ result in ... }` (or `{ _ in ... }`). In Objective-C, the block now takes a `PostHogFeatureFlagsLoaded *` argument.
7 changes: 7 additions & 0 deletions .changeset/v4-sendable-callbacks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'posthog-ios': major
---

**Breaking:** mark callbacks the SDK can call off the main thread `@Sendable` β€” `reloadFeatureFlags(_:)`, the `BeforeSendBlock` and `PostHogBeforeSendLogBlock` used by `setBeforeSend(_:)` on `PostHogConfig` and `PostHogLogsConfig`, `PostHogConfig.getAnonymousId`, `PostHogConfig.pushIdentityProvider` and its `completion`, and `sessionReplayConfig.captureLogsConfig.logSanitizer` β€” and make `PostHogFeatureFlagsLoaded` `Sendable`. This fixes a crash in Swift 6 apps when a `reloadFeatureFlags(_:)` callback is written in main-actor code.

To update main-actor state from `reloadFeatureFlags(_:)` or `pushIdentityProvider`, hop with `Task { @MainActor in ... }`. The other callbacks return a value immediately, so they must not read main-actor state. Declare closures you store in your own variables as `@Sendable` to avoid new Swift 5 warnings. Use the `BeforeSendBlock` and `PostHogBeforeSendLogBlock` typealiases for any `setBeforeSend(_:)` arrays you build or pass around, including your own helpers: in Swift 5 mode, converting an array between the old function type and these types compiles but crashes at runtime, in either direction.
9 changes: 9 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ let package = Package(
.process("Resources"),
]
),
// Compiles in Swift 6 language mode to check the public API the way a Swift 6 app sees it.
.testTarget(
name: "PostHogSwift6Tests",
dependencies: ["PostHog"],
path: "PostHogSwift6Tests",
swiftSettings: [
.swiftLanguageMode(.v6),
]
),
],
swiftLanguageModes: [.v5]
)
4 changes: 2 additions & 2 deletions PostHog/Logs/PostHogLogsConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import Foundation
///
/// - Parameter record: The log record about to be queued.
/// - Returns: The record to queue, or `nil` to drop it.
public typealias PostHogBeforeSendLogBlock = (PostHogLogRecord) -> PostHogLogRecord?
public typealias PostHogBeforeSendLogBlock = @Sendable (PostHogLogRecord) -> PostHogLogRecord?

/// Configuration for the PostHog logs subsystem. Mutate fields on `config.logs`
/// before calling `PostHogSDK.setup(_:)`.
Expand Down Expand Up @@ -105,7 +105,7 @@ public typealias PostHogBeforeSendLogBlock = (PostHogLogRecord) -> PostHogLogRec
@available(swift, obsoleted: 1.0, message: "Use setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) instead")
@objc public func setBeforeSend(_ blocks: [BoxedBeforeSendLogBlock]) {
setBeforeSend(blocks.map { box in
{ record in box.invokeSafely(with: record) }
{ @Sendable record in box.invokeSafely(with: record) }
})
}

Expand Down
15 changes: 11 additions & 4 deletions PostHog/Models/PostHogFeatureFlagsLoaded.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import Foundation
/// subscription.unsubscribe()
/// ```
@objc(PostHogFeatureFlagsLoaded)
public final class PostHogFeatureFlagsLoaded: NSObject {
public final class PostHogFeatureFlagsLoaded: NSObject, @unchecked Sendable {
// @unchecked: every property is a `let`, and `variants` only holds Swift `Bool` and `String` values (see init).

/// The keys of the enabled feature flags.
@objc public let flags: [String]

Expand All @@ -38,11 +40,16 @@ public final class PostHogFeatureFlagsLoaded: NSObject {
@objc public let errorsLoading: Bool

init(featureFlags: [String: Any], errorsLoading: Bool) {
let enabled = featureFlags.filter { _, value in
// Store the bridged values, not the originals: a bootstrap `NSMutableString` would stay mutable.
var enabled: [String: Any] = [:]
for (key, value) in featureFlags {
if let bool = value as? Bool {
return bool
if bool {
enabled[key] = bool
}
} else if let string = value as? String {
enabled[key] = string
}
return value is String
}
flags = Array(enabled.keys)
variants = enabled
Expand Down
11 changes: 6 additions & 5 deletions PostHog/PostHogConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import Foundation
///
/// - Parameter event: The event about to be queued.
/// - Returns: The event to queue, or `nil` to drop it.
public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent?
public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent?

/// Runtime configuration for a `PostHogSDK` instance.
///
Expand Down Expand Up @@ -205,7 +205,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent?
/// here stalls the SDK's push retry/offline-resume flow (though not your app's main thread).
///
/// Default: `nil` (requests carry no identity token).
@objc public var pushIdentityProvider: ((_ distinctId: String, _ appId: String, _ completion: @escaping (String?) -> Void) -> Void)?
@objc public var pushIdentityProvider: (@Sendable (_ distinctId: String, _ appId: String, _ completion: @escaping @Sendable (String?) -> Void) -> Void)?

#if os(iOS) || targetEnvironment(macCatalyst)
/// Enables UIKit element interaction autocapture on iOS and Mac Catalyst.
Expand Down Expand Up @@ -282,11 +282,12 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent?
/// Hook used to customize newly generated anonymous IDs.
///
/// The SDK passes its generated UUID v7 and stores the UUID returned by this closure.
/// Existing stored anonymous IDs are not regenerated.
/// Existing stored anonymous IDs are not regenerated. The closure can be called on any thread,
/// so it must not read main-actor state.
///
/// - Parameter uuid: The SDK-generated anonymous UUID.
/// - Returns: The UUID to persist as the anonymous ID.
@objc public var getAnonymousId: ((UUID) -> UUID) = { uuid in uuid }
@objc public var getAnonymousId: (@Sendable (UUID) -> UUID) = { uuid in uuid }

/// Pre-seeded identity and feature-flag state applied during setup, before any
/// network request completes.
Expand Down Expand Up @@ -604,7 +605,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent?
@available(swift, obsoleted: 1.0, message: "Use setBeforeSend(_ blocks: BeforeSendBlock...) instead")
@objc public func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) {
setBeforeSend(blocks.map { box in
{ event in box.invokeSafely(with: event) }
{ @Sendable event in box.invokeSafely(with: event) }
})
}

Expand Down
37 changes: 27 additions & 10 deletions PostHog/PostHogRemoteConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ class PostHogRemoteConfig {
}

func reloadFeatureFlags(
callback: (([String: Any]?) -> Void)? = nil
callback: ((FeatureFlagsLoadResult) -> Void)? = nil
) {
reloadFeatureFlags(callback: callback, surveyCompletion: nil)
}
Expand All @@ -290,7 +290,7 @@ class PostHogRemoteConfig {
}

private func reloadFeatureFlags(
callback: (([String: Any]?) -> Void)?,
callback: ((FeatureFlagsLoadResult) -> Void)?,
surveyCompletion: (([String: Any]?) -> Void)?
) {
guard canReloadFlagsForTesting else {
Expand All @@ -300,7 +300,7 @@ class PostHogRemoteConfig {

guard let storageManager = config.storageManager else {
hedgeLog("No PostHogStorageManager found in config, skipping loading feature flags")
callback?(nil)
callback?(failedLoadResult())
surveyCompletion?(nil)
return
}
Expand Down Expand Up @@ -422,7 +422,7 @@ class PostHogRemoteConfig {
anonymousId: String?,
deviceId: String? = nil,
groups: [String: String],
callback: (([String: Any]?) -> Void)? = nil,
callback: ((FeatureFlagsLoadResult) -> Void)? = nil,
surveyCompletion: (([String: Any]?) -> Void)? = nil,
coalesceWithCurrentRequest: Bool = false
) {
Expand Down Expand Up @@ -492,15 +492,16 @@ class PostHogRemoteConfig {
self.processErrorTrackingConfig(nil)

self.notifyFeatureFlagsAndRelease(cachedFeatureFlags)
callback?(cachedFeatureFlags)
callback?(FeatureFlagsLoadResult(featureFlags: cachedFeatureFlags, lastKnownFeatureFlags: cachedFeatureFlags))
return
}

// Safely handle optional data
guard var data = data else {
hedgeLog("Error: Flags response data is nil")
let result = self.failedLoadResult()
self.notifyFeatureFlagsAndRelease(nil)
callback?(nil)
callback?(result)
return
}

Expand All @@ -512,8 +513,9 @@ class PostHogRemoteConfig {
let featureFlagPayloads = data["featureFlagPayloads"] as? [String: Any]
else {
hedgeLog("Error: Flags response missing correct featureFlags format")
let result = self.failedLoadResult()
self.notifyFeatureFlagsAndRelease(nil)
callback?(nil)
callback?(result)
return
}

Expand Down Expand Up @@ -576,7 +578,7 @@ class PostHogRemoteConfig {
}

self.notifyFeatureFlagsAndRelease(loadedFeatureFlags)
callback?(loadedFeatureFlags)
callback?(FeatureFlagsLoadResult(featureFlags: loadedFeatureFlags, lastKnownFeatureFlags: loadedFeatureFlags ?? [:]))
}
}
}
Expand Down Expand Up @@ -794,6 +796,12 @@ class PostHogRemoteConfig {
}
}

/// Snapshots the cached flags for a failed load. Taken before listeners and callbacks run.
private func failedLoadResult() -> FeatureFlagsLoadResult {
let cached = featureFlagsLock.withLock { getCachedFeatureFlags() ?? [:] }
return FeatureFlagsLoadResult(featureFlags: nil, lastKnownFeatureFlags: cached)
}

private func notifyFeatureFlagsAndRelease(_ featureFlags: [String: Any]?) {
let (pending, surveyWaiters): (PendingFeatureFlagsRequest?, [([String: Any]?) -> Void]) = loadingFeatureFlagsLock.withLock {
self.loadingFeatureFlags = false
Expand All @@ -812,7 +820,7 @@ class PostHogRemoteConfig {
anonymousId: pending.anonymousId,
deviceId: pending.deviceId,
groups: pending.groups,
callback: { flags in pending.callbacks.forEach { $0(flags) } }
callback: { result in pending.callbacks.forEach { $0(result) } }
)
} else {
for waiter in surveyWaiters {
Expand Down Expand Up @@ -1324,7 +1332,16 @@ private struct PendingFeatureFlagsRequest {
let anonymousId: String?
let deviceId: String?
let groups: [String: String]
let callbacks: [([String: Any]?) -> Void]
let callbacks: [(FeatureFlagsLoadResult) -> Void]
}

/// The outcome of one feature flags load, captured once when it completes so every coalesced
/// callback sees the same values even if the cache changes while they run (for example a `reset()`).
struct FeatureFlagsLoadResult {
/// The loaded flags, or `nil` if the load failed.
let featureFlags: [String: Any]?
/// The loaded flags, or the cached flags when the load failed.
let lastKnownFeatureFlags: [String: Any]
}

#if TESTING
Expand Down
41 changes: 28 additions & 13 deletions PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2098,7 +2098,7 @@ let maxRetryDelay = 30.0
///
/// ```swift
/// PostHogSDK.shared.setPersonPropertiesForFlags(["plan": "premium"], reloadFeatureFlags: false)
/// PostHogSDK.shared.reloadFeatureFlags {
/// PostHogSDK.shared.reloadFeatureFlags { _ in
/// let flagValue = PostHogSDK.shared.isFeatureEnabled("new_feature")
/// }
/// ```
Expand All @@ -2107,9 +2107,10 @@ let maxRetryDelay = 30.0
/// leaves your app in control of when flags load.
///
/// - Note: `reset()` clears person properties set here, so they must be set again afterwards.
/// - Note: `reloadFeatureFlags(_:)` reports that the reload finished, not that it succeeded. If the
/// request fails, or the project is over its feature flag quota, the handler still runs and the
/// flags you read are the previously cached ones.
/// - Note: The `reloadFeatureFlags(_:)` handler runs whether or not the reload succeeded. If the
/// request fails, `errorsLoading` is `true` and the flags you read are the previously cached ones.
/// If the project is over its feature flag quota, `errorsLoading` is `false` but the flags are
/// still the previously cached ones.
///
/// - Parameters:
/// - properties: Dictionary of person properties to include in flag evaluation
Expand Down Expand Up @@ -2303,29 +2304,43 @@ let maxRetryDelay = 30.0

/// Reloads feature flags for the current user and group context.
@objc public func reloadFeatureFlags() {
reloadFeatureFlags {
reloadFeatureFlags { _ in
// No use case
}
}

/// Reloads feature flags and invokes a callback when finished.
/// Reloads feature flags and invokes a callback with the result when finished.
///
/// - Parameter callback: Invoked when the reload finishes, or immediately if the reload
/// is skipped (SDK disabled/opted-out, or no remote config available).
/// ```swift
/// PostHogSDK.shared.reloadFeatureFlags { result in
/// if result.errorsLoading {
/// // The reload failed. result.variants holds the last known flags.
/// }
/// }
/// ```
///
/// - Parameter callback: Invoked when the reload finishes, possibly on a background thread.
/// If the request fails, ``PostHogFeatureFlagsLoaded/errorsLoading`` is `true` and the flags are
/// the last known ones. If the reload is skipped because the SDK isn't set up, it's invoked
/// right away with no flags and `errorsLoading` set to `true`. To update UI or other main-actor
/// state, hop to the main actor, for example with `Task { @MainActor in ... }`.
@objc(reloadFeatureFlagsWithCallback:)
public func reloadFeatureFlags(_ callback: @escaping () -> Void) {
public func reloadFeatureFlags(_ callback: @escaping @Sendable (PostHogFeatureFlagsLoaded) -> Void) {
if !isEnabled() {
callback()
callback(PostHogFeatureFlagsLoaded(featureFlags: [:], errorsLoading: true))
return
}

guard let remoteConfig else {
callback()
callback(PostHogFeatureFlagsLoaded(featureFlags: [:], errorsLoading: true))
return
}

remoteConfig.reloadFeatureFlags { _ in
callback()
remoteConfig.reloadFeatureFlags { result in
callback(PostHogFeatureFlagsLoaded(
featureFlags: result.lastKnownFeatureFlags,
errorsLoading: result.featureFlags == nil
))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,8 @@ final class PostHogPushSubscriptionHandler {
return completion(cached)
}

var completed = false
// Guarded by stateLock.
nonisolated(unsafe) var completed = false
// A provider that never calls its completion would hold isSending for the whole process and
// wedge every later send. Bound the wait: if the mint doesn't land in time, fall back to a
// token-less send. A late real completion doesn't deliver (via `completed`) but still
Expand Down
6 changes: 4 additions & 2 deletions PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
/// - Detect log level (best effort)
/// - Process OSLog messages to remove metadata
///
/// The block runs on a background thread, so it must not read main-actor state.
///
/// - Parameter output: The raw console output to process.
/// - Returns: A `PostHogConsoleLogEntry` to capture, or `nil` to skip the log output.
@objc public var logSanitizer: ((String) -> PostHogConsoleLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer
@objc public var logSanitizer: (@Sendable (String) -> PostHogConsoleLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer

/// The minimum log level to capture in session replay.
/// Only log messages with this level or higher will be captured.
Expand All @@ -38,7 +40,7 @@
@objc public var minLogLevel: PostHogConsoleLogLevel = .error

/// Default implementation for processing console output.
static func defaultLogSanitizer(_ message: String) -> PostHogConsoleLogEntry? {
@Sendable static func defaultLogSanitizer(_ message: String) -> PostHogConsoleLogEntry? {
let message = String(message)
// Determine console log level
let level: PostHogConsoleLogLevel = {
Expand Down
4 changes: 3 additions & 1 deletion PostHog/Surveys/PostHogSurveyIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@
) {
getCachedOrReload(
getCached: remoteConfig.getFeatureFlags,
reload: { remoteConfig.reloadFeatureFlags(callback: $0) },
reload: { callback in
remoteConfig.reloadFeatureFlags(callback: callback.map { callback in { callback($0.featureFlags) } })
},
forceReload: forceReload,
callback: callback
)
Expand Down
6 changes: 4 additions & 2 deletions PostHog/Utils/BeforeSendChain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@

import Foundation

/// Composable `(T) -> T?` pipeline shared by the events and logs configs.
/// Composable `@Sendable (T) -> T?` pipeline shared by the events and logs configs.
/// Blocks run in registration order; the first `nil` drops the value.
/// Reference type so concurrent `set` / `run` see a coherent block snapshot.
final class BeforeSendChain<T> {
typealias Block = (T) -> T?
// @Sendable to match the public block types: converting their arrays to a
// non-Sendable element type fails at runtime (`_arrayForceCast`).
typealias Block = @Sendable (T) -> T?

private let lock = NSLock()
private var block: Block = { $0 }
Expand Down
4 changes: 2 additions & 2 deletions PostHog/Utils/BoxedBeforeSend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ internal import PostHogObjCExceptionSupport

/// ObjC wrapper for the events `beforeSend` block. Use with
/// `PostHogConfig.setBeforeSend(_:)`.
@objc public final class BoxedBeforeSendBlock: NSObject {
@objc public final class BoxedBeforeSendBlock: NSObject, Sendable {
/// Wrapped event callback.
@objc public let block: BeforeSendBlock

Expand All @@ -35,7 +35,7 @@ internal import PostHogObjCExceptionSupport

/// ObjC wrapper for the logs `beforeSend` block. Use with
/// `PostHogLogsConfig.setBeforeSend(_:)`.
@objc public final class BoxedBeforeSendLogBlock: NSObject {
@objc public final class BoxedBeforeSendLogBlock: NSObject, Sendable {
/// Wrapped log callback.
@objc public let block: PostHogBeforeSendLogBlock

Expand Down
Loading
Loading