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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
## Next

- feat: add config option to disable swizzling ([#388](https://github.com/PostHog/posthog-ios/pull/388))
- feat: SDK instance now manages its own session ([#388](https://github.com/PostHog/posthog-ios/pull/388))
> **Note**: A potentially breaking change for users with multiple SDK instances. Each SDK instance now manages its own `$session_id` instead of sharing a global session across all instances.
> This aligns with PostHog JS SDK behavior and ensures proper session isolation when using multiple SDK instances.
> For single-instance usage (the common case), this change has no impact.

## 3.33.0 - 2025-10-13

- add evaluation tags to iOS SDK ([#387](https://github.com/PostHog/posthog-ios/pull/387))
- feat: add evaluation tags to iOS SDK ([#387](https://github.com/PostHog/posthog-ios/pull/387))

## 3.32.0 - 2025-10-03

Expand Down
2 changes: 2 additions & 0 deletions PostHog/App Life Cycle/PostHogAppLifeCycleIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import Foundation
- captures an `App Backgrounded` event when the app moves to the background
*/
final class PostHogAppLifeCycleIntegration: PostHogIntegration {
var requiresSwizzling: Bool { false }

private static var integrationInstalledLock = NSLock()
private static var integrationInstalled = false
private static var didCaptureAppInstallOrUpdate = false
Expand Down
2 changes: 2 additions & 0 deletions PostHog/Autocapture/PostHogAutocaptureIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
private let elementsChainDelimiter = ";"

class PostHogAutocaptureIntegration: AutocaptureEventProcessing, PostHogIntegration {
var requiresSwizzling: Bool { true }

private static var integrationInstalledLock = NSLock()
private static var integrationInstalled = false

Expand Down
2 changes: 0 additions & 2 deletions PostHog/DI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ enum DI {
static var main = Container()

final class Container {
// manages session rotation
lazy var sessionManager: PostHogSessionManager = .init()
// publishes global app lifecycle events
lazy var appLifecyclePublisher: AppLifecyclePublishing = ApplicationLifecyclePublisher.shared
// publishes global screen view events (UIViewController.viewDidAppear)
Expand Down
11 changes: 11 additions & 0 deletions PostHog/PostHogConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent?

@objc public var captureApplicationLifecycleEvents: Bool = true
@objc public var captureScreenViews: Bool = true

/// Enable method swizzling for SDK functionality that depends on it
///
/// When disabled, functionality that require swizzling (like autocapture, screen views, session replay, surveys) will not be installed.
///
/// Note: Disabling swizzling will limit session rotation logic to only detect application open and background events.
/// Session rotation will still work, just with reduced granularity for detecting user activity.
///
/// Default: true
@objc public var enableSwizzling: Bool = true

#if os(iOS) || targetEnvironment(macCatalyst)
/// Enable autocapture for iOS
/// Default: false
Expand Down
8 changes: 8 additions & 0 deletions PostHog/PostHogIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
import Foundation

protocol PostHogIntegration {
/**
* Indicates whether this integration requires method swizzling to function.
*
* When `enableSwizzling` is set to `false` in PostHogConfig, integrations
* that return `true` for this property will be skipped during installation.
*/
var requiresSwizzling: Bool { get }

/**
* Installs and initializes the integration with a PostHogSDK instance.
*
Expand Down
33 changes: 23 additions & 10 deletions PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ let maxRetryDelay = 30.0
private var context: PostHogContext?
private static var apiKeys = Set<String>()
private var installedIntegrations: [PostHogIntegration] = []
let sessionManager = PostHogSessionManager()

#if os(iOS)
private weak var replayIntegration: PostHogReplayIntegration?
Expand Down Expand Up @@ -136,7 +137,9 @@ let maxRetryDelay = 30.0
replayQueue?.start(disableReachabilityForTesting: config.disableReachabilityForTesting,
disableQueueTimerForTesting: config.disableQueueTimerForTesting)

PostHogSessionManager.shared.startSession()
// Create session manager instance for this PostHogSDK instance
sessionManager.setup(config: config)
sessionManager.startSession()

if !config.optOut {
// don't install integrations if in opt-out state
Expand Down Expand Up @@ -170,23 +173,23 @@ let maxRetryDelay = 30.0
return nil
}

return PostHogSessionManager.shared.getSessionId(readOnly: true)
return sessionManager.getSessionId(readOnly: true)
}

@objc public func startSession() {
if !isEnabled() {
return
}

PostHogSessionManager.shared.startSession()
sessionManager.startSession()
}

@objc public func endSession() {
if !isEnabled() {
return
}

PostHogSessionManager.shared.endSession()
sessionManager.endSession()
}

// EVENT CAPTURE
Expand Down Expand Up @@ -303,7 +306,7 @@ let maxRetryDelay = 30.0
// if not present, get a current or new session id at event timestamp
let propSessionId = properties?["$session_id"] as? String
let sessionId: String? = propSessionId.isNilOrEmpty
? PostHogSessionManager.shared.getSessionId(at: timestamp ?? now())
? sessionManager.getSessionId(at: timestamp ?? now())
: propSessionId

if let sessionId {
Expand Down Expand Up @@ -351,7 +354,7 @@ let maxRetryDelay = 30.0
flagCallReportedLock.withLock {
flagCallReported.removeAll()
}
PostHogSessionManager.shared.resetSession()
sessionManager.reset()

// Clear person and group properties for flags
remoteConfig?.resetPersonPropertiesForFlags()
Expand Down Expand Up @@ -1270,7 +1273,7 @@ let maxRetryDelay = 30.0
flagCallReported.removeAll()
}
context = nil
PostHogSessionManager.shared.endSession()
sessionManager.endSession()
toggleHedgeLog(false)

uninstallIntegrations()
Expand Down Expand Up @@ -1328,8 +1331,8 @@ let maxRetryDelay = 30.0
}

let sessionId = resumeCurrent
? PostHogSessionManager.shared.getSessionId()
: PostHogSessionManager.shared.getNextSessionId()
? sessionManager.getSessionId()
: sessionManager.getNextSessionId()

guard let sessionId else {
return hedgeLog("Could not start recording. Missing session id.")
Expand Down Expand Up @@ -1375,7 +1378,7 @@ let maxRetryDelay = 30.0
}

return replayIntegration.isActive()
&& !PostHogSessionManager.shared.getSessionId(readOnly: true).isNilOrEmpty
&& !sessionManager.getSessionId(readOnly: true).isNilOrEmpty
&& remoteConfig.isSessionReplayFlagActive()
}
#endif
Expand All @@ -1396,6 +1399,12 @@ let maxRetryDelay = 30.0
var installed: [PostHogIntegration] = []

for integration in integrations {
// Skip integrations that require swizzling when swizzling is disabled
if integration.requiresSwizzling, !config.enableSwizzling {
hedgeLog("Integration \(type(of: integration)) skipped. Integration requires swizzling but enableSwizzling is disabled in config")
continue
}

do {
try integration.install(self)
installed.append(integration)
Expand Down Expand Up @@ -1469,6 +1478,10 @@ let maxRetryDelay = 30.0
}
#endif

func getSessionManager() -> PostHogSessionManager? {
sessionManager
}

func getAppLifeCycleIntegration() -> PostHogAppLifeCycleIntegration? {
installedIntegrations.compactMap {
$0 as? PostHogAppLifeCycleIntegration
Expand Down
25 changes: 21 additions & 4 deletions PostHog/PostHogSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,31 @@ import Foundation
}

@objc public static var shared: PostHogSessionManager {
DI.main.sessionManager
PostHogSDK.shared.sessionManager
}

// Private initializer to prevent multiple instances
private var config: PostHogConfig?

override init() {
super.init()
}

func setup(config: PostHogConfig) {
self.config = config
didBecomeActiveToken = nil
didEnterBackgroundToken = nil
applicationEventToken = nil
registerNotifications()
registerApplicationSendEvent()
}

func reset() {
resetSession()
didBecomeActiveToken = nil
didEnterBackgroundToken = nil
applicationEventToken = nil
}

private let queue = DispatchQueue(label: "com.posthog.PostHogSessionManager", target: .global(qos: .utility))
private var sessionId: String?
private var sessionStartTimestamp: TimeInterval?
Expand Down Expand Up @@ -237,8 +252,10 @@ import Foundation

private func registerApplicationSendEvent() {
#if os(iOS) || os(tvOS)
let applicationEventPublisher = DI.main.applicationEventPublisher
applicationEventToken = applicationEventPublisher.onApplicationEvent { [weak self] _, _ in
guard let config, config.enableSwizzling else {
return
}
applicationEventToken = DI.main.applicationEventPublisher.onApplicationEvent { [weak self] _, _ in
// update "last active" session
// we want to keep track of the idle time, so we need to maintain a timestamp on the last interactions of the user with the app. UIEvents are a good place to do so since it means that the user is actively interacting with the app (e.g not just noise background activity)
self?.queue.async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
isActive,
let postHog,
postHog.isSessionReplayActive(),
let sessionId = PostHogSessionManager.shared.getSessionId(at: output.timestamp)
let sessionId = postHog.sessionManager.getSessionId(at: output.timestamp)
else {
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
do {
sessionSwizzler = try URLSessionSwizzler(
shouldCapture: shouldCaptureNetworkSample,
onCapture: handleNetworkSample
onCapture: handleNetworkSample,
getSessionId: { [weak self] date in
self?.postHog?.sessionManager.getSessionId(at: date)
}
)
sessionSwizzler?.swizzle()
hedgeLog("[Session Replay] Network telemetry plugin started")
Expand Down
4 changes: 2 additions & 2 deletions PostHog/Replay/Plugins/Network/URLSessionExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
let timestamp = Date()
let startMillis = getMonotonicTimeInMilliseconds()
var endMillis: UInt64?
let sessionId = PostHogSessionManager.shared.getSessionId(at: timestamp)
let sessionId = postHog?.sessionManager.getSessionId(at: timestamp)
do {
let (data, response) = try await action()
endMillis = getMonotonicTimeInMilliseconds()
Expand Down Expand Up @@ -57,7 +57,7 @@
let timestamp = Date()
let startMillis = getMonotonicTimeInMilliseconds()
var endMillis: UInt64?
let sessionId = PostHogSessionManager.shared.getSessionId(at: timestamp)
let sessionId = postHog?.sessionManager.getSessionId(at: timestamp)
do {
let (url, response) = try await action()
endMillis = getMonotonicTimeInMilliseconds()
Expand Down
6 changes: 4 additions & 2 deletions PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
private let tasksLock = NSLock()
private let shouldCapture: () -> Bool
private let onCapture: (NetworkSample) -> Void
private let getSessionId: (Date) -> String?

init(shouldCapture: @escaping () -> Bool, onCapture: @escaping (NetworkSample) -> Void) {
init(shouldCapture: @escaping () -> Bool, onCapture: @escaping (NetworkSample) -> Void, getSessionId: @escaping (Date) -> String?) {
self.shouldCapture = shouldCapture
self.onCapture = onCapture
self.getSessionId = getSessionId
}

/// An internal queue for synchronising the access to `samplesByTask`.
Expand All @@ -42,7 +44,7 @@

let date = now()

guard let sessionId = PostHogSessionManager.shared.getSessionId(at: date) else {
guard let sessionId = getSessionId(date) else {
return
}

Expand Down
5 changes: 3 additions & 2 deletions PostHog/Replay/Plugins/Network/URLSessionSwizzler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@

private var hasSwizzled = false

init(shouldCapture: @escaping () -> Bool, onCapture: @escaping (NetworkSample) -> Void) throws {
init(shouldCapture: @escaping () -> Bool, onCapture: @escaping (NetworkSample) -> Void, getSessionId: @escaping (Date) -> String?) throws {
interceptor = URLSessionInterceptor(
shouldCapture: shouldCapture,
onCapture: onCapture
onCapture: onCapture,
getSessionId: getSessionId
)

dataTaskWithURLAndCompletion = try DataTaskWithURLAndCompletion.build(interceptor: interceptor)
Expand Down
10 changes: 6 additions & 4 deletions PostHog/Replay/PostHogReplayIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import WebKit

class PostHogReplayIntegration: PostHogIntegration {
var requiresSwizzling: Bool { true }

private static var integrationInstalledLock = NSLock()
private static var integrationInstalled = false

Expand Down Expand Up @@ -140,7 +142,7 @@

isEnabled = true
// reset views when session id changes (or is cleared) so we can re-send new metadata (or full snapshot in the future)
PostHogSessionManager.shared.onSessionIdChanged = { [weak self] in
postHog.sessionManager.onSessionIdChanged = { [weak self] in
self?.resetViews()
}

Expand Down Expand Up @@ -183,7 +185,7 @@
guard isEnabled else { return }
isEnabled = false
resetViews()
PostHogSessionManager.shared.onSessionIdChanged = {}
postHog?.sessionManager.onSessionIdChanged = {}

// stop listening to `UIApplication.sendEvent`
applicationEventToken = nil
Expand Down Expand Up @@ -249,7 +251,7 @@

PostHogReplayIntegration.dispatchQueue.async { [touchInfo, weak postHog = postHog] in
// always make sure we have a fresh session id as early as possible
guard let sessionId = PostHogSessionManager.shared.getSessionId(at: date) else {
guard let sessionId = postHog?.sessionManager.getSessionId(at: date) else {
return
}

Expand Down Expand Up @@ -341,7 +343,7 @@

PostHogReplayIntegration.dispatchQueue.async {
// always make sure we have a fresh session id at correct timestamp
guard let sessionId = PostHogSessionManager.shared.getSessionId(at: timestampDate) else {
guard let sessionId = postHog.sessionManager.getSessionId(at: timestampDate) else {
return
}

Expand Down
2 changes: 2 additions & 0 deletions PostHog/Screen Views/PostHogScreenViewIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import Foundation

final class PostHogScreenViewIntegration: PostHogIntegration {
var requiresSwizzling: Bool { true }

private static var integrationInstalledLock = NSLock()
private static var integrationInstalled = false

Expand Down
2 changes: 2 additions & 0 deletions PostHog/Surveys/PostHogSurveyIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
#endif

final class PostHogSurveyIntegration: PostHogIntegration {
var requiresSwizzling: Bool { true }

private static var integrationInstalledLock = NSLock()
private static var integrationInstalled = false

Expand Down
2 changes: 1 addition & 1 deletion PostHogTests/PostHogAutocaptureIntegrationSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import Quick
server.stop()
server = nil
integration.stop()
PostHogSessionManager.shared.endSession {}
posthog.endSession()
posthog.close()
deleteSafely(applicationSupportDirectoryURL())
}
Expand Down
Loading
Loading