From b8b54d13a34137e6193d7ea98f532e70c7108652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Wed, 25 Dec 2024 16:53:47 +0100 Subject: [PATCH 1/2] Add new duration signal type where the SDK tracks duration & sends it --- .../Helpers/DurationSignalTracker.swift | 103 ++++++++++++++++++ Sources/TelemetryDeck/TelemetryDeck.swift | 53 ++++++++- 2 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift diff --git a/Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift b/Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift new file mode 100644 index 0000000..f2502bf --- /dev/null +++ b/Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift @@ -0,0 +1,103 @@ +#if canImport(WatchKit) +import WatchKit +#elseif canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +@MainActor +@available(watchOS 7.0, *) +final class DurationSignalTracker { + static let shared = DurationSignalTracker() + + private struct CachedData { + let startTime: Date + let parameters: [String: String] + } + + private var startedSignals: [String: CachedData] = [:] + private var lastEnteredBackground: Date? + + private init() { + self.setupAppLifecycleObservers() + } + + func startTracking(_ signalName: String, parameters: [String: String]) { + self.startedSignals[signalName] = CachedData(startTime: Date(), parameters: parameters) + } + + func stopTracking(_ signalName: String) -> (duration: TimeInterval, parameters: [String: String])? { + guard let trackingData = self.startedSignals[signalName] else { return nil } + self.startedSignals[signalName] = nil + + let duration = Date().timeIntervalSince(trackingData.startTime) + return (duration, trackingData.parameters) + } + + private func setupAppLifecycleObservers() { +#if canImport(WatchKit) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidEnterBackgroundNotification), + name: WKApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForegroundNotification), + name: WKApplication.willEnterForegroundNotification, + object: nil + ) +#elseif canImport(UIKit) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidEnterBackgroundNotification), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForegroundNotification), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) +#elseif canImport(AppKit) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidEnterBackgroundNotification), + name: NSApplication.didResignActiveNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForegroundNotification), + name: NSApplication.willBecomeActiveNotification, + object: nil + ) +#endif + } + + @objc + private func handleDidEnterBackgroundNotification() { + self.lastEnteredBackground = Date() + } + + @objc + private func handleWillEnterForegroundNotification() { + guard let lastEnteredBackground else { return } + let backgroundDuration = Date().timeIntervalSince(lastEnteredBackground) + + for (signalName, data) in self.startedSignals { + self.startedSignals[signalName] = CachedData( + startTime: data.startTime.addingTimeInterval(backgroundDuration), + parameters: data.parameters + ) + } + + self.lastEnteredBackground = nil + } +} diff --git a/Sources/TelemetryDeck/TelemetryDeck.swift b/Sources/TelemetryDeck/TelemetryDeck.swift index d95aac7..e0de5d2 100644 --- a/Sources/TelemetryDeck/TelemetryDeck.swift +++ b/Sources/TelemetryDeck/TelemetryDeck.swift @@ -81,6 +81,49 @@ public enum TelemetryDeck { self.internalSignal(combinedSignalName, parameters: prefixedParameters, floatValue: floatValue, customUserID: customUserID) } + /// Starts tracking the duration of a signal without sending it yet. + /// + /// - Parameters: + /// - signalName: The name of the signal to track. This will be used to identify and stop the duration tracking later. + /// - parameters: A dictionary of additional string key-value pairs that will be included when the duration signal is eventually sent. Default is empty. + /// + /// This function only starts tracking time – it does not send a signal. You must call `stopAndSendDurationSignal(_:parameters:)` + /// with the same signal name to finalize and actually send the signal with the tracked duration. + /// + /// The timer only counts time while the app is in the foreground. + /// + /// If a new duration signal ist started while an existing duration signal with the same name was not stopped yet, the old one is replaced with the new one. + @MainActor + @available(watchOS 7.0, *) + public static func startDurationSignal(_ signalName: String, parameters: [String: String] = [:]) { + DurationSignalTracker.shared.startTracking(signalName, parameters: parameters) + } + + /// Stops tracking the duration of a signal and sends it with the total duration. + /// + /// - Parameters: + /// - signalName: The name of the signal that was previously started with `startDurationSignal(_:parameters:)`. + /// - parameters: Additional parameters to include with the signal. These will be merged with the parameters provided at the start. Default is empty. + /// + /// This function finalizes the duration tracking by: + /// 1. Stopping the timer for the given signal name + /// 2. Calculating the duration in seconds (excluding background time) + /// 3. Sending a signal that includes the start parameters, stop parameters, and calculated duration + /// + /// The duration is included in the `TelemetryDeck.Signal.durationInSeconds` parameter. + /// + /// If no matching signal was started, this function does nothing. + @MainActor + @available(watchOS 7.0, *) + public static func stopAndSendDurationSignal(_ signalName: String, parameters: [String: String] = [:]) { + guard let (duration, startParameters) = DurationSignalTracker.shared.stopTracking(signalName) else { return } + + var durationParameters = ["TelemetryDeck.Signal.durationInSeconds": String(duration)] + durationParameters.merge(startParameters) { $1 } + + self.internalSignal(signalName, parameters: durationParameters.merging(parameters) { $1 }) + } + /// A signal being sent without enriching the signal name with a prefix. Also, any reserved signal name checks are skipped. Only for internal use. static func internalSignal( _ signalName: String, @@ -121,17 +164,17 @@ public enum TelemetryDeck { ) } - /// Do not call this method unless you really know what you're doing. The signals will automatically sync with + /// Do not call this method unless you really know what you're doing. The signals will automatically sync with /// the server at appropriate times, there's no need to call this. /// - /// Use this sparingly and only to indicate a time in your app where a signal was just sent but the user is likely + /// Use this sparingly and only to indicate a time in your app where a signal was just sent but the user is likely /// to leave your app and not return again for a long time. /// - /// This function does not guarantee that the signal cache will be sent right away. Calling this after every - /// ``signal(_:parameters:floatValue:customUserID:)`` will not make data reach our servers faster, so avoid + /// This function does not guarantee that the signal cache will be sent right away. Calling this after every + /// ``signal(_:parameters:floatValue:customUserID:)`` will not make data reach our servers faster, so avoid /// doing that. /// - /// But if called at the right time (sparingly), it can help ensure the server doesn't miss important churn + /// But if called at the right time (sparingly), it can help ensure the server doesn't miss important churn /// data because a user closes your app and doesn't reopen it anytime soon (if at all). public static func requestImmediateSync() { let manager = TelemetryManager.shared From 5d35f6560b6d3f6fd8553c3018412b91271b359c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Tue, 14 Jan 2025 13:13:32 +0100 Subject: [PATCH 2/2] Rework to not require main thread using dispatch queue --- .../Helpers/DurationSignalTracker.swift | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift b/Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift index f2502bf..f517b04 100644 --- a/Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift +++ b/Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift @@ -6,16 +6,16 @@ import UIKit import AppKit #endif -@MainActor @available(watchOS 7.0, *) -final class DurationSignalTracker { +final class DurationSignalTracker: @unchecked Sendable { static let shared = DurationSignalTracker() - private struct CachedData { + private struct CachedData: Sendable { let startTime: Date let parameters: [String: String] } + private let queue = DispatchQueue(label: "com.telemetrydeck.DurationSignalTracker") private var startedSignals: [String: CachedData] = [:] private var lastEnteredBackground: Date? @@ -24,19 +24,23 @@ final class DurationSignalTracker { } func startTracking(_ signalName: String, parameters: [String: String]) { - self.startedSignals[signalName] = CachedData(startTime: Date(), parameters: parameters) + self.queue.sync { + self.startedSignals[signalName] = CachedData(startTime: Date(), parameters: parameters) + } } func stopTracking(_ signalName: String) -> (duration: TimeInterval, parameters: [String: String])? { - guard let trackingData = self.startedSignals[signalName] else { return nil } - self.startedSignals[signalName] = nil + self.queue.sync { + guard let trackingData = self.startedSignals[signalName] else { return nil } + self.startedSignals[signalName] = nil - let duration = Date().timeIntervalSince(trackingData.startTime) - return (duration, trackingData.parameters) + let duration = Date().timeIntervalSince(trackingData.startTime) + return (duration, trackingData.parameters) + } } private func setupAppLifecycleObservers() { -#if canImport(WatchKit) + #if canImport(WatchKit) NotificationCenter.default.addObserver( self, selector: #selector(handleDidEnterBackgroundNotification), @@ -50,7 +54,7 @@ final class DurationSignalTracker { name: WKApplication.willEnterForegroundNotification, object: nil ) -#elseif canImport(UIKit) + #elseif canImport(UIKit) NotificationCenter.default.addObserver( self, selector: #selector(handleDidEnterBackgroundNotification), @@ -64,7 +68,7 @@ final class DurationSignalTracker { name: UIApplication.willEnterForegroundNotification, object: nil ) -#elseif canImport(AppKit) + #elseif canImport(AppKit) NotificationCenter.default.addObserver( self, selector: #selector(handleDidEnterBackgroundNotification), @@ -78,26 +82,30 @@ final class DurationSignalTracker { name: NSApplication.willBecomeActiveNotification, object: nil ) -#endif + #endif } @objc private func handleDidEnterBackgroundNotification() { - self.lastEnteredBackground = Date() + self.queue.sync { + self.lastEnteredBackground = Date() + } } @objc private func handleWillEnterForegroundNotification() { - guard let lastEnteredBackground else { return } - let backgroundDuration = Date().timeIntervalSince(lastEnteredBackground) + self.queue.sync { + guard let lastEnteredBackground else { return } + let backgroundDuration = Date().timeIntervalSince(lastEnteredBackground) - for (signalName, data) in self.startedSignals { - self.startedSignals[signalName] = CachedData( - startTime: data.startTime.addingTimeInterval(backgroundDuration), - parameters: data.parameters - ) - } + for (signalName, data) in self.startedSignals { + self.startedSignals[signalName] = CachedData( + startTime: data.startTime.addingTimeInterval(backgroundDuration), + parameters: data.parameters + ) + } - self.lastEnteredBackground = nil + self.lastEnteredBackground = nil + } } }