Skip to content

Commit

Permalink
Add session tracking (#100)
Browse files Browse the repository at this point in the history
  • Loading branch information
mfclarke-cnx authored Feb 7, 2024
1 parent 698c86c commit c33f2ec
Show file tree
Hide file tree
Showing 3 changed files with 198 additions and 16 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Next

## 3.1.0 - 2024-02-07

- Add session tracking [#100](https://github.com/PostHog/posthog-ios/pull/100)

## 3.0.0 - 2024-01-29

Check out the updated [docs](https://posthog.com/docs/libraries/ios).
Expand Down
89 changes: 80 additions & 9 deletions PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import Foundation

let retryDelay = 5.0
let maxRetryDelay = 30.0
// 30 minutes in seconds
private let sessionChangeThreshold: TimeInterval = 60 * 30

// renamed to PostHogSDK due to https://github.com/apple/swift/issues/56573
@objc public class PostHogSDK: NSObject {
Expand All @@ -29,6 +31,7 @@ let maxRetryDelay = 30.0
private let optOutLock = NSLock()
private let groupsLock = NSLock()
private let personPropsLock = NSLock()
private let sessionLock = NSLock()

private var queue: PostHogQueue?
private var api: PostHogApi?
Expand All @@ -37,12 +40,16 @@ let maxRetryDelay = 30.0
#if !os(watchOS)
private var reachability: Reachability?
#endif
var now: () -> Date = { Date() }
private var flagCallReported = Set<String>()
private var featureFlags: PostHogFeatureFlags?
private var context: PostHogContext?
private static var apiKeys = Set<String>()
private var capturedAppInstalled = false
private var appFromBackground = false
private var sessionId: String?
private var sessionLastTimestamp: TimeInterval?
private var isInBackground = false

@objc public static let shared: PostHogSDK = {
let instance = PostHogSDK(PostHogConfig(apiKey: ""))
Expand Down Expand Up @@ -112,6 +119,8 @@ let maxRetryDelay = 30.0
registerNotifications()
captureScreenViews()

rotateSession()

DispatchQueue.main.async {
NotificationCenter.default.post(name: PostHogSDK.didStartNotification, object: nil)
}
Expand Down Expand Up @@ -151,6 +160,14 @@ let maxRetryDelay = 30.0
properties["$groups"] = groups!
}

var theSessionId: String?
sessionLock.withLock {
theSessionId = sessionId
}
if let theSessionId = theSessionId {
properties["$session_id"] = theSessionId
}

guard let flags = featureFlags?.getFeatureFlags() as? [String: Any] else {
return properties
}
Expand Down Expand Up @@ -359,6 +376,18 @@ let maxRetryDelay = 30.0
guard let queue = queue else {
return
}

// If events fire in the background after the threshold, they should no longer have a sessionId
if isInBackground,
sessionId != nil,
let sessionLastTimestamp = sessionLastTimestamp,
now().timeIntervalSince1970 - sessionLastTimestamp > sessionChangeThreshold
{
sessionLock.withLock {
sessionId = nil
}
}

queue.add(PostHogEvent(
event: event,
distinctId: getDistinctId(),
Expand Down Expand Up @@ -579,6 +608,27 @@ let maxRetryDelay = 30.0
return enabled
}

private func rotateSessionIdIfRequired() {
guard sessionId != nil, let sessionLastTimestamp = sessionLastTimestamp else {
rotateSession()
return
}

if now().timeIntervalSince1970 - sessionLastTimestamp > sessionChangeThreshold {
rotateSession()
}
}

private func rotateSession() {
let newSessionId = UUID().uuidString
let newSessionLastTimestamp = now().timeIntervalSince1970

sessionLock.withLock {
sessionId = newSessionId
sessionLastTimestamp = newSessionLastTimestamp
}
}

@objc public func optIn() {
if !isEnabled() {
return
Expand Down Expand Up @@ -643,29 +693,29 @@ let maxRetryDelay = 30.0

#if os(iOS) || os(tvOS)
defaultCenter.addObserver(self,
selector: #selector(captureAppInstallLifecycle),
selector: #selector(handleAppDidFinishLaunching),
name: UIApplication.didFinishLaunchingNotification,
object: nil)
defaultCenter.addObserver(self,
selector: #selector(captureAppBackgrounded),
selector: #selector(handleAppDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil)
defaultCenter.addObserver(self,
selector: #selector(captureAppOpened),
selector: #selector(handleAppDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil)
#elseif os(macOS)
defaultCenter.addObserver(self,
selector: #selector(captureAppInstallLifecycle),
selector: #selector(handleAppDidFinishLaunching),
name: NSApplication.didFinishLaunchingNotification,
object: nil)
// macOS does not have didEnterBackgroundNotification, so we use didResignActiveNotification
defaultCenter.addObserver(self,
selector: #selector(captureAppBackgrounded),
selector: #selector(handleAppDidEnterBackground),
name: NSApplication.didResignActiveNotification,
object: nil)
defaultCenter.addObserver(self,
selector: #selector(captureAppOpened),
selector: #selector(handleAppDidBecomeActive),
name: NSApplication.didBecomeActiveNotification,
object: nil)
#endif
Expand All @@ -679,7 +729,11 @@ let maxRetryDelay = 30.0
}
}

@objc func captureAppInstallLifecycle() {
@objc func handleAppDidFinishLaunching() {
captureAppInstallLifecycle()
}

private func captureAppInstallLifecycle() {
if !config.captureApplicationLifecycleEvents {
return
}
Expand Down Expand Up @@ -738,7 +792,14 @@ let maxRetryDelay = 30.0
}
}

@objc func captureAppOpened() {
@objc func handleAppDidBecomeActive() {
rotateSessionIdIfRequired()

isInBackground = false
captureAppOpened()
}

private func captureAppOpened() {
var props: [String: Any] = [:]
props["from_background"] = appFromBackground

Expand All @@ -761,7 +822,17 @@ let maxRetryDelay = 30.0
capture("Application Opened", properties: props)
}

@objc func captureAppBackgrounded() {
@objc func handleAppDidEnterBackground() {
captureAppBackgrounded()

sessionLock.withLock {
sessionLastTimestamp = now().timeIntervalSince1970
}

isInBackground = true
}

private func captureAppBackgrounded() {
if !config.captureApplicationLifecycleEvents {
return
}
Expand Down
121 changes: 114 additions & 7 deletions PostHogTests/PostHogSDKTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class PostHogSDKTest: QuickSpec {
server.start()
}
afterEach {
PostHogSDK.shared.now = { Date() }
server.stop()
server = nil
}
Expand Down Expand Up @@ -285,7 +286,7 @@ class PostHogSDKTest: QuickSpec {
it("capture AppBackgrounded") {
let sut = self.getSut()

sut.captureAppBackgrounded()
sut.handleAppDidEnterBackground()

let events = getBatchedEvents(server)

Expand All @@ -301,7 +302,7 @@ class PostHogSDKTest: QuickSpec {
it("capture AppInstalled") {
let sut = self.getSut()

sut.captureAppInstallLifecycle()
sut.handleAppDidFinishLaunching()

let events = getBatchedEvents(server)

Expand All @@ -324,7 +325,7 @@ class PostHogSDKTest: QuickSpec {
userDefaults.setValue("1", forKey: "PHGBuildKeyV2")
userDefaults.synchronize()

sut.captureAppInstallLifecycle()
sut.handleAppDidFinishLaunching()

let events = getBatchedEvents(server)

Expand All @@ -344,7 +345,7 @@ class PostHogSDKTest: QuickSpec {
it("capture AppOpenedFromBackground from_background should be false") {
let sut = self.getSut()

sut.captureAppOpened()
sut.handleAppDidBecomeActive()

let events = getBatchedEvents(server)

Expand All @@ -361,8 +362,8 @@ class PostHogSDKTest: QuickSpec {
it("capture AppOpenedFromBackground from_background should be true") {
let sut = self.getSut(flushAt: 2)

sut.captureAppOpened()
sut.captureAppOpened()
sut.handleAppDidBecomeActive()
sut.handleAppDidBecomeActive()

let events = getBatchedEvents(server)

Expand All @@ -379,7 +380,7 @@ class PostHogSDKTest: QuickSpec {
it("capture captureAppOpened") {
let sut = self.getSut()

sut.captureAppOpened()
sut.handleAppDidBecomeActive()

let events = getBatchedEvents(server)

Expand Down Expand Up @@ -524,5 +525,111 @@ class PostHogSDKTest: QuickSpec {
sut.reset()
sut.close()
}

it("sets sessionId on app start") {
let sut = self.getSut()

sut.handleAppDidBecomeActive()

let events = getBatchedEvents(server)

expect(events.count) == 1

let event = events.first!
expect(event.properties["$session_id"]).toNot(beNil())

sut.reset()
sut.close()
}

it("uses the same sessionId for all events in a session") {
let sut = self.getSut(flushAt: 3)
let mockNow = MockDate()
sut.now = { mockNow.date }

sut.capture("event1")

mockNow.date.addTimeInterval(10)

sut.capture("event2")

mockNow.date.addTimeInterval(10)

sut.capture("event3")

let events = getBatchedEvents(server)

expect(events.count) == 3

let sessionId = events[0].properties["$session_id"] as? String
expect(sessionId).toNot(beNil())
expect(events[1].properties["$session_id"] as? String).to(equal(sessionId))
expect(events[2].properties["$session_id"] as? String).to(equal(sessionId))

sut.reset()
sut.close()
}

it("rotates to a new sessionId only after > 30 mins in the background") {
let sut = self.getSut(flushAt: 5)
let mockNow = MockDate()
sut.now = { mockNow.date }

sut.handleAppDidEnterBackground() // Background "timer": 0 mins

mockNow.date.addTimeInterval(60 * 15) // Background "timer": 15 mins

sut.capture("event captured while in background")

mockNow.date.addTimeInterval(60 * 14) // Background "timer": 29 mins

sut.handleAppDidBecomeActive() // Background "timer": Resets back to 0 mins on next backgrounding

mockNow.date.addTimeInterval(60)

sut.handleAppDidEnterBackground() // Background "timer": 0 mins

mockNow.date.addTimeInterval(30 * 60 + 1) // Background "timer": 30 mins 1 second

sut.handleAppDidBecomeActive() // New sessionId created

let events = getBatchedEvents(server)
expect(events.count) == 5

let sessionId1 = events[0].properties["$session_id"] as? String
expect(sessionId1).toNot(beNil()) // Background "timer": 0 mins
expect(events[1].properties["$session_id"] as? String).to(equal(sessionId1)) // Background "timer": 15 mins
expect(events[2].properties["$session_id"] as? String).to(equal(sessionId1)) // Background "timer": 29 mins
expect(events[3].properties["$session_id"] as? String).to(equal(sessionId1)) // Background "timer": 0 mins
expect(events[4].properties["$session_id"] as? String).toNot(equal(sessionId1)) // Background "timer": 30 mins 1 second

sut.reset()
sut.close()
}

it("clears sessionId for background events after 30 mins in background") {
let sut = self.getSut(flushAt: 2)
let mockNow = MockDate()
sut.now = { mockNow.date }

sut.handleAppDidEnterBackground() // Background "timer": 0 mins

mockNow.date.addTimeInterval(60 * 30 + 1) // Background "timer": 30 mins 1 second

sut.capture("event captured while in background")

let events = getBatchedEvents(server)
expect(events.count) == 2

expect(events[0].properties["$session_id"] as? String).toNot(beNil())
expect(events[1].properties["$session_id"] as? String).to(beNil())

sut.reset()
sut.close()
}
}
}

private class MockDate {
var date = Date()
}

0 comments on commit c33f2ec

Please sign in to comment.