diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index b7f32f6..547f129 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -17,11 +17,11 @@ jobs: build_and_test_examples: needs: cancel_previous - runs-on: macos-11 + runs-on: macos-14 steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '13.0' + xcode-version: '15.2' - uses: actions/checkout@v2 - uses: actions/cache@v2 with: diff --git a/Example/BasicExample/BasicExample.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/BasicExample/BasicExample.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7231dd8..3b5e304 100644 --- a/Example/BasicExample/BasicExample.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/BasicExample/BasicExample.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,34 +1,32 @@ { - "object": { - "pins": [ - { - "package": "Segment", - "repositoryURL": "https://github.com/segmentio/analytics-swift", - "state": { - "branch": null, - "revision": "7eeb2abf8452153af056baae5369679589f10936", - "version": "1.5.9" - } - }, - { - "package": "JSONSafeEncoder", - "repositoryURL": "https://github.com/segmentio/jsonsafeencoder-swift.git", - "state": { - "branch": null, - "revision": "8b70dc8c01b7b041912e30e29d2b488a43f782ac", - "version": "1.0.1" - } - }, - { - "package": "Sovran", - "repositoryURL": "https://github.com/segmentio/Sovran-Swift.git", - "state": { - "branch": null, - "revision": "a342b905f6baa64499cabdf61ccc185ec476b7b2", - "version": "1.1.1" - } + "pins" : [ + { + "identity" : "analytics-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/analytics-swift", + "state" : { + "revision" : "7eeb2abf8452153af056baae5369679589f10936", + "version" : "1.5.9" } - ] - }, - "version": 1 + }, + { + "identity" : "jsonsafeencoder-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/jsonsafeencoder-swift.git", + "state" : { + "revision" : "8b70dc8c01b7b041912e30e29d2b488a43f782ac", + "version" : "1.0.1" + } + }, + { + "identity" : "sovran-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/Sovran-Swift.git", + "state" : { + "revision" : "a342b905f6baa64499cabdf61ccc185ec476b7b2", + "version" : "1.1.1" + } + } + ], + "version" : 2 } diff --git a/Package.resolved b/Package.resolved index 91284cb..56536d2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,34 +1,32 @@ { - "object": { - "pins": [ - { - "package": "Segment", - "repositoryURL": "https://github.com/segmentio/analytics-swift.git", - "state": { - "branch": null, - "revision": "7eeb2abf8452153af056baae5369679589f10936", - "version": "1.5.9" - } - }, - { - "package": "JSONSafeEncoder", - "repositoryURL": "https://github.com/segmentio/jsonsafeencoder-swift.git", - "state": { - "branch": null, - "revision": "8b70dc8c01b7b041912e30e29d2b488a43f782ac", - "version": "1.0.1" - } - }, - { - "package": "Sovran", - "repositoryURL": "https://github.com/segmentio/Sovran-Swift.git", - "state": { - "branch": null, - "revision": "a342b905f6baa64499cabdf61ccc185ec476b7b2", - "version": "1.1.1" - } + "pins" : [ + { + "identity" : "analytics-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/analytics-swift.git", + "state" : { + "revision" : "7eeb2abf8452153af056baae5369679589f10936", + "version" : "1.5.9" } - ] - }, - "version": 1 + }, + { + "identity" : "jsonsafeencoder-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/jsonsafeencoder-swift.git", + "state" : { + "revision" : "8b70dc8c01b7b041912e30e29d2b488a43f782ac", + "version" : "1.0.1" + } + }, + { + "identity" : "sovran-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/Sovran-Swift.git", + "state" : { + "revision" : "a342b905f6baa64499cabdf61ccc185ec476b7b2", + "version" : "1.1.1" + } + } + ], + "version" : 2 } diff --git a/Package.swift b/Package.swift index d0f49f6..c90c8c2 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -19,19 +19,18 @@ let package = Package( dependencies: [ // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), - .package( - name: "Segment", - url: "https://github.com/segmentio/analytics-swift.git", - from: "1.5.9" - ) + .package(url: "https://github.com/segmentio/analytics-swift.git", from: "1.5.9") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "SegmentAmplitude", - dependencies: ["Segment"]), - + dependencies: [ + .product(name: "Segment", package: "analytics-swift") + ], + resources: [.process("Resources")] + ) // TESTS ARE HANDLED VIA THE EXAMPLE APP. ] ) diff --git a/Sources/SegmentAmplitude/AmplitudeSession.swift b/Sources/SegmentAmplitude/AmplitudeSession.swift index 34594a6..01efda9 100644 --- a/Sources/SegmentAmplitude/AmplitudeSession.swift +++ b/Sources/SegmentAmplitude/AmplitudeSession.swift @@ -40,128 +40,247 @@ public class AmplitudeSession: EventPlugin, iOSLifecycle { public var type = PluginType.enrichment public weak var analytics: Analytics? - var active = false + internal struct Constants { + static let ampPrefix = "[Amplitude] " + + static let ampSessionEndEvent = "session_end" + static let ampSessionStartEvent = "session_start" + static let ampAppInstalledEvent = "\(ampPrefix)Application Installed" + static let ampAppUpdatedEvent = "\(ampPrefix)Application Updated" + static let ampAppOpenedEvent = "\(ampPrefix)Application Opened" + static let ampAppBackgroundedEvent = "\(ampPrefix)Application Backgrounded" + static let ampDeepLinkOpenedEvent = "\(ampPrefix)Deep Link Opened" + static let ampScreenViewedEvent = "\(ampPrefix)Screen Viewed" + } - private var sessionID: TimeInterval? - private var lastEventFiredTime = Date() - private var minSessionTime: TimeInterval = 5 * 60 + @Atomic private var active = false + @Atomic private var inForeground: Bool = false + private var storage = Storage() - public init() { - if (sessionID == nil || sessionID == -1) - { - sessionID = Date().timeIntervalSince1970 + @Atomic var sessionID: Int64 { + didSet { + storage.write(key: Storage.Constants.previousSessionID, value: sessionID) + //print("sessionID = \(sessionID)") + } + } + + @Atomic var lastEventTime: Int64 { + didSet { + storage.write(key: Storage.Constants.lastEventTime, value: lastEventTime) } } + public init() { + self.sessionID = storage.read(key: Storage.Constants.previousSessionID) ?? -1 + self.lastEventTime = storage.read(key: Storage.Constants.lastEventTime) ?? -1 + //print("startup sessionID = \(sessionID)") + } + public func update(settings: Settings, type: UpdateType) { + if type != .initial { return } + if settings.hasIntegrationSettings(key: key) { active = true } else { active = false } + + if sessionID == -1 { + startNewSession() + } else { + startNewSessionIfNecessary() + } } public func execute(event: T?) -> T? { - if !active { - return event - } + guard let event else { return nil } + guard let event = defaultEventHandler(event: event) else { return nil } - var result: T? = event - switch result { - case let r as IdentifyEvent: - result = self.identify(event: r) as? T - lastEventFiredTime = Date() - case let r as TrackEvent: - result = self.track(event: r) as? T - case let r as ScreenEvent: - result = self.screen(event: r) as? T - lastEventFiredTime = Date() - case let r as AliasEvent: - result = self.alias(event: r) as? T - lastEventFiredTime = Date() - case let r as GroupEvent: - result = self.group(event: r) as? T - lastEventFiredTime = Date() - default: - break + if var trackEvent = event as? TrackEvent { + let eventName = trackEvent.event + + // check if time has elapsed and kick of a new session if it has. + // this will send events back through to do the tasks; nothing really happens inline. + startNewSessionIfNecessary() + + // if it's a start event, set a new sessionID + if eventName == Constants.ampSessionStartEvent { + sessionID = newTimestamp() + } + + // if it's amp specific stuff, disable all the integrations except for amp. + if eventName.contains(Constants.ampPrefix) || eventName == Constants.ampSessionStartEvent || eventName == Constants.ampSessionEndEvent { + var integrations = disableAllIntegrations(integrations: trackEvent.integrations) + integrations?.setValue(["session_id": sessionID], forKeyPath: KeyPath(key)) + trackEvent.integrations = integrations + } + + // handle events that need to be re-generated back to amplitude. + // block the originals from going to amplitude as well. + switch trackEvent.event { + case "Application Opened": + analytics?.track(name: Constants.ampAppOpenedEvent, properties: trackEvent.properties) + trackEvent.integrations?.setValue(false, forKeyPath: KeyPath(key)) + case "Application Installed": + analytics?.track(name: Constants.ampAppInstalledEvent, properties: trackEvent.properties) + trackEvent.integrations?.setValue(false, forKeyPath: KeyPath(key)) + case "Application Updated": + analytics?.track(name: Constants.ampAppUpdatedEvent, properties: trackEvent.properties) + trackEvent.integrations?.setValue(false, forKeyPath: KeyPath(key)) + case "Application Backgrounded": + analytics?.track(name: Constants.ampAppBackgroundedEvent, properties: trackEvent.properties) + trackEvent.integrations?.setValue(false, forKeyPath: KeyPath(key)) + case "Application Foregrounded": + // amplitude doesn't need this one, it's redundant. + trackEvent.integrations?.setValue(false, forKeyPath: KeyPath(key)) + default: + break + } + + return trackEvent as? T } - return result + + lastEventTime = newTimestamp() + return event } - public func track(event: TrackEvent) -> TrackEvent? { - if event.event != "Application Opened" { - lastEventFiredTime = Date() - } - - guard let returnEvent = insertSession(event: event) as? TrackEvent else { - return nil - } - return returnEvent + public func reset() { + resetSession() } - public func identify(event: IdentifyEvent) -> IdentifyEvent? { - guard let returnEvent = insertSession(event: event) as? IdentifyEvent else { - return nil - } - return returnEvent + public func applicationWillEnterForeground(application: UIApplication?) { + startNewSessionIfNecessary() + print("Foreground: \(sessionID)") + analytics?.log(message: "Amplitude Session ID: \(sessionID)") } - public func alias(event: AliasEvent) -> AliasEvent? { - guard let returnEvent = insertSession(event: event) as? AliasEvent else { - return nil + public func applicationWillResignActive(application: UIApplication?) { + print("Background: \(sessionID)") + lastEventTime = newTimestamp() + } +} + +extension AmplitudeSession: VersionedPlugin { + public static func version() -> String { + return __destination_version + } +} + + +// MARK: - AmplitudeSession Helper Methods + +extension AmplitudeSession { + private func disableAllIntegrations(integrations: JSON?) -> JSON? { + var result = integrations + if let keys = integrations?.dictionaryValue?.keys { + for key in keys { + result?.setValue(false, forKeyPath: KeyPath(key)) + } } - return returnEvent + // make sure segment is disabled too. + result?.setValue(false, forKeyPath: KeyPath("Segment.io")) + return result } - public func screen(event: ScreenEvent) -> ScreenEvent? { - guard let returnEvent = insertSession(event: event) as? ScreenEvent else { + private func defaultEventHandler(event: T) -> T? { + guard let returnEvent = insertSession(event: event) as? T else { return nil } return returnEvent } - public func group(event: GroupEvent) -> GroupEvent? { - guard let returnEvent = insertSession(event: event) as? GroupEvent else { - return nil + private func resetSession() { + if sessionID != -1 { + endSession() } - return returnEvent + startNewSession() } - public func reset() { - sessionID = nil + private func startNewSession() { + analytics?.track(name: Constants.ampSessionStartEvent) } - public func applicationWillEnterForeground(application: UIApplication?) { - if Date().timeIntervalSince(lastEventFiredTime) >= minSessionTime { - sessionID = Date().timeIntervalSince1970 + private func startNewSessionIfNecessary() { + let timestamp = newTimestamp() + let withinSessionLimit = withinMinSessionTime(timestamp: timestamp) + if sessionID >= 0 && withinSessionLimit { + return } - - analytics?.log(message: "Amplitude Session ID: \(sessionID ?? -1)") + // we'll consider this our new lastEventTime + lastEventTime = timestamp + // end previous session + endSession() + // start new session + startNewSession() } - public func applicationWillResignActive(application: UIApplication?) { - // Exposed if reacting to lifecycle events is needed + private func endSession() { + analytics?.track(name: Constants.ampSessionEndEvent) } -} - - -// MARK: - AmplitudeSession Helper Methods -extension AmplitudeSession { - func insertSession(event: RawEvent) -> RawEvent { + private func insertSession(event: RawEvent) -> RawEvent { var returnEvent = event - if var integrations = event.integrations?.dictionaryValue, - let sessionID = sessionID { - - integrations[key] = ["session_id": (Int(sessionID) * 1000)] + if var integrations = event.integrations?.dictionaryValue { + integrations[key] = ["session_id": sessionID] returnEvent.integrations = try? JSON(integrations as Any) } return returnEvent } + + private func newTimestamp() -> Int64 { + return Int64(Date().timeIntervalSince1970 * 1000) + } + + private func withinMinSessionTime(timestamp: Int64) -> Bool { + let minMilisecondsBetweenSessions = 300_000 + let timeDelta = timestamp - self.lastEventTime + return timeDelta < minMilisecondsBetweenSessions + } } -extension AmplitudeSession: VersionedPlugin { - public static func version() -> String { - return __destination_version +// MARK: - Storage for Session information + +extension AmplitudeSession { + internal class Storage { + internal struct Constants { + static let lastEventID = "last_event_id" + static let previousSessionID = "previous_session_id" + static let lastEventTime = "last_event_time" + } + + private var userDefaults = UserDefaults(suiteName: "com.segment.amplitude.session") + private func isBasicType(value: T?) -> Bool { + var result = false + if value == nil { + result = true + } else { + switch value { + // NSNull is not valid for UserDefaults + //case is NSNull: + // fallthrough + case is Decimal: + fallthrough + case is NSNumber: + fallthrough + case is Bool: + fallthrough + case is String: + result = true + default: + break + } + } + return result + } + + func read(key: String) -> T? { + return userDefaults?.value(forKey: key) as? T + } + + func write(key: String, value: T?) { + if let value, isBasicType(value: value) { + userDefaults?.setValue(value, forKey: key) + } + } } } diff --git a/Sources/SegmentAmplitude/Resources/PrivacyInfo.xcprivacy b/Sources/SegmentAmplitude/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..d46f3e9 --- /dev/null +++ b/Sources/SegmentAmplitude/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,56 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + 1C8F.1 + + + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeProductInteraction + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeCoarseLocation + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + + \ No newline at end of file diff --git a/Sources/SegmentAmplitude/Session.swift b/Sources/SegmentAmplitude/Session.swift new file mode 100644 index 0000000..5650834 --- /dev/null +++ b/Sources/SegmentAmplitude/Session.swift @@ -0,0 +1,122 @@ +// +// SessionTracker.swift +// +// +// Created by Brandon Sneed on 4/22/24. +// + +import Foundation +import Segment + +// MARK: - Amplitude Session Management + +internal class Session { + internal struct Constants { + static let ampPrefix = "[Amplitude] " + + static let ampSessionEndEvent = "session_end" + static let ampSessionStartEvent = "session_start" + static let ampAppInstalledEvent = "\(ampPrefix)Application Installed" + static let ampAppUpdatedEvent = "\(ampPrefix)Application Updated" + static let ampAppOpenedEvent = "\(ampPrefix)Application Opened" + static let ampAppBackgroundedEvent = "\(ampPrefix)Application Backgrounded" + static let ampDeepLinkOpenedEvent = "\(ampPrefix)Deep Link Opened" + static let ampScreenViewedEvent = "\(ampPrefix)Screen Viewed" + static let ampRevenueEvent = "revenue_amount" + } + + @Atomic var sessionID: Int64 { + didSet { + storage.write(key: Storage.Constants.previousSessionID, value: sessionID) + } + } + + @Atomic var lastEventTime: Int64 { + didSet { + storage.write(key: Storage.Constants.lastEventTime, value: lastEventTime) + } + } + + private var inForeground: Bool = true + private var storage = Storage() + + init() { + self.sessionID = storage.read(key: Storage.Constants.previousSessionID) ?? -1 + self.lastEventTime = storage.read(key: Storage.Constants.lastEventTime) ?? -1 + } + + + + func startNewSession(analytics: Analytics) { + let timestamp = newTimestamp() + if sessionID >= 0 && (inForeground || withinMinSessionTime(timestamp: timestamp)) { + return + } + // end previous session + analytics.track(name: Constants.ampSessionEndEvent) + // start new session + sessionID = timestamp + analytics.track(name: Constants.ampSessionStartEvent) + } +} + +// MARK: - Session helper functions + +extension Session { + private func newTimestamp() -> Int64 { + return Int64(Date().timeIntervalSince1970 * 1000) + } + + private func withinMinSessionTime(timestamp: Int64) -> Bool { + let minMilisecondsBetweenSessions = 300_000 + let timeDelta = timestamp - self.lastEventTime + return timeDelta < minMilisecondsBetweenSessions + } +} + +// MARK: - Session Storage + +extension Session { + internal class Storage { + internal struct Constants { + static let lastEventID = "last_event_id" + static let previousSessionID = "previous_session_id" + static let lastEventTime = "last_event_time" + } + + private var userDefaults = UserDefaults(suiteName: "com.segment.amplitude.session") + private func isBasicType(value: T?) -> Bool { + var result = false + if value == nil { + result = true + } else { + switch value { + // NSNull is not valid for UserDefaults + //case is NSNull: + // fallthrough + case is Decimal: + fallthrough + case is NSNumber: + fallthrough + case is Bool: + fallthrough + case is String: + result = true + default: + break + } + } + return result + } + + func read(key: String) -> T? { + return userDefaults?.value(forKey: key) as? T + } + + func write(key: String, value: T?) { + if let value, isBasicType(value: value) { + userDefaults?.setValue(value, forKey: key) + } + } + } +}