Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
23 changes: 19 additions & 4 deletions PostHog/PostHogSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,29 @@ 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
reset()
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 +250,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