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-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 {
Comment thread
turnipdabeets marked this conversation as resolved.
Resolved
// @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
5 changes: 3 additions & 2 deletions PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2322,9 +2322,10 @@ let maxRetryDelay = 30.0
/// - 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`.
/// 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 (PostHogFeatureFlagsLoaded) -> Void) {
public func reloadFeatureFlags(_ callback: @escaping @Sendable (PostHogFeatureFlagsLoaded) -> Void) {
if !isEnabled() {
callback(PostHogFeatureFlagsLoaded(featureFlags: [:], errorsLoading: true))
return
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
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
6 changes: 5 additions & 1 deletion PostHogExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class SignInViewModel: NSObject, ObservableObject, ASWebAuthenticationPresentati
}
}

@MainActor
class FeatureFlagsModel: ObservableObject {
@Published var boolValue: Bool?
@Published var stringValue: String?
Expand All @@ -56,7 +57,10 @@ class FeatureFlagsModel: ObservableObject {
isReloading = true

PostHogSDK.shared.reloadFeatureFlags { _ in
self.isReloading = false
// The callback can run on a background thread; @Published changes belong on main.
Task { @MainActor in
self.isReloading = false
}
}
}
}
Expand Down
103 changes: 103 additions & 0 deletions PostHogSwift6Tests/PostHogCallbackIsolationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//
// PostHogCallbackIsolationTests.swift
// PostHogSwift6Tests
//

import Foundation
import PostHog
import Testing

/// Calls the public callback APIs from main-actor code, as a Swift 6 app would.
///
/// In Swift 6 mode, a closure written in main-actor code and passed as a non-`Sendable`
/// parameter is main-actor isolated, and the runtime traps if the SDK calls it off the main thread.
@MainActor
@Suite(.timeLimit(.minutes(1)))
struct PostHogCallbackIsolationTests {
@MainActor final class AppState {
var reloadResult: PostHogFeatureFlagsLoaded?
var loaded: PostHogFeatureFlagsLoaded?
var deliveredOnMain = false
}

/// Nothing listens on this port, so requests fail fast without leaving the machine.
private func makeSDK(bootstrapFlags: [String: Any]? = nil) -> PostHogSDK {
let config = PostHogConfig(projectToken: "phc_swift6_\(UUID().uuidString)", host: "http://127.0.0.1:9")
config.captureApplicationLifecycleEvents = false
config.preloadFeatureFlags = false
if let bootstrapFlags {
let bootstrap = PostHogBootstrapConfig()
bootstrap.featureFlags = bootstrapFlags
config.bootstrap = bootstrap
}
return PostHogSDK.with(config)
}

@Test("reloadFeatureFlags callback written in main-actor code runs without an isolation trap")
func reloadCallbackFromMainActorCode() async {
let sdk = makeSDK()
defer { sdk.close() }
let state = AppState()

// The SDK invokes this from its network completion, off the main thread.
await withCheckedContinuation { continuation in
sdk.reloadFeatureFlags { result in
// Sending the result into the hop requires it to be Sendable.
Task { @MainActor in
state.reloadResult = result
continuation.resume()
}
}
}

#expect(state.reloadResult != nil)
}

@Test("onFeatureFlags callback written in main-actor code runs on main with a Sendable payload")
func onFeatureFlagsDeliversOnMainActor() async {
let sdk = makeSDK(bootstrapFlags: ["swift6-flag": true])
defer { sdk.close() }
let state = AppState()

var subscription: PostHogFeatureFlagsSubscription?
await withCheckedContinuation { continuation in
subscription = sdk.onFeatureFlags { loaded in
// Touches main-actor state directly, with no hop.
guard state.loaded == nil else { return }
state.loaded = loaded
state.deliveredOnMain = Thread.isMainThread
continuation.resume()
}
}
subscription?.unsubscribe()

#expect(state.deliveredOnMain)
// Sending the payload to another task requires it to be Sendable.
let loaded = state.loaded
let flags = await Task.detached { loaded?.flags ?? [] }.value
#expect(flags == ["swift6-flag"])
}

@Test("onFeatureFlags payload keeps its variant values when a mutable bootstrap value changes")
func onFeatureFlagsPayloadIsImmutable() async {
let source = NSMutableString(string: "original")
let sdk = makeSDK(bootstrapFlags: ["swift6-variant": source])
defer { sdk.close() }
let state = AppState()

var subscription: PostHogFeatureFlagsSubscription?
await withCheckedContinuation { continuation in
subscription = sdk.onFeatureFlags { loaded in
guard state.loaded == nil else { return }
state.loaded = loaded
continuation.resume()
}
}
subscription?.unsubscribe()

source.setString("changed")
let loaded = state.loaded
let variant = await Task.detached { loaded?.variants["swift6-variant"] as? String }.value
#expect(variant == "original")
}
}
8 changes: 4 additions & 4 deletions api/posthog-ios.public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ PostHog | PostHogConfig.sendFeatureFlagEvent | property | @objc var sendFeatureF
PostHog | PostHogConfig.sessionReplay | property | @objc var sessionReplay: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)sessionReplay
PostHog | PostHogConfig.sessionReplayConfig | property | @objc let sessionReplayConfig: PostHogSessionReplayConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)sessionReplayConfig
PostHog | PostHogConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) | c:@M@PostHog@objc(cs)PostHogConfig(im)setBeforeSend:
PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: BeforeSendBlock...) | s:7PostHog0aB6ConfigC13setBeforeSendyyAA0aB5EventCSgAFcd_tF
PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [BeforeSendBlock]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFcGF
PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: BeforeSendBlock...) | s:7PostHog0aB6ConfigC13setBeforeSendyyAA0aB5EventCSgAFYbcd_tF
PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [BeforeSendBlock]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFYbcGF
PostHog | PostHogConfig.setDefaultPersonProperties | property | @objc var setDefaultPersonProperties: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)setDefaultPersonProperties
PostHog | PostHogConfig.surveys | property | @objc var surveys: Bool { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)surveys
PostHog | PostHogConfig.surveysConfig | property | @objc var surveysConfig: PostHogSurveysConfig { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)surveysConfig
Expand Down Expand Up @@ -230,8 +230,8 @@ PostHog | PostHogLogsConfig.resourceAttributes | property | @objc var resourceAt
PostHog | PostHogLogsConfig.serviceName | property | @objc var serviceName: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceName
PostHog | PostHogLogsConfig.serviceVersion | property | @objc var serviceVersion: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceVersion
PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendLogBlock]) | c:@M@PostHog@objc(cs)PostHogLogsConfig(im)setBeforeSend:
PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) | s:7PostHog0aB10LogsConfigC13setBeforeSendyyAA0aB9LogRecordCSgAFcd_tF
PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [PostHogBeforeSendLogBlock]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFcGF
PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) | s:7PostHog0aB10LogsConfigC13setBeforeSendyyAA0aB9LogRecordCSgAFYbcd_tF
PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [PostHogBeforeSendLogBlock]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFYbcGF
PostHog | PostHogNextSurveyQuestion | class | @objc class PostHogNextSurveyQuestion | c:@M@PostHog@objc(cs)PostHogNextSurveyQuestion
PostHog | PostHogNextSurveyQuestion.isSurveyCompleted | property | let isSurveyCompleted: Bool | s:7PostHog0aB18NextSurveyQuestionC02isD9CompletedSbvp
PostHog | PostHogNextSurveyQuestion.questionIndex | property | let questionIndex: Int | s:7PostHog0aB18NextSurveyQuestionC13questionIndexSivp
Expand Down
Loading