Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new duration signal type where the SDK tracks duration & sends it #224

Merged
merged 2 commits into from
Jan 14, 2025
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
111 changes: 111 additions & 0 deletions Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#if canImport(WatchKit)
import WatchKit
#elseif canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

@available(watchOS 7.0, *)
final class DurationSignalTracker: @unchecked Sendable {
static let shared = DurationSignalTracker()

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?

private init() {
self.setupAppLifecycleObservers()
}

func startTracking(_ signalName: String, parameters: [String: String]) {
self.queue.sync {
self.startedSignals[signalName] = CachedData(startTime: Date(), parameters: parameters)
}
}

func stopTracking(_ signalName: String) -> (duration: TimeInterval, parameters: [String: String])? {
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)
}
}

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.queue.sync {
self.lastEnteredBackground = Date()
}
}

@objc
private func handleWillEnterForegroundNotification() {
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
)
}

self.lastEnteredBackground = nil
}
}
}
53 changes: 48 additions & 5 deletions Sources/TelemetryDeck/TelemetryDeck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading