Skip to content

Commit

Permalink
Outline all new signals & parameters & sprinkle TODOs to finalize
Browse files Browse the repository at this point in the history
  • Loading branch information
Jeehut committed Dec 25, 2024
1 parent e1a5f4e commit a53101e
Show file tree
Hide file tree
Showing 9 changed files with 290 additions and 0 deletions.
86 changes: 86 additions & 0 deletions Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

@MainActor
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(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
}
}
10 changes: 10 additions & 0 deletions Sources/TelemetryDeck/PirateMetrics/SessionManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation

@MainActor
final class SessionManager {
static let shared = SessionManager()
private init() {}

// TODO: make sure that all session start dates and their duration are persisted (use a Codable?)

Check failure on line 8 in Sources/TelemetryDeck/PirateMetrics/SessionManager.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (make sure that all session sta...) (todo)
// TODO: implement auto-detection of new install and send `newInstallDetected` with `firstSessionDate`

Check failure on line 9 in Sources/TelemetryDeck/PirateMetrics/SessionManager.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (implement auto-detection of ne...) (todo)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation

public extension TelemetryDeck {
static func acquiredUser(
channel: String,
parameters: [String: String] = [:],
customUserID: String? = nil
) {
let acquisitionParameters = ["TelemetryDeck.Acquisition.channel": channel]

// TODO: persist channel and send with every request

Check failure on line 11 in Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (persist channel and send with ...) (todo)

self.internalSignal(
"TelemetryDeck.Acquisition.userAcquired",
parameters: acquisitionParameters.merging(parameters) { $1 },
customUserID: customUserID
)
}

static func leadStarted(
leadID: String,
parameters: [String: String] = [:],
customUserID: String? = nil
) {
let leadParameters: [String: String] = ["TelemetryDeck.Acquisition.leadID": leadID]

self.internalSignal(
"TelemetryDeck.Acquisition.leadStarted",
parameters: leadParameters.merging(parameters) { $1 },
customUserID: customUserID
)
}

static func leadConverted(
leadID: String,
parameters: [String: String] = [:],
customUserID: String? = nil
) {
let leadParameters: [String: String] = ["TelemetryDeck.Acquisition.leadID": leadID]

self.internalSignal(
"TelemetryDeck.Acquisition.leadConverted",
parameters: leadParameters.merging(parameters) { $1 },
customUserID: customUserID
)
}
}
32 changes: 32 additions & 0 deletions Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

extension TelemetryDeck {
static func onboardingCompleted(
parameters: [String: String] = [:],
customUserID: String? = nil
) {
let onboardingParameters: [String: String] = [:]

self.internalSignal(
"TelemetryDeck.Activation.onboardingCompleted",
parameters: onboardingParameters.merging(parameters) { $1 },
customUserID: customUserID
)
}

static func coreFeatureUsed(
featureName: String,
parameters: [String: String] = [:],
customUserID: String? = nil
) {
let featureParameters = [
"TelemetryDeck.Activation.featureName": featureName
]

self.internalSignal(
"TelemetryDeck.Activation.coreFeatureUsed",
parameters: featureParameters.merging(parameters) { $1 },
customUserID: customUserID
)
}
}
50 changes: 50 additions & 0 deletions Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation

extension TelemetryDeck {
static func referralSent(
receiversCount: Int = 1,
kind: String? = nil,
parameters: [String: String] = [:],
customUserID: String? = nil
) {
// TODO: document all new parameters and their types in the default parameters doc

Check failure on line 10 in Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (document all new parameters an...) (todo)
var referralParameters = ["TelemetryDeck.Referral.receiversCount": String(receiversCount)]

if let kind {
referralParameters["TelemetryDeck.Referral.kind"] = kind
}

self.internalSignal(
"TelemetryDeck.Referral.sent",
parameters: referralParameters.merging(parameters) { $1 },
customUserID: customUserID
)
}

// TODO: explicitly mention how this can be used for NPS Score or for App Store like ratings

Check failure on line 24 in Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (explicitly mention how this ca...) (todo)
static func userRatingSubmitted(
rating: Int,
comment: String? = nil,
parameters: [String: String] = [:],
customUserID: String? = nil
) {
guard (0...10).contains(rating) else {
TelemetryManager.shared.configuration.logHandler?.log(.error, message: "Rating must be between 0 and 10")
return
}

var ratingParameters = [
"TelemetryDeck.Referral.ratingValue": String(rating)
]

if let comment {
ratingParameters["TelemetryDeck.Referral.ratingComment"] = comment
}

self.internalSignal(
"TelemetryDeck.Referral.userRatingSubmitted",
parameters: ratingParameters.merging(parameters) { $1 },
customUserID: customUserID
)
}
}
17 changes: 17 additions & 0 deletions Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

extension TelemetryDeck {
static func paywallShown(
reason: String,
parameters: [String: String] = [:],
customUserID: String? = nil
) {
let paywallParameters = ["TelemetryDeck.Revenue.paywallShowReason": reason]

self.internalSignal(
"TelemetryDeck.Revenue.paywallShown",
parameters: paywallParameters.merging(parameters) { $1 },
customUserID: customUserID
)
}
}
4 changes: 4 additions & 0 deletions Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ extension TelemetryDeck {
parameters: [String: String] = [:],
customUserID: String? = nil
) {
// TODO: when a price of 0 and a subscription is detected, send `freeTrialStarted` signal

Check failure on line 22 in Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (when a price of 0 and a subscr...) (todo)
// TODO: persist free trial state

Check failure on line 23 in Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (persist free trial state) (todo)
// TODO: add StoreKit integration to auto-detect free-trial conversions and send `convertedFromFreeTrial`

Check failure on line 24 in Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (add StoreKit integration to au...) (todo)

let priceValueInNativeCurrency = NSDecimalNumber(decimal: transaction.price ?? Decimal()).doubleValue

let priceValueInUSD: Double
Expand Down
3 changes: 3 additions & 0 deletions Sources/TelemetryDeck/TelemetryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ public final class TelemetryManagerConfiguration: @unchecked Sendable {
didSet {
if sendNewSessionBeganSignal {
TelemetryDeck.internalSignal("TelemetryDeck.Session.started")

// TODO: send `totalSessionsCount` and `distinctDaysUsed` as well as `weekday`, `dayOfMonth`, and `dayOfYear`

Check failure on line 75 in Sources/TelemetryDeck/TelemetryClient.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (send `totalSessionsCount` and ...) (todo)
// TODO: calculate and send `averageSessionSeconds`

Check failure on line 76 in Sources/TelemetryDeck/TelemetryClient.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (calculate and send `averageSes...) (todo)
}
}
}
Expand Down
41 changes: 41 additions & 0 deletions Sources/TelemetryDeck/TelemetryDeck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,47 @@ 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
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
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 check are skipped. Only for internal use.
static func internalSignal(
_ signalName: String,
Expand Down

0 comments on commit a53101e

Please sign in to comment.