Skip to content

Commit

Permalink
Add new duration signal type where the SDK tracks duration & sends it
Browse files Browse the repository at this point in the history
  • Loading branch information
Jeehut committed Jan 14, 2025
1 parent b8486e4 commit b8b54d1
Show file tree
Hide file tree
Showing 2 changed files with 151 additions and 5 deletions.
103 changes: 103 additions & 0 deletions Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
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

0 comments on commit b8b54d1

Please sign in to comment.