diff --git a/Package.swift b/Package.swift index 0c5d48b..b49ef4a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,36 +1,47 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.2 import PackageDescription let package = Package( name: "TelemetryDeck", platforms: [ - .macOS(.v10_13), - .iOS(.v12), - .watchOS(.v5), - .tvOS(.v13), + .macOS(.v12), + .macCatalyst(.v13), + .iOS(.v15), + .watchOS(.v8), + .tvOS(.v15), .visionOS(.v1), ], products: [ - .library(name: "TelemetryDeck", targets: ["TelemetryDeck"]), // new name - .library(name: "TelemetryClient", targets: ["TelemetryClient"]), // old name + .library(name: "TelemetryDeck", targets: ["TelemetryDeck"]) ], dependencies: [], targets: [ .target( name: "TelemetryDeck", resources: [.copy("PrivacyInfo.xcprivacy")], - swiftSettings: [.enableExperimentalFeature("StrictConcurrency")] + swiftSettings: [ + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances"), + .defaultIsolation(nil), + ] ), - .target( - name: "TelemetryClient", + .testTarget( + name: "TelemetryDeckTests", dependencies: ["TelemetryDeck"], - resources: [.copy("PrivacyInfo.xcprivacy")], - swiftSettings: [.enableExperimentalFeature("StrictConcurrency")] + swiftSettings: [ + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances"), + .defaultIsolation(nil), + ] ), .testTarget( - name: "TelemetryDeckTests", + name: "TelemetryDeckApproachableConcurrencyTests", dependencies: ["TelemetryDeck"], - swiftSettings: [.enableExperimentalFeature("StrictConcurrency")] + swiftSettings: [ + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances"), + .defaultIsolation(nil), + ] ), ] ) diff --git a/README.md b/README.md index 7acc872..287eb80 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,29 @@ -# Swift SDK for TelemetryDeck +# TelemetryDeck SwiftSDK -This package allows you to send signals to [TelemetryDeck](https://telemetrydeck.com) from your Swift code. Sign up for a free account at telemetrydeck.com +Privacy-first analytics for Apple platforms. Send events to [TelemetryDeck](https://telemetrydeck.com) from your Swift code. + +## Requirements + +- iOS 15+ / macOS 12+ / watchOS 8+ / tvOS 15+ / visionOS 1+ +- Swift 6.2+ / Xcode 26+ +- Swift Package Manager (CocoaPods is not supported) ## Installation -The easiest way to install TelemetryDeck is using [Swift Package Manager](https://www.swift.org/package-manager/), Apple's solution which is built into Xcode. In Xcode, press _File > Add Packages..._, then in the resulting window enter `https://github.com/TelemetryDeck/SwiftSDK` into the search field. Set the _Dependency Rule_ field to _Up to Next Major Version_, then press the _Add Package_ button. Xcode will download it, then you can choose which target of your app to add the "TelemetryDeck" library to (note that "TelemetryClient" is the old name of the lib). +In Xcode, press _File > Add Packages..._, then enter `https://github.com/TelemetryDeck/SwiftSDK` into the search field. Set the _Dependency Rule_ field to _Up to Next Major Version_, then press _Add Package_. Add the "TelemetryDeck" library to your app target. See our [detailed setup guide](https://telemetrydeck.com/docs/guides/swift-setup/?source=github) for more information. -## Initialization +## Quick Start -Init the Telemetry Manager at app startup, so it knows your App ID (you can retrieve the App ID from your [TelemetryDeck Dashboard](https://dashboard.telemetrydeck.com/) under Set Up App) - -```swift -let config = TelemetryDeck.Config(appID: "") -// optional: modify the config here -TelemetryDeck.initialize(config: config) -``` - -For example, if you're building a scene based app, in the `init()` function for your `App`: +Initialize TelemetryDeck at app startup with your App ID and namespace (both available in your [TelemetryDeck Dashboard](https://dashboard.telemetrydeck.com/) under Set Up App): ```swift import SwiftUI import TelemetryDeck @main -struct TelemetryTestApp: App { +struct MyApp: App { var body: some Scene { WindowGroup { ContentView() @@ -33,47 +31,65 @@ struct TelemetryTestApp: App { } init() { - // Note: Do not add this code to `WindowGroup.onAppear`, which will be called - // *after* your window has been initialized, and might lead to our initialization - // occurring too late. - let config = TelemetryDeck.Config(appID: "") - TelemetryDeck.initialize(config: config) + // Do not put this in WindowGroup.onAppear — it would run too late. + let config = TelemetryDeck.Config( + appID: "", + namespace: "" + ) + Task { try await TelemetryDeck.initialize(configuration: config) } } } ``` -Then send signals like so: +Then send events anywhere in your app: ```swift -TelemetryDeck.signal("App.launchedRegularly") +TelemetryDeck.event("App.launchedRegularly") ``` -## Debug -> Test Mode +That's it. TelemetryDeck automatically enriches every event with device info, OS version, app version, accessibility settings, and more. -If your app's build configuration is set to "Debug", all signals sent will be marked as testing signals. In the Telemetry Viewer app, activate **Test Mode** to see those. +## Sending Events -If you want to manually control whether test mode is active, you can set the `config.testMode` property. +`event()` works in both sync and async contexts — the compiler picks the right variant automatically: -## User Identifiers +```swift +TelemetryDeck.event("Settings.opened") // fire-and-forget +await TelemetryDeck.event("Settings.opened") // awaitable +``` -Telemetry Manager will create a user identifier for you user that is specific to app installation and device. If you have a better user identifier available, such as an email address or a username, you can use that instead, by passing it on to the `TelemetryDeck.Config` (the identifier will be hashed before sending it). +`event()` accepts `RawRepresentable` so you could also keep track of your event types using an enumeration: ```swift -config.defaultUser = "myuser@example.com" +enum AppEvent: String { + case launched = "App.launched" + case settingsOpened = "Settings.opened" +} + +TelemetryDeck.event(AppEvent.launched) ``` -You can update the configuration after TelemetryDeck is already initialized. +### Parameters + +Send additional metadata with each event using typed `EventParameters`. Values can be `String`, `Bool`, `Int`, `Double`, `Float`, `UUID`, or `Date`: -## Parameters +```swift +TelemetryDeck.event("Database.updated", parameters: [ + "entryCount": 3831, + "isCompacted": true +]) +``` -You can also send additional parameters with each signal: +### Float Values + +Attach a numeric measurement to any event: ```swift -TelemetryDeck.signal("Database.updated", parameters: ["numberOfDatabaseEntries": "3831"]) +TelemetryDeck.event("Upload.completed", floatValue: fileSize) ```
-TelemetryDeck will automatically send base parameters, expand to see common examples +TelemetryDeck automatically sends base parameters with every event (expand to see common examples) - TelemetryDeck.Accessibility.isBoldTextEnabled - TelemetryDeck.Accessibility.preferredContentSizeCategory @@ -105,37 +121,358 @@ TelemetryDeck.signal("Database.updated", parameters: ["numberOfDatabaseEntries": See our [related documentation page](https://telemetrydeck.com/docs/api/default-parameters/?source=github.com) for a full list.
+## User Identifiers + +TelemetryDeck generates a per-installation user identifier by default. If you have a better identifier (e.g. email or username), set it at any time — it will be hashed before transmission: + +```swift +await TelemetryDeck.setUserIdentifier("myuser@example.com") +``` + +Pass `nil` to revert to the default identifier. + ## Sessions -With each Signal, the client sends a hash of your user ID as well as a _session ID_. This gets automatically generated when the client is initialized, so if you do nothing, you'll get a new session each time your app is started from cold storage. +A session ID is automatically generated at initialization. On iOS, tvOS, and watchOS, the session updates whenever your app returns from the background. On other platforms, a new session starts each time the app launches. + +For manual session control: -On iOS, tvOS, and watchOS, the session identifier will automatically update whenever your app returns from background, or if it is launched from cold storage. On other platforms, a new identifier will be generated each time your app launches. If you'd like more fine-grained session support, write a new random session identifier into the `TelemetryDeck.Config`'s `sessionID` property each time a new session begins. +```swift +await TelemetryDeck.newSession() +``` -## Custom Salt +## Test Mode -By default, user identifiers are hashed by the TelemetryDeck SDK, and then sent to the Ingestion API, where we'll add a salt to the received identifier and hash it again. +In debug builds, all events are automatically marked as test events. View them in the TelemetryDeck dashboard by enabling **Test Mode**. -This is enough for most use cases, but if you want to extra privacy conscious, you can add in you own salt on the client side. The TelemetryDeck SDK will append the salt to all user identifers before hashing them and sending them to us. +## Disabling Analytics -If you'd like to use a custom salt, you can do so by passing it on to the `TelemetryDeck.Config` +Let users opt out of analytics collection: ```swift -let config = TelemetryDeck.Config(appID: "", salt: "") +await TelemetryDeck.setAnalyticsDisabled(true) ``` -## Custom Server +While disabled, all events are silently dropped. Check the current state with `await TelemetryDeck.isAnalyticsDisabled`. -A very small subset of our customers will want to use a custom signal ingestion server or a custom proxy server. To do so, you can pass the URL of the custom server to the `TelemetryDeck.Config`: +## Shutting Down + +To flush pending events and shut down the SDK: ```swift -let config = TelemetryDeck.Config(appID: "", baseURL: "https://nom.telemetrydeck.com") +await TelemetryDeck.terminate() ``` -## Custom Logging Strategy +
+Configuration Reference + +**`TelemetryDeck.Config`** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `appID` | `String` | (required) | Your app ID from the dashboard | +| `namespace` | `String` | (required) | Your namespace from the dashboard | +| `apiBaseURL` | `URL` | `https://nom.telemetrydeck.com` | Ingestion server URL | +| `salt` | `String` | `""` | Client-side salt for user ID hashing | + +**`TelemetryDeck.initialize()` parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `defaultUser` | `String?` | `nil` | Initial user identifier (hashed before transmission) | +| `testMode` | `Bool?` | `nil` | Force test mode on/off; `nil` auto-detects from build configuration | +| `eventPrefix` | `String?` | `nil` | Auto-prefix for all event names | +| `parameterPrefix` | `String?` | `nil` | Auto-prefix for all parameter keys | +| `sendSessionStartedEvent` | `Bool` | `true` | Send an event when a new session begins | +| `defaultParameters` | `EventParameters` | `[:]` | Parameters merged into every event | + +
+ +## Presets + +### Navigation Tracking + +Track screen views with the SwiftUI view modifier: + +```swift +ContentView() + .trackNavigation(path: "Home") +``` + +Or call it manually: + +```swift +await TelemetryDeck.navigationPathChanged(from: "Home", to: "Settings") +``` + +### Error Reporting + +```swift +await TelemetryDeck.errorOccurred( + id: "database-write-failure", + category: .thrownException, + message: error.localizedDescription +) +``` + +Use the `.with(id:)` extension to tag any `Error` with a stable identifier: + +```swift +catch { + await TelemetryDeck.errorOccurred(identifiableError: error.with(id: "sync-failed")) +} +``` + +### Duration Tracking + +Measure time spent on activities: + +```swift +await TelemetryDeck.startDurationEvent("Editor.session") +// ... user works ... +await TelemetryDeck.stopAndSendDurationEvent("Editor.session") +``` + +Use `includeBackgroundTime: false` (the default) to only count foreground time. Cancel without sending via `cancelDurationEvent(_:)`. + +### Purchase Tracking + +Track StoreKit transactions: + +```swift +await TelemetryDeck.purchaseCompleted(transaction: transaction) +``` + +Free trials are automatically detected and reported separately. Please note that we do not keep track of transactions - repeatedly calling this method will result in multiple events. + +### Pirate Metrics (AARRR) + +Track the full acquisition-to-revenue funnel: -By default, some logs helpful for monitoring TelemetryDeck are printed out to the console. This behaviour can be customised by overriding `config.logHandler`. This struct accepts a minimum allows log level (any log with the same or higher log level will be accepted) and a closure. +```swift +await TelemetryDeck.acquiredUser(channel: "organic-search") +await TelemetryDeck.onboardingCompleted() +await TelemetryDeck.coreFeatureUsed(featureName: "export") +await TelemetryDeck.referralSent(receiversCount: 3) +await TelemetryDeck.paywallShown(reason: "feature-gate") +``` + +## Advanced + +### Custom Salt + +For additional privacy, add your own salt to user identifier hashing: + +```swift +let config = TelemetryDeck.Config( + appID: "", + namespace: "", + salt: "
" +) +``` + +### Custom Server + +Use a custom ingestion server or proxy: + +```swift +let config = TelemetryDeck.Config( + appID: "", + namespace: "", + apiBaseURL: URL(string: "https://custom.example.com")! +) +``` + +### Custom Event Transmitter + +For fine-grained control over networking (e.g. certificate pinning or a custom proxy), create your own `DefaultEventTransmitter`: + +```swift +let config = TelemetryDeck.Config(appID: "", namespace: "") +let cache = DefaultEventCache() +let transmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: myCustomURLSession +) +try await TelemetryDeck.initialize( + configuration: config, + processors: TelemetryDeck.defaultProcessors(), + cache: cache, + transmitter: transmitter +) +``` + +You can also implement the `EventTransmitting` protocol for a fully custom transport layer. + +### Custom Logging + +Provide a custom logger conforming to the `Logging` protocol: + +```swift +try await TelemetryDeck.initialize( + configuration: config, + processors: TelemetryDeck.defaultProcessors(), + logger: myCustomLogger +) +``` + +### Default Event Processors + +Events pass through a pipeline of `EventProcessor` middleware before transmission. The SDK ships with these default processors, executed in order: + +| # | Processor | Purpose | +|---|-----------|---------| +| 1 | `PreviewFilterProcessor` | Drops events during SwiftUI previews | +| 2 | `DefaultParametersProcessor` | Merges `defaultParameters` into every event | +| 3 | `DefaultPrefixProcessor` | Applies `eventPrefix` and `parameterPrefix` | +| 4 | `ValidationProcessor` | Warns when event names or parameter keys use reserved `TelemetryDeck.*` identifiers | +| 5 | `TestModeProcessor` | Marks events as test events in debug builds (override with `TestModeProcessor(override: true/false)`) | +| 6 | `UserIdentifierProcessor` | Resolves and attaches the hashed user identifier | +| 7 | `SessionTrackingProcessor` | Manages session IDs, retention metrics, and new-install detection | +| 8 | `DeviceProcessor` | Adds device model, OS version, platform, timezone, simulator/TestFlight/App Store flags | +| 9 | `AppInfoProcessor` | Adds app version, build number, and SDK version | +| 10 | `LocaleProcessor` | Adds locale, language, and region | +| 11 | `CalendarProcessor` | Adds calendar context (day of week, hour, month, quarter, etc.) | +| 12 | `AccessibilityProcessor` | Adds accessibility settings (bold text, reduce motion, etc.) and screen metrics | +| 13 | `TrialConversionProcessor` | Detects free trial → paid subscription conversions via StoreKit | + +To exclude a specific processor, remove it from the default list before initializing: + +```swift +var processors = TelemetryDeck.defaultProcessors() +processors.removeAll { $0 is AccessibilityProcessor } +try await TelemetryDeck.initialize(configuration: config, processors: processors) +``` + +You can also build a processor list from scratch for full control over which processors run. + +### Custom Event Processors + +Add your own processors to enrich, filter, or transform events: + +```swift +struct MyProcessor: EventProcessor { + func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var ctx = context + ctx.addMetadata(key: "MyApp.subscriptionTier", value: "premium") + return try await next(input, ctx) + } +} +``` + +Pass a custom processor list at initialization: + +```swift +var processors = TelemetryDeck.defaultProcessors() +processors.append(MyProcessor()) +try await TelemetryDeck.initialize(configuration: config, processors: processors) +``` + +If you need parameters that are computed at runtime (e.g. values that depend on current app state), use a processor instead of `defaultParameters`: + +```swift +struct DynamicParametersProcessor: EventProcessor { + func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var ctx = context + ctx.addMetadata(key: "MyApp.itemCount", value: String(ItemStore.shared.count)) + return try await next(input, ctx) + } +} +``` + +### Default Parameters and Prefixes + +Attach parameters to every event, or auto-prefix all event names and parameter keys via the convenience initializer: + +```swift +try await TelemetryDeck.initialize( + appID: "", + namespace: "", + eventPrefix: "MyApp.", + parameterPrefix: "MyApp.", + defaultParameters: ["environment": "production"] +) +``` + +## Migrating from v2 + +### Breaking Changes + +| v2 | v3 | Notes | +|----|-----|-------| +| `TelemetryDeck.initialize(config:)` | `try await TelemetryDeck.initialize(configuration:)` | Now async throws | +| `TelemetryDeck.Config(appID:)` | `TelemetryDeck.Config(appID:, namespace:)` | `namespace` is now required | +| `TelemetryManagerConfiguration` | `TelemetryDeck.Config` | Type renamed | +| `config.defaultUser = "..."` | `await TelemetryDeck.setUserIdentifier("...")` | No longer on config | +| `config.testMode` | Removed | Now handled by `TestModeProcessor`. Automatic in debug builds; override with `TestModeProcessor(override: true)` | +| `config.logHandler` | Pass `logger:` to `initialize()` | `Logging` protocol | +| `config.urlSession` | Pass custom `EventTransmitting` | See Custom Event Transmitter | +| `config.sessionID = UUID()` | `await TelemetryDeck.newSession()` | | +| `config.defaultSignalPrefix` | `eventPrefix` parameter on `initialize()` | Moved from config to initializer | +| `config.defaultParameterPrefix` | `parameterPrefix` parameter on `initialize()` | Moved from config to initializer | +| `config.sendNewSessionBeganSignal` | `sendSessionStartedEvent` parameter on `initialize()` | Moved from config to initializer | +| `config.defaultParameters` (closure) | `defaultParameters` parameter on `initialize()` | Now `EventParameters`, not `() -> [String: String]` | +| `[String: String]` parameters | `EventParameters` | Typed values: `String`, `Bool`, `Int`, `Double`, etc. | +| `TelemetryManager.shared` | Removed | Use `TelemetryDeck.*` static API | +| `requestImmediateSync()` | `await TelemetryDeck.flush()` | | +| `generateNewSession()` | `await TelemetryDeck.newSession()` | Now returns `UUID?` | +| `updateDefaultUserID(to:)` | `await TelemetryDeck.setUserIdentifier(_:)` | | +| `metadataEnrichers` / `SignalEnricher` | `EventProcessor` protocol | See Default Event Processors | +| `sendSignalsInDebugConfiguration` | Removed | Was already deprecated | +| CocoaPods | Removed | SPM only | +| `TelemetryClient` ObjC target | Removed | | +| iOS 12 / macOS 10.13 / watchOS 5 / tvOS 13 | iOS 15 / macOS 12 / watchOS 8 / tvOS 15 | Platform minimums raised | + +### Before and After + +**v2:** + +```swift +let config = TelemetryDeck.Config(appID: "") +config.defaultUser = "user@example.com" +TelemetryDeck.initialize(config: config) + +TelemetryDeck.signal("App.launched") +``` + +**v3:** + +```swift +let config = TelemetryDeck.Config(appID: "", namespace: "") +Task { + try await TelemetryDeck.initialize(configuration: config) + await TelemetryDeck.setUserIdentifier("user@example.com") +} + +TelemetryDeck.event("App.launched") +``` -This allows for compatibility with other logging solutions, such as [swift-log](https://github.com/apple/swift-log), by providing your own closure. +### Step-by-Step Migration + +1. Update your minimum deployment targets to iOS 15 / macOS 12 / watchOS 8 / tvOS 15 +2. Switch to Swift Package Manager if you were using CocoaPods +3. Add `namespace:` to your `Config` initializer (get it from the dashboard under Set Up App) +4. Wrap `initialize()` in `Task { try await ... }` +5. Move `config.defaultUser` to `await TelemetryDeck.setUserIdentifier(...)` +6. Remove `config.testMode` — test mode is now automatic in debug builds via `TestModeProcessor`. To force test mode on/off, replace it in the processor list with `TestModeProcessor(override: true/false)` +7. Move `config.defaultSignalPrefix` and `config.defaultParameterPrefix` to `eventPrefix:` and `parameterPrefix:` parameters on `initialize()` +8. Move `config.sendNewSessionBeganSignal` to `sendSessionStartedEvent:` parameter on `initialize()` +9. Replace `requestImmediateSync()` with `await TelemetryDeck.flush()` +10. Replace any `TelemetryManager.shared` usage with `TelemetryDeck.*` static methods +11. If using custom `SignalEnricher`s, migrate to the `EventProcessor` protocol (see Custom Event Processors) +12. If your `defaultParameters` closure computed values at runtime, migrate to a custom `EventProcessor` that reads the current state in its `process` method (see Custom Event Processors) + +Replace `TelemetryDeck.signal(...)` calls with `TelemetryDeck.event(...)` — the call sites require no other changes. ## Developing this SDK diff --git a/Sources/TelemetryClient/Exports.swift b/Sources/TelemetryClient/Exports.swift deleted file mode 100644 index 3b0a639..0000000 --- a/Sources/TelemetryClient/Exports.swift +++ /dev/null @@ -1,2 +0,0 @@ -// This file ensures there's a target named `TelemetryClient` so `import TelemetryClient` is enough even after renaming the library to `TelemetryDeck`. -@_exported import TelemetryDeck diff --git a/Sources/TelemetryClient/PrivacyInfo.xcprivacy b/Sources/TelemetryClient/PrivacyInfo.xcprivacy deleted file mode 100644 index 1327dc6..0000000 --- a/Sources/TelemetryClient/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,46 +0,0 @@ - - - - - NSPrivacyAccessedAPITypes - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryUserDefaults - NSPrivacyAccessedAPITypeReasons - - CA92.1 - - - - NSPrivacyCollectedDataTypes - - - NSPrivacyCollectedDataType - NSPrivacyCollectedDataTypeProductInteraction - NSPrivacyCollectedDataTypeLinked - - NSPrivacyCollectedDataTypeTracking - - NSPrivacyCollectedDataTypePurposes - - NSPrivacyCollectedDataTypePurposeAnalytics - - - - NSPrivacyCollectedDataType - NSPrivacyCollectedDataTypeDeviceID - NSPrivacyCollectedDataTypeLinked - - NSPrivacyCollectedDataTypeTracking - - NSPrivacyCollectedDataTypePurposes - - NSPrivacyCollectedDataTypePurposeAnalytics - - - - NSPrivacyTracking - - - diff --git a/Sources/TelemetryClient/TelemetryClient+ObjC.swift b/Sources/TelemetryClient/TelemetryClient+ObjC.swift deleted file mode 100644 index 94f9dc7..0000000 --- a/Sources/TelemetryClient/TelemetryClient+ObjC.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation -import TelemetryDeck - -@objc(TelemetryManagerConfiguration) -public final class TelemetryManagerConfigurationObjCProxy: NSObject { - fileprivate var telemetryDeckConfiguration: TelemetryDeck.Config - - @objc public init(appID: String, salt: String, baseURL: URL) { - telemetryDeckConfiguration = TelemetryDeck.Config(appID: appID, salt: salt, baseURL: baseURL) - } - - @objc public init(appID: String, baseURL: URL) { - telemetryDeckConfiguration = TelemetryDeck.Config(appID: appID, baseURL: baseURL) - } - - @objc public init(appID: String, salt: String) { - telemetryDeckConfiguration = TelemetryDeck.Config(appID: appID, salt: salt) - } - - @objc public init(appID: String) { - telemetryDeckConfiguration = TelemetryDeck.Config(appID: appID) - } - - @objc public var sendNewSessionBeganSignal: Bool { - get { - telemetryDeckConfiguration.sendNewSessionBeganSignal - } - - set { - telemetryDeckConfiguration.sendNewSessionBeganSignal = newValue - } - } - - @objc public var testMode: Bool { - get { - telemetryDeckConfiguration.testMode - } - - set { - telemetryDeckConfiguration.testMode = newValue - } - } - - @objc public var analyticsDisabled: Bool { - get { - telemetryDeckConfiguration.analyticsDisabled - } - - set { - telemetryDeckConfiguration.analyticsDisabled = newValue - } - } -} - -@objc(TelemetryManager) -public final class TelemetryManagerObjCProxy: NSObject { - @objc public static func initialize(with configuration: TelemetryManagerConfigurationObjCProxy) { - TelemetryDeck.initialize(config: configuration.telemetryDeckConfiguration) - } - - @objc public static func terminate() { - TelemetryDeck.terminate() - } - - @objc public static func send(_ signalName: String, for clientUser: String? = nil, with additionalPayload: [String: String] = [:]) { - TelemetryDeck.signal(signalName, parameters: additionalPayload, customUserID: clientUser) - } - - @objc public static func send(_ signalName: String, with additionalPayload: [String: String] = [:]) { - TelemetryDeck.signal(signalName, parameters: additionalPayload) - } - - @objc public static func send(_ signalName: String) { - TelemetryDeck.signal(signalName) - } - - @objc public static func updateDefaultUser(to newDefaultUser: String?) { - TelemetryDeck.updateDefaultUserID(to: newDefaultUser) - } - - @objc public static func generateNewSession() { - TelemetryDeck.generateNewSession() - } -} diff --git a/Sources/TelemetryClient/TelemetryClient.h b/Sources/TelemetryClient/TelemetryClient.h deleted file mode 100644 index 1a66c98..0000000 --- a/Sources/TelemetryClient/TelemetryClient.h +++ /dev/null @@ -1,10 +0,0 @@ -#import - -//! Project version number for TelemetryClient. -FOUNDATION_EXPORT double TelemetryClientVersionNumber; - -//! Project version string for TelemetryClient. -FOUNDATION_EXPORT const unsigned char TelemetryClientVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import -#import diff --git a/Sources/TelemetryDeck/Capabilities/DurationTracking.swift b/Sources/TelemetryDeck/Capabilities/DurationTracking.swift new file mode 100644 index 0000000..bba171d --- /dev/null +++ b/Sources/TelemetryDeck/Capabilities/DurationTracking.swift @@ -0,0 +1,22 @@ +import Foundation + +/// The result of a completed duration measurement. +public struct DurationResult: Sendable { + /// The elapsed time in seconds. + public let durationInSeconds: TimeInterval + /// The parameters that were recorded when the duration measurement started. + public let startParameters: EventParameters +} + +/// Tracks elapsed time for named events, optionally excluding background time. +public protocol DurationTracking: Sendable { + func startDuration( + _ eventName: String, + parameters: EventParameters, + includeBackgroundTime: Bool + ) async + + func stopDuration(_ eventName: String) async -> DurationResult? + + func cancelDuration(_ eventName: String) async +} diff --git a/Sources/TelemetryDeck/Capabilities/SessionManaging.swift b/Sources/TelemetryDeck/Capabilities/SessionManaging.swift new file mode 100644 index 0000000..e0932a6 --- /dev/null +++ b/Sources/TelemetryDeck/Capabilities/SessionManaging.swift @@ -0,0 +1,7 @@ +import Foundation + +/// An event processor that also exposes session management capabilities. +public protocol SessionManaging: EventProcessor { + func currentSessionID() async -> UUID + func startNewSession() async -> UUID +} diff --git a/Sources/TelemetryDeck/Capabilities/TestModeProviding.swift b/Sources/TelemetryDeck/Capabilities/TestModeProviding.swift new file mode 100644 index 0000000..4835048 --- /dev/null +++ b/Sources/TelemetryDeck/Capabilities/TestModeProviding.swift @@ -0,0 +1,6 @@ +import Foundation + +/// An event processor that can report whether the SDK is currently operating in test mode. +public protocol TestModeProviding: EventProcessor { + func isTestMode() async -> Bool +} diff --git a/Sources/TelemetryDeck/Capabilities/UserIdentifierManaging.swift b/Sources/TelemetryDeck/Capabilities/UserIdentifierManaging.swift new file mode 100644 index 0000000..7a413fc --- /dev/null +++ b/Sources/TelemetryDeck/Capabilities/UserIdentifierManaging.swift @@ -0,0 +1,7 @@ +import Foundation + +/// An event processor that manages the current user identifier. +public protocol UserIdentifierManaging: EventProcessor { + func currentUserIdentifier() async -> String? + func setUserIdentifier(_ value: String?) async +} diff --git a/Sources/TelemetryDeck/Core/Config.swift b/Sources/TelemetryDeck/Core/Config.swift new file mode 100644 index 0000000..97c1d44 --- /dev/null +++ b/Sources/TelemetryDeck/Core/Config.swift @@ -0,0 +1,44 @@ +import Foundation + +extension TelemetryDeck { + /// Configuration for the TelemetryDeck SDK, specifying the app identity and transport settings. + public struct Config: Sendable { + /// The TelemetryDeck app identifier from the dashboard. + public let appID: String + /// The server-side namespace used for API routing (appears in the ingestion URL path). + public let namespace: String + /// The base URL of the TelemetryDeck ingestion API. + public let apiBaseURL: URL + /// A salt value appended to user identifiers before hashing for additional privacy. + public let salt: String + + /// Creates a configuration with the given app identity and optional transport overrides. + public init( + appID: String, + namespace: String, + apiBaseURL: URL = URL(string: "https://nom.telemetrydeck.com")!, + salt: String = "" + ) { + self.appID = appID + self.namespace = namespace + self.apiBaseURL = apiBaseURL + self.salt = salt + } + + func validate() throws(TelemetryDeckError) { + guard !appID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw TelemetryDeckError( + code: .invalidConfiguration, + localizedDescription: "appID must not be empty. Get your app ID from the TelemetryDeck dashboard." + ) + } + + guard !namespace.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw TelemetryDeckError( + code: .invalidConfiguration, + localizedDescription: "namespace must not be empty." + ) + } + } + } +} diff --git a/Sources/TelemetryDeck/Core/Event.swift b/Sources/TelemetryDeck/Core/Event.swift new file mode 100644 index 0000000..c5e96d4 --- /dev/null +++ b/Sources/TelemetryDeck/Core/Event.swift @@ -0,0 +1,42 @@ +import Foundation + +/// A finalized event ready for transmission to the TelemetryDeck ingestion API. +public struct Event: Sendable, Codable { + /// The TelemetryDeck app identifier. + public let appID: String + /// The event name. + public let type: String + /// The hashed user identifier. + public let clientUser: String + /// The session identifier associated with this event. + public let sessionID: String? + /// The timestamp at which the event was recorded on the client. + public let receivedAt: Date + /// The enriched parameter payload. + public let payload: [String: PayloadValue] + /// An optional numeric value associated with the event. + public let floatValue: Double? + /// Indicates whether this event is a test-mode event ("true" or "false"). + public let isTestMode: String + + /// Creates an event with the given fields, converting the boolean test mode flag to a string. + public init( + appID: String, + type: String, + clientUser: String, + sessionID: String?, + receivedAt: Date, + payload: [String: PayloadValue], + floatValue: Double?, + isTestMode: Bool + ) { + self.appID = appID + self.type = type + self.clientUser = clientUser + self.sessionID = sessionID + self.receivedAt = receivedAt + self.payload = payload + self.floatValue = floatValue + self.isTestMode = isTestMode ? "true" : "false" + } +} diff --git a/Sources/TelemetryDeck/Core/EventContext.swift b/Sources/TelemetryDeck/Core/EventContext.swift new file mode 100644 index 0000000..a6b71d9 --- /dev/null +++ b/Sources/TelemetryDeck/Core/EventContext.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Mutable context passed through the processor pipeline, accumulating enrichment data for an event. +public struct EventContext: Sendable { + /// The session identifier to attach to the event. + public var sessionID: UUID? + /// The resolved user identifier for the event. + public var userIdentifier: String? + /// Whether the event should be marked as test-mode. + public var isTestMode: Bool? + + /// Accumulated metadata parameters added by processors. + public private(set) var metadata: EventParameters + + /// Creates an empty context. + public init() { + self.metadata = [:] + } + + /// Adds a single parameter to the metadata using a string key. + public mutating func addParameter(_ key: String, value: any ParameterValue) { + metadata[key] = value + } + + /// Adds a single parameter to the metadata using a raw-representable key. + public mutating func addParameter(_ key: K, value: any ParameterValue) where K.RawValue == String { + metadata[key.rawValue] = value + } + + /// Removes a parameter from the metadata by key. + public mutating func removeParameter(_ key: String) { + metadata[key] = nil + } + + /// Merges the given `EventParameters` into the metadata, overwriting existing keys. + public mutating func addParameters(_ params: EventParameters) { + metadata.merge(params) + } + + /// Merges the given string dictionary into the metadata, overwriting existing keys. + public mutating func addParameters(_ params: [String: String]) { + metadata.merge(params) + } +} diff --git a/Sources/TelemetryDeck/Core/EventInput.swift b/Sources/TelemetryDeck/Core/EventInput.swift new file mode 100644 index 0000000..751b332 --- /dev/null +++ b/Sources/TelemetryDeck/Core/EventInput.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Raw event data provided by the caller before processing through the pipeline. +public struct EventInput: Sendable { + /// The event name. + public var name: String + /// Caller-supplied parameters to attach to the event. + public var parameters: EventParameters + /// An optional numeric value associated with the event. + public var floatValue: Double? + /// An optional user identifier that overrides the default for this event only. + public var customUserID: String? + /// The time at which the event was created. + public let timestamp: Date + /// When true, the validation processor skips reserved-prefix checks for this event. + public var skipsReservedPrefixValidation: Bool + + /// Creates an event input with the given name, optional parameters, float value, and user override. + public init( + _ name: String, + parameters: EventParameters = [:], + floatValue: Double? = nil, + customUserID: String? = nil, + skipsReservedPrefixValidation: Bool = false + ) { + self.name = name + self.parameters = parameters + self.floatValue = floatValue + self.customUserID = customUserID + self.timestamp = Date() + self.skipsReservedPrefixValidation = skipsReservedPrefixValidation + } +} diff --git a/Sources/TelemetryDeck/Core/EventParameters.swift b/Sources/TelemetryDeck/Core/EventParameters.swift new file mode 100644 index 0000000..e248822 --- /dev/null +++ b/Sources/TelemetryDeck/Core/EventParameters.swift @@ -0,0 +1,69 @@ +import Foundation + +/// A typed collection of event parameters keyed by string. +public struct EventParameters: Sendable, ExpressibleByDictionaryLiteral, Sequence { + private var storage: [String: any ParameterValue] + + /// Creates an empty parameters collection. + public init() { + self.storage = [:] + } + + /// Creates a parameters collection from a dictionary literal. + public init(dictionaryLiteral elements: (String, any ParameterValue)...) { + self.storage = Dictionary(uniqueKeysWithValues: elements) + } + + /// Creates a parameters collection from a plain string dictionary. + public init(_ dictionary: [String: String]) { + self.storage = dictionary + } + + /// Creates a parameters collection from a typed payload dictionary. + public init(_ dictionary: [String: PayloadValue]) { + self.storage = dictionary + } + + /// Accesses a parameter value by string key. + public subscript(key: String) -> (any ParameterValue)? { + get { storage[key] } + set { storage[key] = newValue } + } + + /// Accesses a parameter value by raw-representable key. + public subscript(key: K) -> (any ParameterValue)? where K.RawValue == String { + get { storage[key.rawValue] } + set { storage[key.rawValue] = newValue } + } + + /// Merges another `EventParameters` collection into this one, overwriting existing keys. + public mutating func merge(_ other: EventParameters) { + for (key, value) in other.storage { + storage[key] = value + } + } + + /// Merges a plain string dictionary into this collection, overwriting existing keys. + public mutating func merge(_ other: [String: String]) { + for (key, value) in other { + storage[key] = value + } + } + + /// All parameters converted to a typed `[String: PayloadValue]` dictionary. + public var payloadDictionary: [String: PayloadValue] { + storage.mapValues { $0.payloadValue } + } + + /// The number of parameters in the collection. + public var count: Int { storage.count } + /// Whether the collection contains no parameters. + public var isEmpty: Bool { storage.isEmpty } + /// The keys present in the collection. + public var keys: Dictionary.Keys { storage.keys } + + /// Returns an iterator over the key-value pairs. + public func makeIterator() -> Dictionary.Iterator { + storage.makeIterator() + } +} diff --git a/Sources/TelemetryDeck/Core/ParameterValue.swift b/Sources/TelemetryDeck/Core/ParameterValue.swift new file mode 100644 index 0000000..66bd4d2 --- /dev/null +++ b/Sources/TelemetryDeck/Core/ParameterValue.swift @@ -0,0 +1,67 @@ +import Foundation + +/// A type that can be serialised as a parameter value in an event payload. +public protocol ParameterValue: Sendable { + /// The typed payload representation of this value. + var payloadValue: PayloadValue { get } +} + +extension String: ParameterValue { + /// Returns a string payload value. + public var payloadValue: PayloadValue { .string(self) } +} + +extension Bool: ParameterValue { + /// Returns a boolean payload value. + public var payloadValue: PayloadValue { .bool(self) } +} + +extension Int: ParameterValue { + /// Returns an integer payload value. + public var payloadValue: PayloadValue { .int(Int64(self)) } +} + +extension Int64: ParameterValue { + /// Returns an integer payload value. + public var payloadValue: PayloadValue { .int(self) } +} + +extension Int32: ParameterValue { + /// Returns an integer payload value. + public var payloadValue: PayloadValue { .int(Int64(self)) } +} + +extension UInt: ParameterValue { + /// Returns an integer payload value. + public var payloadValue: PayloadValue { .int(Int64(self)) } +} + +extension UInt64: ParameterValue { + /// Returns an integer payload value, clamped to `Int64` range. + public var payloadValue: PayloadValue { .int(Int64(clamping: self)) } +} + +extension Double: ParameterValue { + /// Returns a double payload value. + public var payloadValue: PayloadValue { .double(self) } +} + +extension Float: ParameterValue { + /// Returns a double payload value. + public var payloadValue: PayloadValue { .double(Double(self)) } +} + +extension UUID: ParameterValue { + /// Returns a string payload value containing the UUID string. + public var payloadValue: PayloadValue { .string(uuidString) } +} + +extension Date: ParameterValue { + /// Returns a string payload value containing the ISO 8601 date. + public var payloadValue: PayloadValue { .string(ISO8601DateFormatter().string(from: self)) } +} + +extension PayloadValue: ParameterValue { + /// Returns itself as the payload value. + public var payloadValue: PayloadValue { self } +} diff --git a/Sources/TelemetryDeck/Core/PayloadValue.swift b/Sources/TelemetryDeck/Core/PayloadValue.swift new file mode 100644 index 0000000..13ba5f8 --- /dev/null +++ b/Sources/TelemetryDeck/Core/PayloadValue.swift @@ -0,0 +1,80 @@ +import Foundation + +/// A typed payload value that encodes as a native JSON type. +public enum PayloadValue: Sendable, Codable, Equatable, Hashable { + case string(String) + case int(Int64) + case double(Double) + case bool(Bool) + + /// Decodes from a single JSON value, preserving integer and double distinctions. + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let boolValue = try? container.decode(Bool.self) { + self = .bool(boolValue) + } else if let intValue = try? container.decode(Int64.self) { + self = .int(intValue) + } else if let doubleValue = try? container.decode(Double.self) { + self = .double(doubleValue) + } else if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode PayloadValue") + } + } + + /// Encodes as a native JSON value. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + } + } +} + +extension PayloadValue: CustomStringConvertible { + /// A human-readable representation of the underlying value. + public var description: String { + switch self { + case .string(let value): value + case .int(let value): String(value) + case .double(let value): String(value) + case .bool(let value): value ? "true" : "false" + } + } +} + +extension PayloadValue: ExpressibleByStringLiteral { + /// Creates a string payload value from a string literal. + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension PayloadValue: ExpressibleByIntegerLiteral { + /// Creates an integer payload value from an integer literal. + public init(integerLiteral value: Int64) { + self = .int(value) + } +} + +extension PayloadValue: ExpressibleByFloatLiteral { + /// Creates a double payload value from a float literal. + public init(floatLiteral value: Double) { + self = .double(value) + } +} + +extension PayloadValue: ExpressibleByBooleanLiteral { + /// Creates a boolean payload value from a boolean literal. + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} diff --git a/Sources/TelemetryDeck/Core/ProcessorStorage.swift b/Sources/TelemetryDeck/Core/ProcessorStorage.swift new file mode 100644 index 0000000..9d4e2b9 --- /dev/null +++ b/Sources/TelemetryDeck/Core/ProcessorStorage.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Persistent key-value storage used by event processors to save and restore state between launches. +public protocol ProcessorStorage: Sendable { + func data(forKey key: String) async -> Data? + func set(_ data: Data?, forKey key: String) async + func string(forKey key: String) async -> String? + func set(_ value: String?, forKey key: String) async + func integer(forKey key: String) async -> Int + func set(_ value: Int, forKey key: String) async + func bool(forKey key: String) async -> Bool + func set(_ value: Bool, forKey key: String) async + /// Returns a string array for the given key; used internally for v2 data migration. + func stringArray(forKey key: String) async -> [String]? +} + +extension ProcessorStorage { + /// Returns `nil` by default; overridden by `UserDefaultsProcessorStorage` to read plist arrays. + public func stringArray(forKey key: String) async -> [String]? { nil } +} diff --git a/Sources/TelemetryDeck/Core/TelemetryDeckError.swift b/Sources/TelemetryDeck/Core/TelemetryDeckError.swift new file mode 100644 index 0000000..1c5be99 --- /dev/null +++ b/Sources/TelemetryDeck/Core/TelemetryDeckError.swift @@ -0,0 +1,52 @@ +import Foundation + +/// An error thrown by the TelemetryDeck SDK. +public struct TelemetryDeckError: Error, LocalizedError, CustomDebugStringConvertible, CustomNSError, Sendable { + /// Identifies the category of error. + public enum Code: Int, Sendable { + case invalidConfiguration = 1001 + } + + /// The specific error code. + public let code: Code + private let _localizedDescription: String + + init(code: Code, localizedDescription: String) { + self.code = code + self._localizedDescription = localizedDescription + } + + /// A localised, human-readable description of the error. + public var errorDescription: String? { + _localizedDescription + } + + /// A debug description including the error code and message. + public var debugDescription: String { + "TelemetryDeckError.\(code) (\(code.rawValue)): \(_localizedDescription)" + } + + /// The NSError domain for TelemetryDeck errors. + public static var errorDomain: String { + "TelemetryDeck" + } + + /// The integer error code used when bridging to NSError. + public var errorCode: Int { + code.rawValue + } + + /// The NSError user info dictionary. + public var errorUserInfo: [String: Any] { + [NSLocalizedDescriptionKey: _localizedDescription] + } + +} + +extension TelemetryDeckError.Code { + /// Allows using a `Code` value in a `catch` pattern to match a thrown `TelemetryDeckError`. + public static func ~= (match: Self, error: E) -> Bool { + guard let telemetryError = error as? TelemetryDeckError else { return false } + return telemetryError.code == match + } +} diff --git a/Sources/TelemetryDeck/Deprecated/DeprecatedPresets.swift b/Sources/TelemetryDeck/Deprecated/DeprecatedPresets.swift new file mode 100644 index 0000000..eaffb6a --- /dev/null +++ b/Sources/TelemetryDeck/Deprecated/DeprecatedPresets.swift @@ -0,0 +1,320 @@ +import Foundation +import SwiftUI + +// MARK: - Errors + +extension TelemetryDeck { + /// Sends an error event with the given identifier, optional category, message, and additional parameters. + @available(*, deprecated, message: "Use 'await TelemetryDeck.errorOccurred(id:category:message:parameters:floatValue:customUserID:)' instead") + public static func errorOccurred( + id: String, + category: ErrorCategory? = nil, + message: String? = nil, + parameters: [String: String] = [:], + floatValue: Double? = nil, + customUserID: String? = nil + ) { + Task { + await errorOccurred( + id: id, + category: category, + message: message, + parameters: EventParameters(parameters), + floatValue: floatValue, + customUserID: customUserID + ) + } + } + + /// Sends an error event for the given `IdentifiableError`, using its localised description as the message. + @available(*, deprecated, message: "Use 'await TelemetryDeck.errorOccurred(identifiableError:category:parameters:)' instead") + public static func errorOccurred( + identifiableError: IdentifiableError, + category: ErrorCategory = .thrownException, + parameters: [String: String] = [:], + floatValue: Double? = nil, + customUserID: String? = nil + ) { + Task { + await errorOccurred( + identifiableError: identifiableError, + category: category, + parameters: EventParameters(parameters), + floatValue: floatValue, + customUserID: customUserID + ) + } + } + + /// Sends an error event for the given `IdentifiableError` with an explicit optional message override. + @_disfavoredOverload + @available(*, deprecated, message: "Use 'await TelemetryDeck.errorOccurred(identifiableError:category:message:parameters:)' instead") + public static func errorOccurred( + identifiableError: IdentifiableError, + category: ErrorCategory = .thrownException, + message: String? = nil, + parameters: [String: String] = [:], + floatValue: Double? = nil, + customUserID: String? = nil + ) { + Task { + await errorOccurred( + identifiableError: identifiableError, + category: category, + message: message, + parameters: EventParameters(parameters), + floatValue: floatValue, + customUserID: customUserID + ) + } + } +} + +// MARK: - Acquisition + +extension TelemetryDeck { + /// Sends an acquisition event recording the channel through which this user was acquired. + @available(*, deprecated, message: "Use 'await TelemetryDeck.acquiredUser(channel:parameters:customUserID:)' instead") + public static func acquiredUser( + channel: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + Task { + await acquiredUser( + channel: channel, + parameters: EventParameters(parameters), + customUserID: customUserID + ) + } + } + + /// Sends an event indicating that a lead funnel has started for the given lead identifier. + @available(*, deprecated, message: "Use 'await TelemetryDeck.leadStarted(leadID:parameters:customUserID:)' instead") + public static func leadStarted( + leadID: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + Task { + await leadStarted( + leadID: leadID, + parameters: EventParameters(parameters), + customUserID: customUserID + ) + } + } + + /// Sends an event indicating that a lead has converted for the given lead identifier. + @available(*, deprecated, message: "Use 'await TelemetryDeck.leadConverted(leadID:parameters:customUserID:)' instead") + public static func leadConverted( + leadID: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + Task { + await leadConverted( + leadID: leadID, + parameters: EventParameters(parameters), + customUserID: customUserID + ) + } + } +} + +// MARK: - Activation + +extension TelemetryDeck { + /// Sends an event indicating that the user has completed onboarding. + @available(*, deprecated, message: "Use 'await TelemetryDeck.onboardingCompleted(parameters:customUserID:)' instead") + public static func onboardingCompleted( + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + Task { + await onboardingCompleted( + parameters: EventParameters(parameters), + customUserID: customUserID + ) + } + } + + /// Sends an event indicating that the user engaged with a core feature. + @available(*, deprecated, message: "Use 'await TelemetryDeck.coreFeatureUsed(featureName:parameters:customUserID:)' instead") + public static func coreFeatureUsed( + featureName: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + Task { + await coreFeatureUsed( + featureName: featureName, + parameters: EventParameters(parameters), + customUserID: customUserID + ) + } + } +} + +// MARK: - Referral + +extension TelemetryDeck { + /// Sends an event recording that the user sent a referral to one or more recipients. + @available(*, deprecated, message: "Use 'await TelemetryDeck.referralSent(receiversCount:kind:parameters:customUserID:)' instead") + public static func referralSent( + receiversCount: Int = 1, + kind: String? = nil, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + Task { + await referralSent( + receiversCount: receiversCount, + kind: kind, + parameters: EventParameters(parameters), + customUserID: customUserID + ) + } + } + + /// Sends an event recording a user-submitted rating (0–10) and optional comment. + @available(*, deprecated, message: "Use 'await TelemetryDeck.userRatingSubmitted(rating:comment:parameters:customUserID:)' instead") + public static func userRatingSubmitted( + rating: Int, + comment: String? = nil, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + Task { + await userRatingSubmitted( + rating: rating, + comment: comment, + parameters: EventParameters(parameters), + customUserID: customUserID + ) + } + } +} + +// MARK: - Revenue + +extension TelemetryDeck { + /// Sends an event indicating that a paywall was shown to the user, including the reason it appeared. + @available(*, deprecated, message: "Use 'await TelemetryDeck.paywallShown(reason:parameters:customUserID:)' instead") + public static func paywallShown( + reason: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + Task { + await paywallShown( + reason: reason, + parameters: EventParameters(parameters), + customUserID: customUserID + ) + } + } +} + +// MARK: - Navigation + +extension TelemetryDeck { + /// Sends a navigation event recording a transition from `source` to `destination`. + @available(*, deprecated, message: "Use 'await TelemetryDeck.navigationPathChanged(from:to:customUserID:)' instead") + public static func navigationPathChanged( + from source: String, + to destination: String, + customUserID: String? = nil + ) { + Task { + await navigationPathChanged( + from: source, + to: destination, + customUserID: customUserID + ) + } + } + + /// Sends a navigation event to `destination`, using the last recorded path as the source. + @available(*, deprecated, message: "Use 'await TelemetryDeck.navigationPathChanged(to:customUserID:)' instead") + public static func navigationPathChanged( + to destination: String, + customUserID: String? = nil + ) { + Task { + await navigationPathChanged( + to: destination, + customUserID: customUserID + ) + } + } +} + +// MARK: - Duration Tracking + +extension TelemetryDeck { + /// Starts tracking a duration event with the given name. + @available(*, deprecated, renamed: "startDurationEvent") + public static func startDurationSignal( + _ signalName: String, + parameters: [String: String] = [:], + includeBackgroundTime: Bool = false + ) { + Task { + await startDurationEvent( + signalName, + parameters: EventParameters(parameters), + includeBackgroundTime: includeBackgroundTime + ) + } + } + + /// Stops tracking a duration event and sends the resulting signal. + @available(*, deprecated, renamed: "stopAndSendDurationEvent") + public static func stopAndSendDurationSignal( + _ signalName: String, + parameters: [String: String] = [:], + floatValue _: Double? = nil, + customUserID _: String? = nil + ) { + Task { + await stopAndSendDurationEvent( + signalName, + parameters: EventParameters(parameters) + ) + } + } + + /// Cancels an in-progress duration event without sending a signal. + @available(*, deprecated, renamed: "cancelDurationEvent") + public static func cancelDurationSignal(_ signalName: String) { + Task { + await cancelDurationEvent(signalName) + } + } +} + +// MARK: - Purchases + +#if canImport(StoreKit) + import StoreKit + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension TelemetryDeck { + /// Sends a purchase event for the given StoreKit transaction, automatically handling free trials. + @available(*, deprecated, message: "Use 'await TelemetryDeck.purchaseCompleted(transaction:parameters:customUserID:)' instead") + public static func purchaseCompleted( + transaction: StoreKit.Transaction, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + Task { + await purchaseCompleted( + transaction: transaction, + parameters: EventParameters(parameters), + customUserID: customUserID + ) + } + } + } +#endif diff --git a/Sources/TelemetryDeck/Deprecated/DeprecatedSignal.swift b/Sources/TelemetryDeck/Deprecated/DeprecatedSignal.swift new file mode 100644 index 0000000..3d85831 --- /dev/null +++ b/Sources/TelemetryDeck/Deprecated/DeprecatedSignal.swift @@ -0,0 +1,19 @@ +import Foundation + +extension TelemetryDeck { + /// Sends a signal with the given name and optional parameters. + @available(*, deprecated, renamed: "event") + public static func signal( + _ signalName: String, + parameters: [String: String] = [:], + floatValue: Double? = nil, + customUserID: String? = nil + ) { + event( + signalName, + parameters: EventParameters(parameters), + floatValue: floatValue, + customUserID: customUserID + ) + } +} diff --git a/Sources/TelemetryDeck/Events/DefaultEvents.swift b/Sources/TelemetryDeck/Events/DefaultEvents.swift new file mode 100644 index 0000000..672de28 --- /dev/null +++ b/Sources/TelemetryDeck/Events/DefaultEvents.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Typed event name constants for SDK-emitted events. +public enum DefaultEvents { + /// Session lifecycle events. + public enum Session: String { + case started = "TelemetryDeck.Session.started" + } + + /// Navigation tracking events. + public enum Navigation: String { + case pathChanged = "TelemetryDeck.Navigation.pathChanged" + } + + /// Acquisition funnel events. + public enum Acquisition: String { + case userAcquired = "TelemetryDeck.Acquisition.userAcquired" + case leadStarted = "TelemetryDeck.Acquisition.leadStarted" + case leadConverted = "TelemetryDeck.Acquisition.leadConverted" + case newInstallDetected = "TelemetryDeck.Acquisition.newInstallDetected" + } + + /// Activation funnel events. + public enum Activation: String { + case onboardingCompleted = "TelemetryDeck.Activation.onboardingCompleted" + case coreFeatureUsed = "TelemetryDeck.Activation.coreFeatureUsed" + } + + /// Referral and rating events. + public enum Referral: String { + case sent = "TelemetryDeck.Referral.sent" + case userRatingSubmitted = "TelemetryDeck.Referral.userRatingSubmitted" + } + + /// Revenue-related events. + public enum Revenue: String { + case paywallShown = "TelemetryDeck.Revenue.paywallShown" + } + + /// In-app purchase events. + public enum Purchase: String { + case completed = "TelemetryDeck.Purchase.completed" + case freeTrialStarted = "TelemetryDeck.Purchase.freeTrialStarted" + case convertedFromTrial = "TelemetryDeck.Purchase.convertedFromTrial" + } + + /// Error reporting events. + public enum Error: String { + case occurred = "TelemetryDeck.Error.occurred" + } +} diff --git a/Sources/TelemetryDeck/Helpers/CryptoHashing.swift b/Sources/TelemetryDeck/Helpers/CryptoHashing.swift deleted file mode 100644 index 39de609..0000000 --- a/Sources/TelemetryDeck/Helpers/CryptoHashing.swift +++ /dev/null @@ -1,59 +0,0 @@ -import CommonCrypto -import Foundation - -#if canImport(CryptoKit) - import CryptoKit -#endif - -/// A wrapper for crypto hash algorithms. -enum CryptoHashing { - /// Returns a String representation of the SHA256 digest created with Apples CryptoKit library if available, else falls back to the ``commonCryptoSha256(strData:)`` function. - /// [CryptoKit](https://developer.apple.com/documentation/cryptokit) is Apples modern, safe & performant crypto library that - /// should be preferred where available. - /// [CommonCrypto](https://github.com/apple-oss-distributions/CommonCrypto) provides compatibility with older OS versions, - /// apps built with Xcode versions lower than 11 and non-Apple platforms like Linux. - static func sha256(string: String, salt: String) -> String { - if let strData = (string + salt).data(using: String.Encoding.utf8) { - #if canImport(CryptoKit) - if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) { - let digest = SHA256.hash(data: strData) - return digest.compactMap { String(format: "%02x", $0) }.joined() - } else { - // OS version requirement not met, but built with Xcode 11+ for Apple Platforms - return commonCryptoSha256(strData: strData) - } - #else - // Linux, etc. (and iOS when compiled with < Xcode 11) - return commonCryptoSha256(strData: strData) - #endif - } - return "" - } - - /// Example SHA 256 Hash using CommonCrypto - /// CC_SHA256 API exposed from from CommonCrypto-60118.50.1: - /// https://opensource.apple.com/source/CommonCrypto/CommonCrypto-60118.50.1/include/CommonDigest.h.auto.html - static func commonCryptoSha256(strData: Data) -> String { - /// #define CC_SHA256_DIGEST_LENGTH 32 - /// Creates an array of unsigned 8 bit integers that contains 32 zeros - var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - - /// CC_SHA256 performs digest calculation and places the result in the caller-supplied buffer for digest (md) - /// Takes the strData referenced value (const unsigned char *d) and hashes it into a reference to the digest parameter. - _ = strData.withUnsafeBytes { - // CommonCrypto - // extern unsigned char *CC_SHA256(const void *data, CC_LONG len, unsigned char *md) -| - // OpenSSL | - // unsigned char *SHA256(const unsigned char *d, size_t n, unsigned char *md) <-| - CC_SHA256($0.baseAddress, UInt32(strData.count), &digest) - } - - var sha256String = "" - /// Unpack each byte in the digest array and add them to the sha256String - for byte in digest { - sha256String += String(format: "%02x", UInt8(byte)) - } - - return sha256String - } -} diff --git a/Sources/TelemetryDeck/Helpers/DictionaryExt.swift b/Sources/TelemetryDeck/Helpers/DictionaryExt.swift deleted file mode 100644 index 03f0712..0000000 --- a/Sources/TelemetryDeck/Helpers/DictionaryExt.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -// Source: https://github.com/FlineDev/HandySwift/blob/main/Sources/HandySwift/Extensions/DictionaryExt.swift - -extension Dictionary { - /// Transforms the keys of the dictionary using the given closure, returning a new dictionary with the transformed keys. - /// - /// - Parameter transform: A closure that takes a key from the dictionary as its argument and returns a new key. - /// - Returns: A dictionary with keys transformed by the `transform` closure and the same values as the original dictionary. - /// - Throws: Rethrows any error thrown by the `transform` closure. - /// - /// - Warning: If the `transform` closure produces duplicate keys, the values of earlier keys will be overridden by the values of later keys in the resulting dictionary. - /// - /// - Example: - /// ``` - /// let originalDict = ["one": 1, "two": 2, "three": 3] - /// let transformedDict = originalDict.mapKeys { $0.uppercased() } - /// // transformedDict will be ["ONE": 1, "TWO": 2, "THREE": 3] - /// ``` - func mapKeys(_ transform: (Key) throws -> K) rethrows -> [K: Value] { - var transformedDict: [K: Value] = [:] - for (key, value) in self { - transformedDict[try transform(key)] = value - } - return transformedDict - } -} diff --git a/Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift b/Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift deleted file mode 100644 index d05efe8..0000000 --- a/Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift +++ /dev/null @@ -1,123 +0,0 @@ -#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] - let includeBackgroundTime: Bool - } - - 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], includeBackgroundTime: Bool) { - self.queue.sync { - self.startedSignals[signalName] = CachedData( - startTime: Date(), - parameters: parameters, - includeBackgroundTime: includeBackgroundTime - ) - } - } - - @discardableResult - 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 { - // skip offsetting by background time if background time explicitly requested by developer - if data.includeBackgroundTime { - continue - } - - self.startedSignals[signalName] = CachedData( - startTime: data.startTime.addingTimeInterval(backgroundDuration), - parameters: data.parameters, - includeBackgroundTime: data.includeBackgroundTime - ) - } - - self.lastEnteredBackground = nil - } - } -} diff --git a/Sources/TelemetryDeck/Helpers/LogHandler.swift b/Sources/TelemetryDeck/Helpers/LogHandler.swift deleted file mode 100644 index f6239b7..0000000 --- a/Sources/TelemetryDeck/Helpers/LogHandler.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation - -#if canImport(OSLog) - import OSLog -#endif - -public struct LogHandler: Sendable { - public enum LogLevel: Int, CustomStringConvertible, Sendable { - case debug = 0 - case info = 1 - case error = 2 - - public var description: String { - switch self { - case .debug: - return "DEBUG" - case .info: - return "INFO" - case .error: - return "ERROR" - } - } - } - - let logLevel: LogLevel - let handler: @Sendable (LogLevel, String) -> Void - - public init(logLevel: LogHandler.LogLevel, handler: @escaping @Sendable (LogHandler.LogLevel, String) -> Void) { - self.logLevel = logLevel - self.handler = handler - } - - internal func log(_ level: LogLevel = .info, message: String) { - if level.rawValue >= logLevel.rawValue { - handler(level, message) - } - } - - public static func standard(_ logLevel: LogLevel) -> LogHandler { - #if canImport(OSLog) - if #available(iOS 15, macOS 11, tvOS 15, watchOS 8, *) { - return Self.oslog(logLevel) - } else { - return Self.stdout(logLevel) - } - #else - return Self.stdout(logLevel) - #endif - } - - @available(iOS 15, macOS 11, tvOS 15, watchOS 8, *) - private static func oslog(_ logLevel: LogLevel) -> LogHandler { - LogHandler(logLevel: logLevel) { level, message in - let logger = Logger(subsystem: "TelemetryDeck", category: "LogHandler") - - switch level { - case .debug: logger.debug("\(message)") - case .info: logger.info("\(message)") - case .error: logger.error("\(message)") - } - } - } - - private static func stdout(_ logLevel: LogLevel) -> LogHandler { - LogHandler(logLevel: logLevel) { level, message in - print("[TelemetryDeck: \(level.description)] \(message)") - } - } -} diff --git a/Sources/TelemetryDeck/Helpers/SessionManager.swift b/Sources/TelemetryDeck/Helpers/SessionManager.swift deleted file mode 100644 index 33d2427..0000000 --- a/Sources/TelemetryDeck/Helpers/SessionManager.swift +++ /dev/null @@ -1,288 +0,0 @@ -#if canImport(WatchKit) - import WatchKit -#elseif canImport(UIKit) - import UIKit -#elseif canImport(AppKit) - import AppKit -#endif - -@available(watchOS 7, *) -final class SessionManager: @unchecked Sendable { - private struct StoredSession: Codable { - let startedAt: Date - var durationInSeconds: Int - - // Let's save some extra space in UserDefaults by using shorter keys. - private enum CodingKeys: String, CodingKey { - case startedAt = "st" - case durationInSeconds = "dn" - } - } - - static let shared = SessionManager() - - private static let recentSessionsKey = "recentSessions" - private static let deletedSessionsCountKey = "deletedSessionsCount" - - private static let firstSessionDateKey = "firstSessionDate" - private static let distinctDaysUsedKey = "distinctDaysUsed" - - private static let decoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .secondsSince1970 - return decoder - }() - - private static let encoder: JSONEncoder = { - let encoder = JSONEncoder() - // removes sub-second level precision from the start date as we don't need it - encoder.dateEncodingStrategy = .custom { date, encoder in - let timestamp = Int(date.timeIntervalSince1970) - var container = encoder.singleValueContainer() - try container.encode(timestamp) - } - return encoder - }() - - private var recentSessions: [StoredSession] - - private var deletedSessionsCount: Int { - get { TelemetryDeck.customDefaults?.integer(forKey: Self.deletedSessionsCountKey) ?? 0 } - set { - self.persistenceQueue.async { - TelemetryDeck.customDefaults?.set(newValue, forKey: Self.deletedSessionsCountKey) - } - } - } - - var totalSessionsCount: Int { - self.recentSessions.count + self.deletedSessionsCount - } - - var averageSessionSeconds: Int { - guard self.recentSessions.count > 1 else { - return self.recentSessions.first?.durationInSeconds ?? -1 - } - - let completedSessions = self.recentSessions.dropLast() - let totalCompletedSessionSeconds = completedSessions.map(\.durationInSeconds).reduce(into: 0) { $0 += $1 } - return totalCompletedSessionSeconds / completedSessions.count - } - - var previousSessionSeconds: Int? { - self.recentSessions.dropLast().last?.durationInSeconds - } - - var firstSessionDate: String { - get { - TelemetryDeck.customDefaults?.string(forKey: Self.firstSessionDateKey) - ?? ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) - } - set { - self.persistenceQueue.async { - TelemetryDeck.customDefaults?.set(newValue, forKey: Self.firstSessionDateKey) - } - } - } - - var distinctDaysUsed: [String] { - get { TelemetryDeck.customDefaults?.stringArray(forKey: Self.distinctDaysUsedKey) ?? [] } - set { - self.persistenceQueue.async { - TelemetryDeck.customDefaults?.set(newValue, forKey: Self.distinctDaysUsedKey) - } - } - } - - var distinctDaysUsedLastMonthCount: Int { - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withFullDate] - - // Get date 30 days ago - let thirtyDaysAgoDate = Date().addingTimeInterval(-(30 * 24 * 60 * 60)) - let thirtyDaysAgoFormatted = dateFormatter.string(from: thirtyDaysAgoDate) - - return self.distinctDaysUsed.countISODatesOnOrAfter(cutoffISODate: thirtyDaysAgoFormatted) - } - - private var currentSessionStartedAt: Date = .distantPast - private var currentSessionDuration: TimeInterval = .zero - - private var sessionDurationUpdater: Timer? - private var sessionDurationLastUpdatedAt: Date? - - private let persistenceQueue = DispatchQueue(label: "com.telemetrydeck.sessionmanager.persistence") - - private init() { - if let existingSessionData = TelemetryDeck.customDefaults?.data(forKey: Self.recentSessionsKey), - let existingSessions = try? Self.decoder.decode([StoredSession].self, from: existingSessionData) - { - // upon app start, clean up any sessions older than 90 days to keep dict small - let cutoffDate = Date().addingTimeInterval(-(90 * 24 * 60 * 60)) - self.recentSessions = existingSessions.filter { $0.startedAt > cutoffDate } - - // Update deleted sessions count - self.deletedSessionsCount += existingSessions.count - self.recentSessions.count - } else { - self.recentSessions = [] - } - - self.updateDistinctDaysUsed() - self.setupAppLifecycleObservers() - } - - func startNewSession() { - // stop automatic duration counting of previous session - self.stopSessionTimer() - - // if the recent sessions are empty, this must be the first start after installing the app - if self.recentSessions.isEmpty { - // this ensures we only use the date, not the time –> e.g. "2025-01-31" - let todayFormatted = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) - - self.firstSessionDate = todayFormatted - - TelemetryDeck.internalSignal( - "TelemetryDeck.Acquisition.newInstallDetected", - parameters: ["TelemetryDeck.Acquisition.firstSessionDate": todayFormatted] - ) - } - - // start a new session - self.currentSessionStartedAt = Date() - self.currentSessionDuration = .zero - - // start automatic duration counting of new session - self.updateSessionDuration() - self.sessionDurationUpdater = Timer.scheduledTimer( - timeInterval: 1, - target: self, - selector: #selector(updateSessionDuration), - userInfo: nil, - repeats: true - ) - } - - private func stopSessionTimer() { - self.sessionDurationUpdater?.invalidate() - self.sessionDurationUpdater = nil - self.sessionDurationLastUpdatedAt = nil - } - - @objc - private func updateSessionDuration() { - if let sessionDurationLastUpdatedAt { - self.currentSessionDuration += Date().timeIntervalSince(sessionDurationLastUpdatedAt) - } - - self.sessionDurationLastUpdatedAt = Date() - self.persistCurrentSessionIfNeeded() - } - - private func persistCurrentSessionIfNeeded() { - // Ignore sessions under 1 second - guard self.currentSessionDuration >= 1.0 else { return } - - // Add or update the current session - if let existingSessionIndex = self.recentSessions.lastIndex(where: { $0.startedAt == self.currentSessionStartedAt }) { - self.recentSessions[existingSessionIndex].durationInSeconds = Int(self.currentSessionDuration) - } else { - let newSession = StoredSession(startedAt: self.currentSessionStartedAt, durationInSeconds: Int(self.currentSessionDuration)) - self.recentSessions.append(newSession) - } - - // Save changes to UserDefaults without blocking Main thread - self.persistenceQueue.async { - if let updatedSessionData = try? Self.encoder.encode(self.recentSessions) { - TelemetryDeck.customDefaults?.set(updatedSessionData, forKey: Self.recentSessionsKey) - } - } - } - - @objc - private func handleDidEnterBackgroundNotification() { - self.updateSessionDuration() - self.stopSessionTimer() - } - - @objc - private func handleWillEnterForegroundNotification() { - self.updateSessionDuration() - self.sessionDurationUpdater = Timer.scheduledTimer( - timeInterval: 1, - target: self, - selector: #selector(updateSessionDuration), - userInfo: nil, - repeats: true - ) - } - - private func updateDistinctDaysUsed() { - let todayFormatted = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) - - var distinctDays = self.distinctDaysUsed - if distinctDays.last != todayFormatted { - distinctDays.append(todayFormatted) - self.distinctDaysUsed = distinctDays - } - } - - 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 - } -} - -extension [String] { - /// Counts ISO-formatted date strings (YYYY-MM-DD) that are on or after the given date. - /// Uses string comparison since ISO dates sort alphabetically like dates chronologically. - /// - /// - Parameter cutoffISODate: The ISO date string to compare against - /// - Returns: Count of dates on or after the cutoff - func countISODatesOnOrAfter(cutoffISODate: String) -> Int { - // Simply filter strings that are >= the cutoff date string - // (works because: String compares alphabetically & ISO date format sorts dates alphabetically) - self.filter { $0 >= cutoffISODate }.count - } -} diff --git a/Sources/TelemetryDeck/Helpers/TelemetryEnvironment.swift b/Sources/TelemetryDeck/Helpers/TelemetryEnvironment.swift deleted file mode 100644 index adf8dcb..0000000 --- a/Sources/TelemetryDeck/Helpers/TelemetryEnvironment.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -/// Represents the current telemetry execution environment. -internal enum TelemetryEnvironment { - - /// Indicates whether the code is running inside an app extension. - /// - /// Determined by checking whether the main bundle’s path ends with the `.appex` suffix. - static let isAppExtension: Bool = { - Bundle.main.bundlePath.hasSuffix(".appex") - }() -} diff --git a/Sources/TelemetryDeck/Logging/DefaultLogger.swift b/Sources/TelemetryDeck/Logging/DefaultLogger.swift new file mode 100644 index 0000000..3131b4f --- /dev/null +++ b/Sources/TelemetryDeck/Logging/DefaultLogger.swift @@ -0,0 +1,33 @@ +import Foundation + +#if canImport(OSLog) + import OSLog +#endif + +/// Forwards SDK log messages to `os.Logger` where available, or `print` as a fallback. +public struct DefaultLogger: Logging { + private let minimumLevel: LogLevel + + /// Creates a logger that filters messages below the given minimum level. + public init(minimumLevel: LogLevel = .info) { + self.minimumLevel = minimumLevel + } + + /// Logs the message at the given level if it meets the minimum level threshold. + public func log(_ level: LogLevel, _ message: @autoclosure () -> String) { + guard level >= minimumLevel else { return } + + let messageText = message() + + #if canImport(OSLog) + let osLog = os.Logger(subsystem: "TelemetryDeck", category: "SDK") + switch level { + case .debug: osLog.debug("\(messageText)") + case .info: osLog.info("\(messageText)") + case .error: osLog.error("\(messageText)") + } + #else + print("[TelemetryDeck] \(messageText)") + #endif + } +} diff --git a/Sources/TelemetryDeck/Logging/Logging.swift b/Sources/TelemetryDeck/Logging/Logging.swift new file mode 100644 index 0000000..5a433c1 --- /dev/null +++ b/Sources/TelemetryDeck/Logging/Logging.swift @@ -0,0 +1,18 @@ +import Foundation + +/// A sink for SDK log messages. +public protocol Logging: Sendable { + func log(_ level: LogLevel, _ message: @autoclosure () -> String) +} + +/// The severity level of a log message. +public enum LogLevel: Int, Sendable, Comparable { + case debug = 0 + case info = 1 + case error = 2 + + /// Compares two log levels by their raw integer severity. + public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + lhs.rawValue < rhs.rawValue + } +} diff --git a/Sources/TelemetryDeck/Modifiers/TrackNavigationModifier.swift b/Sources/TelemetryDeck/Modifiers/TrackNavigationModifier.swift deleted file mode 100644 index e1e28a8..0000000 --- a/Sources/TelemetryDeck/Modifiers/TrackNavigationModifier.swift +++ /dev/null @@ -1,90 +0,0 @@ -#if canImport(SwiftUI) - import SwiftUI - - /// A view modifier that automatically sends navigation signals to TelemetryDeck when a view appears. - @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) - private struct TrackNavigationModifier: ViewModifier { - let path: String - - func body(content: Content) -> some View { - content - .onAppear { - TelemetryDeck.navigationPathChanged(to: path) - } - } - } - - @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) - extension View { - /// Tracks navigation to this view by sending a signal to TelemetryDeck when the view appears. - /// - /// Use this modifier to automatically track user navigation through your app. The modifier will send - /// a navigation signal to TelemetryDeck whenever the view appears, using the provided path. - /// - /// Navigation paths should be delineated by either `.` or `/` characters to represent hierarchy. - /// Keep paths generic and avoid including dynamic values like IDs to ensure meaningful analytics. - /// - /// For example: - /// - `settings.profile` - /// - `store.items.detail` - /// - `onboarding.step2` - /// - /// # Example Usage - /// ```swift - /// struct AppTabView: View { - /// var body: some View { - /// TabView { - /// HomeView() - /// .trackNavigation(path: "home") - /// .tabItem { Text("Home") } - /// - /// StoreView() - /// .trackNavigation(path: "store.browse") - /// .tabItem { Text("Store") } - /// - /// ProfileView() - /// .trackNavigation(path: "profile") - /// .tabItem { Text("Profile") } - /// } - /// } - /// } - /// - /// struct StoreItemView: View { - /// let itemId: String - /// - /// var body: some View { - /// ItemDetailsView() - /// .trackNavigation(path: "store.items.detail") - /// } - /// } - /// - /// struct SettingsView: View { - /// var body: some View { - /// Form { - /// NavigationLink("Account") { - /// AccountSettingsView() - /// .trackNavigation(path: "settings.account") - /// } - /// NavigationLink("Privacy") { - /// PrivacySettingsView() - /// .trackNavigation(path: "settings.privacy") - /// } - /// } - /// .trackNavigation(path: "settings") - /// } - /// } - /// ``` - /// - /// - Note: For accurate navigation tracking, ensure you consistently apply this modifier to all views - /// that represent navigation destinations in your app. Otherwise, TelemetryDeck might record - /// incorrect navigation paths since it uses the previously recorded destination as the source - /// for the next navigation event. - /// - /// - Parameter path: The navigation path that identifies this view in your analytics. - /// Use dot notation (e.g., "settings.account") to represent hierarchy. - /// - Returns: A view that triggers navigation tracking when it appears. - public func trackNavigation(path: String) -> some View { - modifier(TrackNavigationModifier(path: path)) - } - } -#endif diff --git a/Sources/TelemetryDeck/Params/DefaultParams.swift b/Sources/TelemetryDeck/Params/DefaultParams.swift new file mode 100644 index 0000000..f45bd7d --- /dev/null +++ b/Sources/TelemetryDeck/Params/DefaultParams.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Typed parameter key constants for SDK-enriched event parameters. +public enum DefaultParams { + /// Device hardware and operating system parameters. + public enum Device: String { + case platform = "TelemetryDeck.Device.platform" + case operatingSystem = "TelemetryDeck.Device.operatingSystem" + case systemVersion = "TelemetryDeck.Device.systemVersion" + case systemMajorVersion = "TelemetryDeck.Device.systemMajorVersion" + case systemMajorMinorVersion = "TelemetryDeck.Device.systemMajorMinorVersion" + case modelName = "TelemetryDeck.Device.modelName" + case architecture = "TelemetryDeck.Device.architecture" + case timeZone = "TelemetryDeck.Device.timeZone" + case orientation = "TelemetryDeck.Device.orientation" + case screenResolutionWidth = "TelemetryDeck.Device.screenResolutionWidth" + case screenResolutionHeight = "TelemetryDeck.Device.screenResolutionHeight" + case screenScaleFactor = "TelemetryDeck.Device.screenScaleFactor" + } + + /// Parameters describing the current run environment (simulator, debug, TestFlight, App Store). + public enum RunContext: String { + case isSimulator = "TelemetryDeck.RunContext.isSimulator" + case isDebug = "TelemetryDeck.RunContext.isDebug" + case isTestFlight = "TelemetryDeck.RunContext.isTestFlight" + case isAppStore = "TelemetryDeck.RunContext.isAppStore" + case targetEnvironment = "TelemetryDeck.RunContext.targetEnvironment" + case locale = "TelemetryDeck.RunContext.locale" + case language = "TelemetryDeck.RunContext.language" + case extensionIdentifier = "TelemetryDeck.RunContext.extensionIdentifier" + } + + /// App version and build number parameters. + public enum AppInfo: String { + case version = "TelemetryDeck.AppInfo.version" + case buildNumber = "TelemetryDeck.AppInfo.buildNumber" + case versionAndBuildNumber = "TelemetryDeck.AppInfo.versionAndBuildNumber" + } + + /// SDK name and version parameters. + public enum SDK: String { + case name = "TelemetryDeck.SDK.name" + case version = "TelemetryDeck.SDK.version" + case nameAndVersion = "TelemetryDeck.SDK.nameAndVersion" + } + + /// User-configured preference parameters such as language and colour scheme. + public enum UserPreference: String { + case language = "TelemetryDeck.UserPreference.language" + case region = "TelemetryDeck.UserPreference.region" + case colorScheme = "TelemetryDeck.UserPreference.colorScheme" + case layoutDirection = "TelemetryDeck.UserPreference.layoutDirection" + } + + /// System accessibility setting parameters. + public enum Accessibility: String { + case isReduceMotionEnabled = "TelemetryDeck.Accessibility.isReduceMotionEnabled" + case isBoldTextEnabled = "TelemetryDeck.Accessibility.isBoldTextEnabled" + case isInvertColorsEnabled = "TelemetryDeck.Accessibility.isInvertColorsEnabled" + case isDarkerSystemColorsEnabled = "TelemetryDeck.Accessibility.isDarkerSystemColorsEnabled" + case isReduceTransparencyEnabled = "TelemetryDeck.Accessibility.isReduceTransparencyEnabled" + case shouldDifferentiateWithoutColor = "TelemetryDeck.Accessibility.shouldDifferentiateWithoutColor" + case preferredContentSizeCategory = "TelemetryDeck.Accessibility.preferredContentSizeCategory" + } + + /// Calendar context parameters such as day of week and hour of day. + public enum Calendar: String { + case dayOfMonth = "TelemetryDeck.Calendar.dayOfMonth" + case dayOfWeek = "TelemetryDeck.Calendar.dayOfWeek" + case dayOfYear = "TelemetryDeck.Calendar.dayOfYear" + case weekOfYear = "TelemetryDeck.Calendar.weekOfYear" + case isWeekend = "TelemetryDeck.Calendar.isWeekend" + case monthOfYear = "TelemetryDeck.Calendar.monthOfYear" + case quarterOfYear = "TelemetryDeck.Calendar.quarterOfYear" + case hourOfDay = "TelemetryDeck.Calendar.hourOfDay" + } + + /// User retention metrics parameters. + public enum Retention: String { + case totalSessionsCount = "TelemetryDeck.Retention.totalSessionsCount" + case distinctDaysUsed = "TelemetryDeck.Retention.distinctDaysUsed" + case distinctDaysUsedLastMonth = "TelemetryDeck.Retention.distinctDaysUsedLastMonth" + case averageSessionSeconds = "TelemetryDeck.Retention.averageSessionSeconds" + case previousSessionSeconds = "TelemetryDeck.Retention.previousSessionSeconds" + } + + /// Acquisition funnel parameters. + public enum Acquisition: String { + case firstSessionDate = "TelemetryDeck.Acquisition.firstSessionDate" + case isNewInstall = "TelemetryDeck.Acquisition.isNewInstall" + case channel = "TelemetryDeck.Acquisition.channel" + case leadID = "TelemetryDeck.Acquisition.leadID" + } + + /// Activation funnel parameters. + public enum Activation: String { + case featureName = "TelemetryDeck.Activation.featureName" + } + + /// Referral and rating parameters. + public enum Referral: String { + case receiversCount = "TelemetryDeck.Referral.receiversCount" + case kind = "TelemetryDeck.Referral.kind" + case ratingValue = "TelemetryDeck.Referral.ratingValue" + case ratingComment = "TelemetryDeck.Referral.ratingComment" + } + + /// Revenue and paywall parameters. + public enum Revenue: String { + case paywallShowReason = "TelemetryDeck.Revenue.paywallShowReason" + } + + /// In-app purchase parameters. + public enum Purchase: String { + case type = "TelemetryDeck.Purchase.type" + case countryCode = "TelemetryDeck.Purchase.countryCode" + case currencyCode = "TelemetryDeck.Purchase.currencyCode" + case productID = "TelemetryDeck.Purchase.productID" + } + + /// Navigation tracking parameters. + public enum Navigation: String { + case schemaVersion = "TelemetryDeck.Navigation.schemaVersion" + case identifier = "TelemetryDeck.Navigation.identifier" + case sourcePath = "TelemetryDeck.Navigation.sourcePath" + case destinationPath = "TelemetryDeck.Navigation.destinationPath" + } + + /// Error reporting parameters. + public enum Error: String { + case id = "TelemetryDeck.Error.id" + case category = "TelemetryDeck.Error.category" + case message = "TelemetryDeck.Error.message" + } + + /// Generic event metadata parameters. + public enum Event: String { + case durationInSeconds = "TelemetryDeck.Signal.durationInSeconds" + } +} diff --git a/Sources/TelemetryDeck/Pipeline/EventFinalizer.swift b/Sources/TelemetryDeck/Pipeline/EventFinalizer.swift new file mode 100644 index 0000000..b17fc63 --- /dev/null +++ b/Sources/TelemetryDeck/Pipeline/EventFinalizer.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Converts a processed `EventInput` and its accumulated `EventContext` into a transmittable `Event`. +public struct EventFinalizer: Sendable { + private let configuration: TelemetryDeck.Config + + /// Creates a finalizer using the given configuration. + public init(configuration: TelemetryDeck.Config) { + self.configuration = configuration + } + + /// Merges context metadata with event parameters and returns a fully populated `Event`. + public func finalize(_ input: EventInput, context: EventContext) -> Event { + var merged = context.metadata + merged.merge(input.parameters) + + return Event( + appID: configuration.appID, + type: input.name, + clientUser: CryptoHashing.sha256( + string: context.userIdentifier ?? "unknown user", + salt: configuration.salt + ), + sessionID: context.sessionID?.uuidString, + receivedAt: input.timestamp, + payload: merged.payloadDictionary, + floatValue: input.floatValue, + isTestMode: context.isTestMode ?? false + ) + } +} diff --git a/Sources/TelemetryDeck/Pipeline/EventProcessor.swift b/Sources/TelemetryDeck/Pipeline/EventProcessor.swift new file mode 100644 index 0000000..659015c --- /dev/null +++ b/Sources/TelemetryDeck/Pipeline/EventProcessor.swift @@ -0,0 +1,22 @@ +import Foundation + +/// A middleware component in the event processing pipeline that can enrich, filter, or transform events. +public protocol EventProcessor: Sendable { + func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event + + /// Called once when the SDK starts; allows the processor to load persisted state. + func start(storage: any ProcessorStorage, logger: any Logging, emitter: any EventSending) async + /// Called when the SDK shuts down; allows the processor to release resources. + func stop() async +} + +extension EventProcessor { + /// Default no-op implementation. + public func start(storage: any ProcessorStorage, logger: any Logging, emitter: any EventSending) async {} + /// Default no-op implementation. + public func stop() async {} +} diff --git a/Sources/TelemetryDeck/Pipeline/EventSending.swift b/Sources/TelemetryDeck/Pipeline/EventSending.swift new file mode 100644 index 0000000..7746ee8 --- /dev/null +++ b/Sources/TelemetryDeck/Pipeline/EventSending.swift @@ -0,0 +1,6 @@ +import Foundation + +/// Allows processors to submit events for pipeline processing and transmission. +public protocol EventSending: Sendable { + func send(_ input: EventInput) async +} diff --git a/Sources/TelemetryDeck/Pipeline/ProcessorError.swift b/Sources/TelemetryDeck/Pipeline/ProcessorError.swift new file mode 100644 index 0000000..1e19aa2 --- /dev/null +++ b/Sources/TelemetryDeck/Pipeline/ProcessorError.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Errors that an event processor can throw to indicate filtering or processing failure. +public enum ProcessorError: Error, Sendable { + case eventFiltered + case processingFailed(underlying: any Error) +} diff --git a/Sources/TelemetryDeck/Pipeline/ProcessorPipeline.swift b/Sources/TelemetryDeck/Pipeline/ProcessorPipeline.swift new file mode 100644 index 0000000..bdea5d8 --- /dev/null +++ b/Sources/TelemetryDeck/Pipeline/ProcessorPipeline.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Chains a sequence of `EventProcessor` instances and a finalizer to produce a transmittable `Event`. +public struct ProcessorPipeline: Sendable { + private let processors: [any EventProcessor] + private let finalizer: EventFinalizer + + /// Creates a pipeline with the given processors and finalizer. + public init(processors: [any EventProcessor], finalizer: EventFinalizer) { + self.processors = processors + self.finalizer = finalizer + } + + /// Runs the input through the processor chain and returns the finalised event. + public func process(_ input: EventInput, context: EventContext) async throws -> Event { + try await runChain(input: input, context: context, index: 0) + } + + private func runChain(input: EventInput, context: EventContext, index: Int) async throws -> Event { + guard index < processors.count else { + return finalizer.finalize(input, context: context) + } + return try await processors[index].process(input, context: context) { @Sendable inp, ctx in + try await runChain(input: inp, context: ctx, index: index + 1) + } + } +} diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift deleted file mode 100644 index df2b519..0000000 --- a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation - -extension TelemetryDeck { - /// Sends a telemetry signal indicating that a user was acquired through a specific channel. - /// - /// - Parameters: - /// - channel: The acquisition channel through which the user was acquired (e.g., "organic", "paid-search", "social-media"). - /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. - /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. - public static func acquiredUser( - channel: String, - parameters: [String: String] = [:], - customUserID: String? = nil - ) { - let acquisitionParameters = ["TelemetryDeck.Acquisition.channel": channel] - - self.internalSignal( - "TelemetryDeck.Acquisition.userAcquired", - parameters: acquisitionParameters.merging(parameters) { $1 }, - customUserID: customUserID - ) - } - - /// Sends a telemetry signal indicating that a lead has been initiated. - /// - /// - Parameters: - /// - leadID: A unique identifier for the lead being tracked. - /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. - /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. - public 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 - ) - } - - /// Sends a telemetry signal indicating that a lead has been successfully converted. - /// - /// - Parameters: - /// - leadID: A unique identifier for the lead that was converted. Should match the identifier used in `leadStarted`. - /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. - /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. - public 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 - ) - } -} diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift deleted file mode 100644 index f66cee1..0000000 --- a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation - -extension TelemetryDeck { - /// Sends a telemetry signal indicating that a user has completed the onboarding process. - /// - /// - Parameters: - /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. - /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. - public 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 - ) - } - - /// Sends a telemetry signal indicating that a core feature of the application has been used. - /// - /// - Parameters: - /// - featureName: The name of the core feature that was used. - /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. - /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. - public 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 - ) - } -} diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift deleted file mode 100644 index b08daa7..0000000 --- a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -extension TelemetryDeck { - /// Sends a telemetry signal indicating that a referral has been sent. - /// - /// - Parameters: - /// - receiversCount: The number of recipients who received the referral. Default is `1`. - /// - kind: An optional categorization of the referral type (e.g., "email", "social", "sms"). Default is `nil`. - /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. - /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. - public static func referralSent( - receiversCount: Int = 1, - kind: String? = nil, - parameters: [String: String] = [:], - customUserID: String? = nil - ) { - 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 - ) - } - - /// Sends a telemetry signal indicating that a user has submitted a rating. - /// - /// - Parameters: - /// - rating: The rating value submitted by the user. Must be between 0 and 10 inclusive. - /// - comment: An optional comment or feedback text accompanying the rating. Default is `nil`. - /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. - /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. - public 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 - ) - } -} diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift deleted file mode 100644 index fe9ba56..0000000 --- a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -extension TelemetryDeck { - /// Sends a telemetry signal indicating that a paywall has been shown to the user. - /// - /// - Parameters: - /// - reason: The reason or context for showing the paywall (e.g., "trial-expired", "feature-locked", "onboarding"). - /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. - /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. - public 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 - ) - } -} diff --git a/Sources/TelemetryDeck/Presets/AnyIdentifiableError.swift b/Sources/TelemetryDeck/Presets/AnyIdentifiableError.swift deleted file mode 100644 index 0a897c1..0000000 --- a/Sources/TelemetryDeck/Presets/AnyIdentifiableError.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation - -/// A generic wrapper that conforms any error to ``IdentifiableError``, exposing its `localizedDescription` as the `message`. -public struct AnyIdentifiableError: LocalizedError, IdentifiableError { - /// Unique identifier for the error, such as `TelemetryDeck.Session.started`. - public let id: String - - /// The underlying error being wrapped. - public let error: any Error - - /// Initializes with a given `id` and `error`. - /// - Parameters: - /// - id: Unique identifier for the error, such as `TelemetryDeck.Session.started`. - /// - error: The error to be wrapped. - public init(id: String, error: any Error) { - self.id = id - self.error = error - } - - /// Provides the localized description of the wrapped error. - public var errorDescription: String { - self.error.localizedDescription - } -} - -extension Error { - /// Wraps any caught error with an `id` for use with ``TelemetryDeck.signal(identifiableError:)``. - /// - Parameters: - /// - id: Unique identifier for the error, such as `TelemetryDeck.Session.started`. - /// - Returns: An ``AnyIdentifiableError`` instance wrapping the given error. - public func with(id: String) -> AnyIdentifiableError { - AnyIdentifiableError(id: id, error: self) - } -} diff --git a/Sources/TelemetryDeck/Presets/ErrorCategory.swift b/Sources/TelemetryDeck/Presets/ErrorCategory.swift deleted file mode 100644 index c245270..0000000 --- a/Sources/TelemetryDeck/Presets/ErrorCategory.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -/// An enumeration of common categories for errors. Each case has its own insight preset on TelemetryDeck. -public enum ErrorCategory: String { - /// Represents an error that was thrown as an exception. - case thrownException = "thrown-exception" - - /// Represents an error caused by user input. - case userInput = "user-input" - - /// Represents an error caused by the application's state. - case appState = "app-state" -} diff --git a/Sources/TelemetryDeck/Presets/Errors.swift b/Sources/TelemetryDeck/Presets/Errors.swift new file mode 100644 index 0000000..ae2a07d --- /dev/null +++ b/Sources/TelemetryDeck/Presets/Errors.swift @@ -0,0 +1,109 @@ +import Foundation + +/// Classifies the nature of an error for TelemetryDeck reporting. +public enum ErrorCategory: String, Sendable { + case thrownException = "thrown-exception" + case userInput = "user-input" + case appState = "app-state" +} + +/// An error that carries a stable, developer-assigned identifier for TelemetryDeck tracking. +public protocol IdentifiableError: Error { + /// A stable identifier used to group occurrences of this error in the dashboard. + var id: String { get } +} + +/// Wraps any error with a developer-provided identifier, conforming to `IdentifiableError`. +public struct AnyIdentifiableError: LocalizedError, IdentifiableError { + /// The stable identifier assigned to this error. + public let id: String + /// The underlying error being wrapped. + public let error: any Error + + /// Creates a wrapper that associates the given identifier with the given error. + public init(id: String, error: any Error) { + self.id = id + self.error = error + } + + /// The localised description of the underlying error. + public var errorDescription: String { + self.error.localizedDescription + } +} + +extension Error { + /// Wraps this error with a stable identifier for TelemetryDeck reporting. + public func with(id: String) -> AnyIdentifiableError { + AnyIdentifiableError(id: id, error: self) + } +} + +extension TelemetryDeck { + /// Sends an error event with the given identifier, optional category, message, and additional parameters. + public static func errorOccurred( + id: String, + category: ErrorCategory? = nil, + message: String? = nil, + parameters: EventParameters = [:], + floatValue: Double? = nil, + customUserID: String? = nil + ) async { + var errorParameters: EventParameters = [DefaultParams.Error.id.rawValue: id] + + if let category { + errorParameters[DefaultParams.Error.category] = category.rawValue + } + + if let message { + errorParameters[DefaultParams.Error.message] = message + } + + errorParameters.merge(parameters) + + await sdkEvent( + DefaultEvents.Error.occurred, + parameters: errorParameters, + floatValue: floatValue, + customUserID: customUserID + ) + } + + /// Sends an error event for the given `IdentifiableError`, using its localised description as the message. + public static func errorOccurred( + identifiableError: IdentifiableError, + category: ErrorCategory = .thrownException, + parameters: EventParameters = [:], + floatValue: Double? = nil, + customUserID: String? = nil + ) async { + await errorOccurred( + id: identifiableError.id, + category: category, + message: identifiableError.localizedDescription, + parameters: parameters, + floatValue: floatValue, + customUserID: customUserID + ) + } + + /// Sends an error event for the given `IdentifiableError` with an explicit optional message override. + @_disfavoredOverload + public static func errorOccurred( + identifiableError: IdentifiableError, + category: ErrorCategory = .thrownException, + message: String? = nil, + parameters: EventParameters = [:], + floatValue: Double? = nil, + customUserID: String? = nil + ) async { + await errorOccurred( + id: identifiableError.id, + category: category, + message: message, + parameters: parameters, + floatValue: floatValue, + customUserID: customUserID + ) + } +} diff --git a/Sources/TelemetryDeck/Presets/IdentifiableError.swift b/Sources/TelemetryDeck/Presets/IdentifiableError.swift deleted file mode 100644 index a507055..0000000 --- a/Sources/TelemetryDeck/Presets/IdentifiableError.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -/// A protocol that represents an error with an identifiable ID. -public protocol IdentifiableError: Error { - /// A unique identifier for the error. - var id: String { get } -} diff --git a/Sources/TelemetryDeck/Presets/Navigation.swift b/Sources/TelemetryDeck/Presets/Navigation.swift new file mode 100644 index 0000000..320792c --- /dev/null +++ b/Sources/TelemetryDeck/Presets/Navigation.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftUI + +private actor NavigationState { + var previousPath: String? + + func setPreviousPath(_ path: String) { + previousPath = path + } +} + +private let navigationState = NavigationState() + +extension TelemetryDeck { + /// Sends a navigation event recording a transition from `source` to `destination`. + public static func navigationPathChanged( + from source: String, + to destination: String, + customUserID: String? = nil + ) async { + await navigationState.setPreviousPath(destination) + + let params: EventParameters = [ + DefaultParams.Navigation.schemaVersion.rawValue: "1", + DefaultParams.Navigation.identifier.rawValue: "\(source) -> \(destination)", + DefaultParams.Navigation.sourcePath.rawValue: source, + DefaultParams.Navigation.destinationPath.rawValue: destination, + ] + await sdkEvent(DefaultEvents.Navigation.pathChanged, parameters: params, customUserID: customUserID) + } + + /// Sends a navigation event to `destination`, using the last recorded path as the source. + public static func navigationPathChanged( + to destination: String, + customUserID: String? = nil + ) async { + let source = await navigationState.previousPath ?? "" + await navigationPathChanged(from: source, to: destination, customUserID: customUserID) + } +} + +extension View { + @ViewBuilder + fileprivate func _onChangeCompat(of value: V, perform action: @escaping (V) -> Void) -> some View { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1, *) { + self.onChange(of: value) { _, newValue in + action(newValue) + } + } else { + self.onChange(of: value, perform: action) + } + } +} + +extension View { + /// Automatically sends a navigation event when the view appears and whenever `path` changes. + public func trackNavigation(path: String) -> some View { + self + .onAppear { + Task { + await TelemetryDeck.navigationPathChanged(to: path) + } + } + ._onChangeCompat(of: path) { newPath in + Task { + await TelemetryDeck.navigationPathChanged(to: newPath) + } + } + } +} diff --git a/Sources/TelemetryDeck/Presets/NavigationStatus.swift b/Sources/TelemetryDeck/Presets/NavigationStatus.swift deleted file mode 100644 index 6ca100b..0000000 --- a/Sources/TelemetryDeck/Presets/NavigationStatus.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -/// This internal singleton keeps track of the last used navigation path so -/// that the ``TelemetryDeck.navigationPathChanged(to:customUserID:)`` function has a `from` source to work off of. -@MainActor -class NavigationStatus { - static let shared = NavigationStatus() - - var previousNavigationPath: String? -} diff --git a/Sources/TelemetryDeck/Presets/PirateMetrics.swift b/Sources/TelemetryDeck/Presets/PirateMetrics.swift new file mode 100644 index 0000000..e18286d --- /dev/null +++ b/Sources/TelemetryDeck/Presets/PirateMetrics.swift @@ -0,0 +1,114 @@ +import Foundation + +// MARK: - Acquisition + +extension TelemetryDeck { + /// Sends an acquisition event recording the channel through which this user was acquired. + public static func acquiredUser( + channel: String, + parameters: EventParameters = [:], + customUserID: String? = nil + ) async { + var params: EventParameters = [DefaultParams.Acquisition.channel.rawValue: channel] + params.merge(parameters) + await sdkEvent(DefaultEvents.Acquisition.userAcquired, parameters: params, customUserID: customUserID) + } + + /// Sends an event indicating that a lead funnel has started for the given lead identifier. + public static func leadStarted( + leadID: String, + parameters: EventParameters = [:], + customUserID: String? = nil + ) async { + var params: EventParameters = [DefaultParams.Acquisition.leadID.rawValue: leadID] + params.merge(parameters) + await sdkEvent(DefaultEvents.Acquisition.leadStarted, parameters: params, customUserID: customUserID) + } + + /// Sends an event indicating that a lead has converted for the given lead identifier. + public static func leadConverted( + leadID: String, + parameters: EventParameters = [:], + customUserID: String? = nil + ) async { + var params: EventParameters = [DefaultParams.Acquisition.leadID.rawValue: leadID] + params.merge(parameters) + await sdkEvent(DefaultEvents.Acquisition.leadConverted, parameters: params, customUserID: customUserID) + } +} + +// MARK: - Activation + +extension TelemetryDeck { + /// Sends an event indicating that the user has completed onboarding. + public static func onboardingCompleted( + parameters: EventParameters = [:], + customUserID: String? = nil + ) async { + await sdkEvent(DefaultEvents.Activation.onboardingCompleted, parameters: parameters, customUserID: customUserID) + } + + /// Sends an event indicating that the user engaged with a core feature. + public static func coreFeatureUsed( + featureName: String, + parameters: EventParameters = [:], + customUserID: String? = nil + ) async { + var params: EventParameters = [DefaultParams.Activation.featureName.rawValue: featureName] + params.merge(parameters) + await sdkEvent(DefaultEvents.Activation.coreFeatureUsed, parameters: params, customUserID: customUserID) + } +} + +// MARK: - Referral + +extension TelemetryDeck { + /// Sends an event recording that the user sent a referral to one or more recipients. + public static func referralSent( + receiversCount: Int, + kind: String? = nil, + parameters: EventParameters = [:], + customUserID: String? = nil + ) async { + var params: EventParameters = [DefaultParams.Referral.receiversCount.rawValue: receiversCount] + if let kind { + params[DefaultParams.Referral.kind] = kind + } + params.merge(parameters) + await sdkEvent(DefaultEvents.Referral.sent, parameters: params, customUserID: customUserID) + } + + /// Sends an event recording a user-submitted rating (0–10) and optional comment. + public static func userRatingSubmitted( + rating: Int, + comment: String? = nil, + parameters: EventParameters = [:], + customUserID: String? = nil + ) async { + guard (0...10).contains(rating) else { + await log(.error, "Rating must be between 0 and 10, got \(rating)") + return + } + var params: EventParameters = [DefaultParams.Referral.ratingValue.rawValue: rating] + if let comment { + params[DefaultParams.Referral.ratingComment] = comment + } + params.merge(parameters) + await sdkEvent(DefaultEvents.Referral.userRatingSubmitted, parameters: params, customUserID: customUserID) + } +} + +// MARK: - Revenue + +extension TelemetryDeck { + /// Sends an event indicating that a paywall was shown to the user, including the reason it appeared. + public static func paywallShown( + reason: String, + parameters: EventParameters = [:], + customUserID: String? = nil + ) async { + var params: EventParameters = [DefaultParams.Revenue.paywallShowReason.rawValue: reason] + params.merge(parameters) + await sdkEvent(DefaultEvents.Revenue.paywallShown, parameters: params, customUserID: customUserID) + } +} diff --git a/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift b/Sources/TelemetryDeck/Presets/Purchases.swift similarity index 60% rename from Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift rename to Sources/TelemetryDeck/Presets/Purchases.swift index 1bae9b1..7da54fb 100644 --- a/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift +++ b/Sources/TelemetryDeck/Presets/Purchases.swift @@ -1,77 +1,82 @@ -#if canImport(StoreKit) && compiler(>=5.9.2) - import StoreKit +#if canImport(StoreKit) import Foundation + import StoreKit - @available(iOS 15, macOS 12, tvOS 15, visionOS 1, watchOS 8, *) + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) extension TelemetryDeck { - /// Sends a telemetry signal indicating that a purchase has been completed. - /// - /// - Parameters: - /// - transaction: The completed `StoreKit.Transaction` containing details about the purchase. - /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. - /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. - /// - /// This function captures details about the completed purchase, including the type of purchase (subscription or one-time), - /// the country code of the storefront, and the currency code. It also converts the price to USD if necessary and sends - /// this information as a telemetry signal. The conversion happens with hard-coded values that might be out of date. + /// Sends a purchase event for the given StoreKit transaction, automatically handling free trials. public static func purchaseCompleted( transaction: StoreKit.Transaction, - parameters: [String: String] = [:], + parameters: EventParameters = [:], customUserID: String? = nil - ) { + ) async { if #available(iOS 17.2, macOS 14.2, tvOS 17.2, visionOS 1.1, watchOS 10.2, *) { - // detect if the purchase is a free trial (using modern APIs) if transaction.productType == .autoRenewable, transaction.offer?.type == .introductory, transaction.price == nil || transaction.price!.isZero { - self.reportFreeTrial(transaction: transaction, parameters: parameters, customUserID: customUserID) - } else { - self.reportPaidPurchase(transaction: transaction, parameters: parameters, customUserID: customUserID) + await reportFreeTrial(transaction: transaction, parameters: parameters, customUserID: customUserID) + return } } else { - // detect if the purchase is a free trial (using legacy APIs on older systems) - if transaction.productType == .autoRenewable, - transaction.offerType == .introductory, - transaction.price == nil || transaction.price!.isZero - { - self.reportFreeTrial(transaction: transaction, parameters: parameters, customUserID: customUserID) - } else { - self.reportPaidPurchase(transaction: transaction, parameters: parameters, customUserID: customUserID) - } + #if !os(visionOS) + if transaction.productType == .autoRenewable, + transaction.offerType == .introductory, + transaction.price == nil || transaction.price!.isZero + { + await reportFreeTrial(transaction: transaction, parameters: parameters, customUserID: customUserID) + return + } + #endif } + await reportPaidPurchase(transaction: transaction, parameters: parameters, customUserID: customUserID) } private static func reportFreeTrial( transaction: StoreKit.Transaction, - parameters: [String: String], + parameters: EventParameters, customUserID: String? - ) { - self.internalSignal( - "TelemetryDeck.Purchase.freeTrialStarted", - parameters: transaction.purchaseParameters().merging(parameters) { $1 }, - customUserID: customUserID - ) + ) async { + var params = EventParameters(transaction.purchaseParameters()) + params.merge(parameters) + await sdkEvent(DefaultEvents.Purchase.freeTrialStarted, parameters: params, customUserID: customUserID) - TrialConversionTracker.shared.freeTrialStarted(transaction: transaction) + guard let client = await TelemetryDeck.client() else { return } + if let processor = await client.processor(ofType: TrialConversionProcessor.self) { + await processor.freeTrialStarted(transaction: transaction) + } } private static func reportPaidPurchase( transaction: StoreKit.Transaction, - parameters: [String: String], + parameters: EventParameters, customUserID: String? - ) { - self.internalSignal( - "TelemetryDeck.Purchase.completed", - parameters: transaction.purchaseParameters().merging(parameters) { $1 }, + ) async { + var params = EventParameters(transaction.purchaseParameters()) + params.merge(parameters) + await sdkEvent( + DefaultEvents.Purchase.completed, + parameters: params, floatValue: transaction.priceInUSD(), customUserID: customUserID ) } } - @available(iOS 15, macOS 12, tvOS 15, visionOS 1, watchOS 8, *) + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) extension Transaction { + var isFreeTrial: Bool { + if #available(iOS 17.2, macOS 14.2, tvOS 17.2, visionOS 1.1, watchOS 10.2, *) { + return self.offer?.type == .introductory && self.offer?.paymentMode == .freeTrial + } else { + #if os(visionOS) + return false + #else + return self.offerType == .introductory && self.offerPaymentModeStringRepresentation == "FREE_TRIAL" + #endif + } + } + func purchaseParameters() -> [String: String] { let countryCode: String if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { @@ -84,52 +89,54 @@ #endif } - var purchaseParameters: [String: String] = [ - "TelemetryDeck.Purchase.type": self.subscriptionGroupID != nil ? "subscription" : "one-time-purchase", - "TelemetryDeck.Purchase.countryCode": countryCode, - "TelemetryDeck.Purchase.productID": self.productID, + var params: [String: String] = [ + DefaultParams.Purchase.type.rawValue: self.subscriptionGroupID != nil ? "subscription" : "one-time-purchase", + DefaultParams.Purchase.countryCode.rawValue: countryCode, + DefaultParams.Purchase.productID.rawValue: self.productID, ] if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { if let currencyCode = self.currency?.identifier { - purchaseParameters["TelemetryDeck.Purchase.currencyCode"] = currencyCode + params[DefaultParams.Purchase.currencyCode.rawValue] = currencyCode } } else { - if let currencyCode = self.currencyCode { - purchaseParameters["TelemetryDeck.Purchase.currencyCode"] = currencyCode - } + #if !os(visionOS) + if let currencyCode = self.currencyCode { + params[DefaultParams.Purchase.currencyCode.rawValue] = currencyCode + } + #endif } - - return purchaseParameters + return params } func priceInUSD() -> Double { - let priceValueInNativeCurrency = NSDecimalNumber(decimal: self.price ?? Decimal()).doubleValue - let priceValueInUSD: Double + let nativePrice = NSDecimalNumber(decimal: self.price ?? Decimal()).doubleValue if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { if self.currency?.identifier == "USD" { - priceValueInUSD = priceValueInNativeCurrency - } else if let currencyCode = self.currency?.identifier, - let oneUSDExchangeRate = Self.currencyCodeToOneUSDExchangeRate[currencyCode] + return nativePrice + } else if let code = self.currency?.identifier, + let rate = Self.currencyCodeToOneUSDExchangeRate[code] { - priceValueInUSD = priceValueInNativeCurrency / oneUSDExchangeRate + return nativePrice / rate } else { - priceValueInUSD = 0 + return 0 } } else { - if self.currencyCode == "USD" { - priceValueInUSD = priceValueInNativeCurrency - } else if let currencyCode = self.currencyCode, - let oneUSDExchangeRate = Self.currencyCodeToOneUSDExchangeRate[currencyCode] - { - priceValueInUSD = priceValueInNativeCurrency / oneUSDExchangeRate - } else { - priceValueInUSD = 0 - } + #if os(visionOS) + return nativePrice + #else + if self.currencyCode == "USD" { + return nativePrice + } else if let code = self.currencyCode, + let rate = Self.currencyCodeToOneUSDExchangeRate[code] + { + return nativePrice / rate + } else { + return 0 + } + #endif } - - return priceValueInUSD } private static let currencyCodeToOneUSDExchangeRate: [String: Double] = [ diff --git a/Sources/TelemetryDeck/Presets/TelemetryDeck+Errors.swift b/Sources/TelemetryDeck/Presets/TelemetryDeck+Errors.swift deleted file mode 100644 index 8931ad7..0000000 --- a/Sources/TelemetryDeck/Presets/TelemetryDeck+Errors.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation - -extension TelemetryDeck { - /// Sends a telemetry signal indicating that an error has occurred. - /// - /// - Parameters: - /// - id: A unique identifier for the error. - /// - category: An optional category for the error. Default is `nil`. - /// - message: An optional message describing the error. Default is `nil`. - /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. - /// - floatValue: An optional floating-point value to include with the signal. Default is `nil`. - /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. - public static func errorOccurred( - id: String, - category: ErrorCategory? = nil, - message: String? = nil, - parameters: [String: String] = [:], - floatValue: Double? = nil, - customUserID: String? = nil - ) { - var errorParameters: [String: String] = ["TelemetryDeck.Error.id": id] - - if let category { - errorParameters["TelemetryDeck.Error.category"] = category.rawValue - } - - if let message { - errorParameters["TelemetryDeck.Error.message"] = message - } - - self.internalSignal( - "TelemetryDeck.Error.occurred", - parameters: errorParameters.merging(parameters) { $1 }, - floatValue: floatValue, - customUserID: customUserID - ) - } - - /// Sends a telemetry signal indicating that an identifiable error has occurred. - /// - /// - Parameters: - /// - identifiableError: The error that conforms to `IdentifiableError`. Conform any error type by calling `.with(id:)` on it. - /// - category: The category of the error. Default is `.thrownException`. - /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. - /// - floatValue: An optional floating-point value to include with the signal. Default is `nil`. - /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. - public static func errorOccurred( - identifiableError: IdentifiableError, - category: ErrorCategory = .thrownException, - parameters: [String: String] = [:], - floatValue: Double? = nil, - customUserID: String? = nil - ) { - self.errorOccurred( - id: identifiableError.id, - category: category, - message: identifiableError.localizedDescription, - parameters: parameters, - floatValue: floatValue, - customUserID: customUserID - ) - } - - /// Sends a telemetry signal indicating that an identifiable error has occurred, with an optional message. - /// - /// - Parameters: - /// - identifiableError: The error that conforms to `IdentifiableError`. - /// - category: The category of the error. Default is `.thrownException`. - /// - message: An optional message describing the error. Default is `nil`. - /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. - /// - floatValue: An optional floating-point value to include with the signal. Default is `nil`. - /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. - /// - /// - Note: Use this overload if you want to provide a custom `message` parameter. Prefer ``errorOccurred(identifiableError:category:parameters:floatValue:customUserID:)`` to send - /// `error.localizedDescription` as the `message` automatically. - @_disfavoredOverload - public static func errorOccurred( - identifiableError: IdentifiableError, - category: ErrorCategory = .thrownException, - message: String? = nil, - parameters: [String: String] = [:], - floatValue: Double? = nil, - customUserID: String? = nil - ) { - self.errorOccurred( - id: identifiableError.id, - category: category, - message: message, - parameters: parameters, - floatValue: floatValue, - customUserID: customUserID - ) - } -} diff --git a/Sources/TelemetryDeck/Presets/TelemetryDeck+Navigation.swift b/Sources/TelemetryDeck/Presets/TelemetryDeck+Navigation.swift deleted file mode 100644 index ada9eef..0000000 --- a/Sources/TelemetryDeck/Presets/TelemetryDeck+Navigation.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation - -extension TelemetryDeck { - /// Send a signal that represents a navigation event with a source and a destination - /// - /// This is a convenience method that will internally send a completely normal TelemetryDeck signals with the type - /// `TelemetryDeck.Route.Transition.navigation` and the necessary parameters. - /// - /// Since TelemetryDeck navigation signals need a source and a destination, this method will store the last - /// used destination for use in the `navigate(to:)` method. - /// - /// ## Navigation Paths - /// Navigation Paths are strings that describe a location or view in your application or website. They must be - /// delineated by either `.` or `/` characters. Delineation characters at the beginning and end of the string are - /// ignored. Use the empty string `""` for navigation from outside the app. Examples are `index`, - /// `settings.user.changePassword`, or `/blog/ios-market-share`. - /// - /// - Parameters: - /// - from: The navigation path at the beginning of the navigation event, identifying the view the user is leaving - /// - to: The navigation path at the end of the navigation event, identifying the view the user is arriving at - /// - customUserID: An optional string specifying a custom user identifier. If provided, it will override the default user identifier from the configuration. Default is `nil`. - @MainActor - public static func navigationPathChanged(from source: String, to destination: String, customUserID: String? = nil) { - NavigationStatus.shared.previousNavigationPath = destination - - self.internalSignal( - "TelemetryDeck.Navigation.pathChanged", - parameters: [ - "TelemetryDeck.Navigation.schemaVersion": "1", - "TelemetryDeck.Navigation.identifier": "\(source) -> \(destination)", - "TelemetryDeck.Navigation.sourcePath": source, - "TelemetryDeck.Navigation.destinationPath": destination, - ], - customUserID: customUserID - ) - } - - /// Send a signal that represents a navigation event with a destination and a default source. - /// - /// This is a convenience method that will internally send a completely normal TelemetryDeck signals with the type - /// `TelemetryDeck.Route.Transition.navigation` and the necessary parameters. - /// - /// ## Navigation Paths - /// Navigation Paths are strings that describe a location or view in your application or website. They must be - /// delineated by either `.` or `/` characters. Delineation characters at the beginning and end of the string are - /// ignored. Use the empty string `""` for navigation from outside the app. Examples are `index`, - /// `settings.user.changePassword`, or `/blog/ios-market-share`. - /// - /// ## Automatic Navigation Tracking - /// Since TelemetryDeck navigation signals need a source and a destination, this method will keep track of the last - /// used destination and will automatically insert it as a source the next time you call this method. - /// - /// This is very convenient, but will produce incorrect graphs if you don't call it from every screen in your app. - /// Suppose you have 3 tabs "Home", "User" and "Settings", but only set up navigation in "Home" and "Settings". If - /// a user taps "Home", "User" and "Settings" in that order, that'll produce an incorrect navigation signal with - /// source "Home" and destination "Settings", a path that the user did not take. - /// - /// - Parameters: - /// - to: The navigation path representing the view the user is arriving at. - /// - customUserID: An optional string specifying a custom user identifier. If provided, it will override the default user identifier from the configuration. Default is `nil`. - @MainActor - public static func navigationPathChanged(to destination: String, customUserID: String? = nil) { - let source = NavigationStatus.shared.previousNavigationPath ?? "" - - Self.navigationPathChanged(from: source, to: destination, customUserID: customUserID) - } - - @MainActor - @available(*, unavailable, renamed: "navigationPathChanged(from:to:customUserID:)") - public static func navigate(from source: String, to destination: String, customUserID: String? = nil) { - self.navigationPathChanged(from: source, to: destination, customUserID: customUserID) - } - - @MainActor - @available(*, unavailable, renamed: "navigationPathChanged(to:customUserID:)") - public static func navigate(to destination: String, customUserID: String? = nil) { - self.navigationPathChanged(to: destination, customUserID: customUserID) - } -} diff --git a/Sources/TelemetryDeck/Presets/TrialConversionTracker.swift b/Sources/TelemetryDeck/Presets/TrialConversionTracker.swift deleted file mode 100644 index 83adb02..0000000 --- a/Sources/TelemetryDeck/Presets/TrialConversionTracker.swift +++ /dev/null @@ -1,130 +0,0 @@ -import StoreKit - -/// Responsible for tracking free trial subscriptions and detecting when they convert to paid subscriptions or are canceled. -/// -/// This class manages the lifecycle of free trials by: -/// - Storing information about the last active free trial in UserDefaults -/// - Monitoring StoreKit transactions for trial conversions and cancellations -/// - Sending telemetry signals when a trial converts to a paid subscription -/// -/// The API call needed to make outside it is this: -/// ``` -/// // When a free trial is started -/// TrialConversionTracker.shared.freeTrialStarted(transaction: transaction) -/// ``` -/// -/// This type automatically starts monitoring transactions during a free trial phase and stops doing so when no longer needed. -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -final class TrialConversionTracker: @unchecked Sendable { - private struct StoredTrial: Codable { - let productID: String - let originalTransactionID: UInt64 - } - - static let shared = TrialConversionTracker() - - private static let lastTrialKey = "lastTrial" - - private let persistenceQueue = DispatchQueue(label: "com.telemetrydeck.trialtracker.persistence") - private var transactionUpdateTask: Task? - - private var currentTrial: StoredTrial? { - get { - if let trialData = TelemetryDeck.customDefaults?.data(forKey: Self.lastTrialKey), - let trial = try? JSONDecoder().decode(StoredTrial.self, from: trialData) - { - return trial - } - - return nil - } - - set { - self.persistenceQueue.async { - if let trial = newValue, let encodedData = try? JSONEncoder().encode(trial) { - TelemetryDeck.customDefaults?.set(encodedData, forKey: Self.lastTrialKey) - } else { - TelemetryDeck.customDefaults?.removeObject(forKey: Self.lastTrialKey) - } - } - } - } - - private init() { - // Start observing transactions if there's an active trial - if currentTrial != nil { - self.startObservingTransactions() - } - } - - /// Call this function only after having validated that the passed transaction is a free trial. - func freeTrialStarted(transaction: Transaction) { - let trial = StoredTrial(productID: transaction.productID, originalTransactionID: transaction.originalID) - self.currentTrial = trial - self.startObservingTransactions() - } - - private func clearCurrentTrial() { - self.currentTrial = nil - self.stopObservingTransactions() - } - - private func startObservingTransactions() { - // Cancel any existing observation - self.stopObservingTransactions() - - // Start new observation - self.transactionUpdateTask = Task { - for await verificationResult in Transaction.updates { - // Check if transaction is verified - guard case .verified(let transaction) = verificationResult else { continue } - - // Check if this transaction matches our trial product - if let currentTrial = self.currentTrial, - transaction.productID == currentTrial.productID, - transaction.originalID == currentTrial.originalTransactionID - { - if transaction.revocationDate != nil - || transaction.expirationDate?.isInThePast == true - || transaction.isUpgraded - { - // Trial was canceled, has expired, or was upgraded – let's clean up & stop observing - self.clearCurrentTrial() - } else if !transaction.isFreeTrial { - // Trial converted to paid subscription - TelemetryDeck.internalSignal( - "TelemetryDeck.Purchase.convertedFromTrial", - parameters: transaction.purchaseParameters(), - floatValue: transaction.priceInUSD() - ) - - self.clearCurrentTrial() - } - } - } - } - } - - private func stopObservingTransactions() { - self.transactionUpdateTask?.cancel() - self.transactionUpdateTask = nil - } -} - -// Convenience extension to check trial status -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -extension Transaction { - var isFreeTrial: Bool { - if #available(iOS 17.2, macOS 14.2, tvOS 17.2, visionOS 1.1, watchOS 10.2, *) { - return self.offer?.type == .introductory && self.offer?.paymentMode == .freeTrial - } else { - return self.offerType == .introductory && self.offerPaymentModeStringRepresentation == "FREE_TRIAL" - } - } -} - -extension Date { - var isInThePast: Bool { - self.timeIntervalSinceNow < 0 - } -} diff --git a/Sources/TelemetryDeck/Processors/AccessibilityProcessor.swift b/Sources/TelemetryDeck/Processors/AccessibilityProcessor.swift new file mode 100644 index 0000000..06ac87a --- /dev/null +++ b/Sources/TelemetryDeck/Processors/AccessibilityProcessor.swift @@ -0,0 +1,163 @@ +import Foundation + +#if os(iOS) || os(tvOS) || os(visionOS) + import UIKit +#elseif os(macOS) + import AppKit +#elseif os(watchOS) + import WatchKit +#endif + +/// Enriches events with system accessibility settings and screen metrics. +public actor AccessibilityProcessor: EventProcessor { + #if os(iOS) || os(tvOS) || os(visionOS) + static func directionString(from direction: UIUserInterfaceLayoutDirection) -> String { + switch direction { + case .leftToRight: return "leftToRight" + case .rightToLeft: return "rightToLeft" + @unknown default: return "Unknown" + } + } + #elseif os(macOS) + static func directionString(from direction: NSUserInterfaceLayoutDirection) -> String { + switch direction { + case .leftToRight: return "leftToRight" + case .rightToLeft: return "rightToLeft" + @unknown default: return "Unknown" + } + } + #endif + private static let cacheLifetime: TimeInterval = 3600 + + private var cachedParams: EventParameters? + private var cacheTimestamp: Date? + + /// Creates an accessibility processor. + public init() {} + + /// Adds accessibility flags, screen dimensions, colour scheme, and layout direction to the context. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var context = context + + let params = await resolvedParams(isTestMode: context.isTestMode ?? false) + context.addParameters(params) + + return try await next(input, context) + } + + private func resolvedParams(isTestMode: Bool) async -> EventParameters { + if !isTestMode, + let cached = cachedParams, + let timestamp = cacheTimestamp, + Date().timeIntervalSince(timestamp) < Self.cacheLifetime + { + return cached + } + + let fresh = await readAccessibilityParams() + cachedParams = fresh + cacheTimestamp = Date() + return fresh + } + + private func readAccessibilityParams() async -> EventParameters { + #if os(iOS) || os(tvOS) || os(visionOS) + return await MainActor.run { () -> EventParameters in + var result = EventParameters() + + result[DefaultParams.Accessibility.isReduceMotionEnabled] = String(UIAccessibility.isReduceMotionEnabled) + result[DefaultParams.Accessibility.isBoldTextEnabled] = String(UIAccessibility.isBoldTextEnabled) + result[DefaultParams.Accessibility.isInvertColorsEnabled] = String(UIAccessibility.isInvertColorsEnabled) + result[DefaultParams.Accessibility.isDarkerSystemColorsEnabled] = String(UIAccessibility.isDarkerSystemColorsEnabled) + result[DefaultParams.Accessibility.isReduceTransparencyEnabled] = String(UIAccessibility.isReduceTransparencyEnabled) + result[DefaultParams.Accessibility.shouldDifferentiateWithoutColor] = String(UIAccessibility.shouldDifferentiateWithoutColor) + + if !Environment.isAppExtension { + result[DefaultParams.Accessibility.preferredContentSizeCategory] = UIApplication.shared.preferredContentSizeCategory.rawValue + .replacingOccurrences(of: "UICTContentSizeCategory", with: "") + } + + #if os(iOS) + let orientation: String + switch UIDevice.current.orientation { + case .portrait, .portraitUpsideDown: + orientation = "Portrait" + case .landscapeLeft, .landscapeRight: + orientation = "Landscape" + default: + orientation = "Unknown" + } + result[DefaultParams.Device.orientation] = orientation + #endif + + #if !os(visionOS) + let screen = UIScreen.main + result[DefaultParams.Device.screenResolutionWidth] = "\(screen.bounds.width)" + result[DefaultParams.Device.screenResolutionHeight] = "\(screen.bounds.height)" + result[DefaultParams.Device.screenScaleFactor] = "\(screen.scale)" + + let colorScheme: String + switch screen.traitCollection.userInterfaceStyle { + case .dark: + colorScheme = "Dark" + case .light: + colorScheme = "Light" + default: + colorScheme = "N/A" + } + result[DefaultParams.UserPreference.colorScheme] = colorScheme + #endif + + if !Environment.isAppExtension { + let direction = UIApplication.shared.userInterfaceLayoutDirection + result[DefaultParams.UserPreference.layoutDirection] = Self.directionString(from: direction) + } + + return result + } + + #elseif os(macOS) + return await MainActor.run { () -> EventParameters in + var result = EventParameters() + + result[DefaultParams.Accessibility.isReduceMotionEnabled] = String(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion) + result[DefaultParams.Accessibility.isInvertColorsEnabled] = String(NSWorkspace.shared.accessibilityDisplayShouldInvertColors) + + let colorScheme: String + let appearance = NSApp?.effectiveAppearance.name.rawValue.lowercased() ?? "" + if appearance.contains("dark") { + colorScheme = "Dark" + } else { + colorScheme = "Light" + } + result[DefaultParams.UserPreference.colorScheme] = colorScheme + + if let layoutDirection = NSApp?.userInterfaceLayoutDirection { + result[DefaultParams.UserPreference.layoutDirection] = Self.directionString(from: layoutDirection) + } + + if let screen = NSScreen.main { + result[DefaultParams.Device.screenResolutionWidth] = "\(screen.frame.width)" + result[DefaultParams.Device.screenResolutionHeight] = "\(screen.frame.height)" + result[DefaultParams.Device.screenScaleFactor] = "\(screen.backingScaleFactor)" + } + + return result + } + + #elseif os(watchOS) + let device = WKInterfaceDevice.current() + var result = EventParameters() + result[DefaultParams.Device.screenResolutionWidth] = Double(device.screenBounds.width) + result[DefaultParams.Device.screenResolutionHeight] = Double(device.screenBounds.height) + return result + + #else + return EventParameters() + #endif + } +} diff --git a/Sources/TelemetryDeck/Processors/AppInfoProcessor.swift b/Sources/TelemetryDeck/Processors/AppInfoProcessor.swift new file mode 100644 index 0000000..d2c2d76 --- /dev/null +++ b/Sources/TelemetryDeck/Processors/AppInfoProcessor.swift @@ -0,0 +1,42 @@ +import Foundation + +let sdkVersion = "3.0.0" + +/// Enriches events with app version, build number, and SDK version metadata. +public struct AppInfoProcessor: EventProcessor { + private let cachedParameters: EventParameters + + /// Creates a processor and caches app and SDK version information from the main bundle. + public init() { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0" + + var params: EventParameters = [ + DefaultParams.AppInfo.version.rawValue: appVersion, + DefaultParams.AppInfo.buildNumber.rawValue: buildNumber, + DefaultParams.AppInfo.versionAndBuildNumber.rawValue: "\(appVersion) (build \(buildNumber))", + DefaultParams.SDK.name.rawValue: "SwiftSDK", + DefaultParams.SDK.version.rawValue: sdkVersion, + DefaultParams.SDK.nameAndVersion.rawValue: "SwiftSDK \(sdkVersion)", + ] + + if let container = Bundle.main.infoDictionary?["NSExtension"] as? [String: Any], + let extensionID = container["NSExtensionPointIdentifier"] as? String + { + params[DefaultParams.RunContext.extensionIdentifier] = extensionID + } + + self.cachedParameters = params + } + + /// Adds cached app and SDK version parameters to the context. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var context = context + context.addParameters(cachedParameters) + return try await next(input, context) + } +} diff --git a/Sources/TelemetryDeck/Processors/CalendarProcessor.swift b/Sources/TelemetryDeck/Processors/CalendarProcessor.swift new file mode 100644 index 0000000..05cb160 --- /dev/null +++ b/Sources/TelemetryDeck/Processors/CalendarProcessor.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Enriches events with Gregorian calendar context derived from the event timestamp. +public struct CalendarProcessor: EventProcessor { + /// Creates a calendar processor. + public init() {} + + /// Adds day, week, month, quarter, hour, and weekend flag parameters to the context. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var context = context + + let calendar = Calendar(identifier: .gregorian) + let nowDate = input.timestamp + let components = calendar.dateComponents([.day, .weekday, .weekOfYear, .month, .hour, .quarter, .yearForWeekOfYear], from: nowDate) + let dayOfYear = calendar.ordinality(of: .day, in: .year, for: nowDate) ?? -1 + let dayOfWeek = components.weekday.map { $0 == 1 ? 7 : $0 - 1 } ?? -1 + let isWeekend = dayOfWeek >= 6 + + context.addParameter(DefaultParams.Calendar.dayOfMonth, value: components.day ?? -1) + context.addParameter(DefaultParams.Calendar.dayOfWeek, value: dayOfWeek) + context.addParameter(DefaultParams.Calendar.dayOfYear, value: dayOfYear) + context.addParameter(DefaultParams.Calendar.weekOfYear, value: components.weekOfYear ?? -1) + context.addParameter(DefaultParams.Calendar.isWeekend, value: isWeekend) + context.addParameter(DefaultParams.Calendar.monthOfYear, value: components.month ?? -1) + context.addParameter(DefaultParams.Calendar.quarterOfYear, value: components.quarter ?? -1) + context.addParameter(DefaultParams.Calendar.hourOfDay, value: (components.hour ?? -1) + 1) + + return try await next(input, context) + } +} diff --git a/Sources/TelemetryDeck/Processors/DefaultParametersProcessor.swift b/Sources/TelemetryDeck/Processors/DefaultParametersProcessor.swift new file mode 100644 index 0000000..0311be2 --- /dev/null +++ b/Sources/TelemetryDeck/Processors/DefaultParametersProcessor.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Merges the configured default parameters into every event's input parameters so they pass through the full pipeline. +public struct DefaultParametersProcessor: EventProcessor { + private let parameters: EventParameters + + /// Creates a default parameters processor with the given parameters. + public init(parameters: EventParameters = [:]) { + self.parameters = parameters + } + + /// Prepends the configured default parameters to the event input, allowing subsequent processors and user-supplied parameters to override them. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var input = input + var merged = parameters + merged.merge(input.parameters) + input.parameters = merged + return try await next(input, context) + } +} diff --git a/Sources/TelemetryDeck/Processors/DefaultPrefixProcessor.swift b/Sources/TelemetryDeck/Processors/DefaultPrefixProcessor.swift new file mode 100644 index 0000000..7d61912 --- /dev/null +++ b/Sources/TelemetryDeck/Processors/DefaultPrefixProcessor.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Applies the configured event name prefix and parameter prefix to events that are not already prefixed. +public struct DefaultPrefixProcessor: EventProcessor { + private let eventPrefix: String? + private let parameterPrefix: String? + + /// Creates a default prefix processor with optional event and parameter prefixes. + public init(eventPrefix: String? = nil, parameterPrefix: String? = nil) { + self.eventPrefix = eventPrefix + self.parameterPrefix = parameterPrefix + } + + /// Prepends the configured event and parameter prefixes where applicable. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var input = input + + if let eventPrefix, + !input.name.hasPrefix(eventPrefix), + !input.name.hasPrefix("TelemetryDeck.") + { + input.name = eventPrefix + input.name + } + + if let parameterPrefix { + var prefixed = EventParameters() + for (key, value) in input.parameters { + if key.hasPrefix("TelemetryDeck.") { + prefixed[key] = value + } else { + prefixed[parameterPrefix + key] = value + } + } + input.parameters = prefixed + } + + return try await next(input, context) + } +} diff --git a/Sources/TelemetryDeck/Processors/DeviceProcessor.swift b/Sources/TelemetryDeck/Processors/DeviceProcessor.swift new file mode 100644 index 0000000..5e308f6 --- /dev/null +++ b/Sources/TelemetryDeck/Processors/DeviceProcessor.swift @@ -0,0 +1,178 @@ +import Foundation + +#if os(macOS) + import IOKit +#endif + +/// Enriches events with device model, OS version, architecture, timezone, and run context metadata. +public struct DeviceProcessor: EventProcessor { + private let cachedParameters: EventParameters + + /// Creates a processor and caches device and environment information at init time. + public init() { + var parameters = EventParameters() + + let platform: String = { + #if os(macOS) + return "macOS" + #elseif os(visionOS) + return "visionOS" + #elseif os(iOS) + #if targetEnvironment(macCatalyst) + return "macCatalyst" + #else + if #available(iOS 14.0, *), ProcessInfo.processInfo.isiOSAppOnMac { + return "isiOSAppOnMac" + } else { + return "iOS" + } + #endif + #elseif os(watchOS) + return "watchOS" + #elseif os(tvOS) + return "tvOS" + #else + return "Unknown Platform" + #endif + }() + parameters[DefaultParams.Device.platform] = platform + + let operatingSystem: String = { + #if os(macOS) + return "macOS" + #elseif os(iOS) + return "iOS" + #elseif os(watchOS) + return "watchOS" + #elseif os(tvOS) + return "tvOS" + #elseif os(visionOS) + return "visionOS" + #else + return "Unknown" + #endif + }() + parameters[DefaultParams.Device.operatingSystem] = operatingSystem + + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + parameters[DefaultParams.Device.systemVersion] = + "\(operatingSystem) \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" + parameters[DefaultParams.Device.systemMajorVersion] = "\(operatingSystem) \(osVersion.majorVersion)" + parameters[DefaultParams.Device.systemMajorMinorVersion] = "\(operatingSystem) \(osVersion.majorVersion).\(osVersion.minorVersion)" + + let modelName = Self.getModelName() + parameters[DefaultParams.Device.modelName] = modelName + + let architecture: String = { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + return machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + }() + parameters[DefaultParams.Device.architecture] = architecture + + parameters[DefaultParams.Device.timeZone] = TimezoneFormatting.utcOffsetString() + + #if targetEnvironment(simulator) + parameters[DefaultParams.RunContext.isSimulator] = "true" + #else + parameters[DefaultParams.RunContext.isSimulator] = "false" + #endif + + #if DEBUG + parameters[DefaultParams.RunContext.isDebug] = "true" + #else + parameters[DefaultParams.RunContext.isDebug] = "false" + #endif + + let isTestFlight: Bool = { + #if DEBUG + return false + #elseif targetEnvironment(simulator) + return false + #else + guard let appStoreReceiptURL = Bundle.main.appStoreReceiptURL else { return false } + return appStoreReceiptURL.lastPathComponent == "sandboxReceipt" + #endif + }() + parameters[DefaultParams.RunContext.isTestFlight] = isTestFlight ? "true" : "false" + + let isAppStore: Bool = { + #if DEBUG + return false + #else + #if targetEnvironment(simulator) + return false + #else + return !isTestFlight + #endif + #endif + }() + parameters[DefaultParams.RunContext.isAppStore] = isAppStore ? "true" : "false" + + let targetEnvironment: String = { + #if targetEnvironment(simulator) + return "simulator" + #elseif targetEnvironment(macCatalyst) + return "macCatalyst" + #else + return "native" + #endif + }() + parameters[DefaultParams.RunContext.targetEnvironment] = targetEnvironment + + self.cachedParameters = parameters + } + + private static func getModelName() -> String { + #if os(iOS) + if #available(iOS 14.0, *) { + if ProcessInfo.processInfo.isiOSAppOnMac { + var size = 0 + sysctlbyname("hw.model", nil, &size, nil, 0) + var machine = [CChar](repeating: 0, count: size) + sysctlbyname("hw.model", &machine, &size, nil, 0) + return String(cString: machine) + } + } + #endif + #if os(macOS) + if #available(macOS 11, *) { + #if compiler(>=6.0) + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")) + #else + let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice")) + #endif + var modelIdentifier: String? + if let modelData = IORegistryEntryCreateCFProperty(service, "model" as CFString, kCFAllocatorDefault, 0).takeRetainedValue() as? Data + { + modelIdentifier = String(decoding: modelData.prefix(while: { $0 != 0 }), as: UTF8.self) + } + IOObjectRelease(service) + if let modelIdentifier { return modelIdentifier } + } + #endif + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + return identifier + } + + /// Adds cached device metadata parameters to the context. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var context = context + context.addParameters(cachedParameters) + return try await next(input, context) + } +} diff --git a/Sources/TelemetryDeck/Processors/LocaleProcessor.swift b/Sources/TelemetryDeck/Processors/LocaleProcessor.swift new file mode 100644 index 0000000..c4b5685 --- /dev/null +++ b/Sources/TelemetryDeck/Processors/LocaleProcessor.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Enriches events with the current locale, language, preferred language, and region. +public struct LocaleProcessor: EventProcessor { + /// Creates a locale processor. + public init() {} + + /// Adds locale, language, and region parameters to the context. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var context = context + + let locale = Locale.current + context.addParameter(DefaultParams.RunContext.locale, value: locale.identifier) + + let appLanguage: String + if #available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) { + appLanguage = locale.language.languageCode?.identifier ?? locale.identifier.components(separatedBy: .init(charactersIn: "-_"))[0] + } else { + appLanguage = locale.languageCode ?? locale.identifier.components(separatedBy: .init(charactersIn: "-_"))[0] + } + context.addParameter(DefaultParams.RunContext.language, value: appLanguage) + + let preferredLocaleIdentifier = Locale.preferredLanguages.first ?? "zz-ZZ" + let preferredLanguage = preferredLocaleIdentifier.components(separatedBy: .init(charactersIn: "-_"))[0] + context.addParameter(DefaultParams.UserPreference.language, value: preferredLanguage) + + let region: String + if #available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) { + region = locale.region?.identifier ?? locale.identifier.components(separatedBy: .init(charactersIn: "-_")).last! + } else { + region = locale.regionCode ?? locale.identifier.components(separatedBy: .init(charactersIn: "-_")).last! + } + context.addParameter(DefaultParams.UserPreference.region, value: region) + + return try await next(input, context) + } +} diff --git a/Sources/TelemetryDeck/Processors/PreviewFilterProcessor.swift b/Sources/TelemetryDeck/Processors/PreviewFilterProcessor.swift new file mode 100644 index 0000000..8b5489a --- /dev/null +++ b/Sources/TelemetryDeck/Processors/PreviewFilterProcessor.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Filters out events when the app is running inside an Xcode SwiftUI preview. +public struct PreviewFilterProcessor: EventProcessor { + private let isPreviewMode: Bool + + /// Creates a processor that detects preview mode from the process environment. + public init() { + self.isPreviewMode = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } + + /// Throws `ProcessorError.eventFiltered` when running inside an Xcode preview. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + guard !isPreviewMode else { + throw ProcessorError.eventFiltered + } + return try await next(input, context) + } +} diff --git a/Sources/TelemetryDeck/Processors/SessionTrackingProcessor.swift b/Sources/TelemetryDeck/Processors/SessionTrackingProcessor.swift new file mode 100644 index 0000000..6df6ad3 --- /dev/null +++ b/Sources/TelemetryDeck/Processors/SessionTrackingProcessor.swift @@ -0,0 +1,296 @@ +import Foundation + +/// Manages session identity, retention metrics, and new-install detection. +public actor SessionTrackingProcessor: EventProcessor, SessionManaging { + private struct StoredSession: Codable { + let startedAt: Date + var durationInSeconds: Int + + private enum CodingKeys: String, CodingKey { + case startedAt = "st" + case durationInSeconds = "dn" + } + } + + private static let backgroundThreshold: TimeInterval = 5 * 60 + + private let sendSessionStartedEvent: Bool + + private var currentSession: UUID + private var backgroundDate: Date? + private var lifecycleTask: Task? + + private var storage: (any ProcessorStorage)? + private var emitter: (any EventSending)? + + private var recentSessions: [StoredSession] = [] + private var deletedSessionsCount: Int = 0 + private var firstSessionDate: String? + private var distinctDaysUsed: Set = [] + + private var currentSessionStart: Date? + private var currentSessionPausedAt: Date? + private var currentSessionAccumulatedSeconds: Int = 0 + + private var isNewInstall = false + + private var pendingPersistTasks: [Task] = [] + + /// Creates a session tracking processor. + public init(sendSessionStartedEvent: Bool = true) { + self.sendSessionStartedEvent = sendSessionStartedEvent + self.currentSession = UUID() + } + + /// Returns the identifier of the current session. + public func currentSessionID() async -> UUID { + currentSession + } + + /// Generates a new session identifier, records the session start, and emits a session-started event if configured. + @discardableResult + public func startNewSession() async -> UUID { + let newID = UUID() + currentSession = newID + recordSessionStart() + if sendSessionStartedEvent { + await emitter?.send(EventInput(DefaultEvents.Session.started.rawValue, skipsReservedPrefixValidation: true)) + } + return newID + } + + /// Restores persisted state, subscribes to lifecycle events, and emits startup events. + public func start(storage: any ProcessorStorage, logger: any Logging, emitter: any EventSending) async { + self.storage = storage + self.emitter = emitter + + await V2DataMigrator.migrateIfNeeded(storage: storage) + + if let data = await storage.data(forKey: "recentSessions"), + let sessions = try? JSONDecoder().decode([StoredSession].self, from: data) + { + recentSessions = sessions + } + + deletedSessionsCount = await storage.integer(forKey: "deletedSessionsCount") + firstSessionDate = await storage.string(forKey: "firstSessionDate") + + if let daysData = await storage.data(forKey: "distinctDaysUsed"), + let days = try? JSONDecoder().decode(Set.self, from: daysData) + { + distinctDaysUsed = days + } + + cleanOldSessions() + updateDistinctDays() + + let existingInstallID = await storage.string(forKey: "installID") + if existingInstallID == nil { + await storage.set(UUID().uuidString, forKey: "installID") + isNewInstall = true + } + + lifecycleTask = Task { + for await event in LifecycleNotifier.events() { + switch event { + case .background: + handleBackground() + case .foreground: + await handleForeground() + case .termination: + break + } + } + } + + recordSessionStart() + + if isNewInstall { + let dateStr = firstSessionDate ?? dateString(from: Date()) + await emitter.send( + EventInput( + DefaultEvents.Acquisition.newInstallDetected.rawValue, + parameters: [DefaultParams.Acquisition.firstSessionDate.rawValue: dateStr], + skipsReservedPrefixValidation: true + ) + ) + } + + if sendSessionStartedEvent { + await emitter.send(EventInput(DefaultEvents.Session.started.rawValue, skipsReservedPrefixValidation: true)) + } + } + + /// Cancels the lifecycle subscription, awaits all pending persistence tasks, and releases the event emitter reference. + public func stop() async { + lifecycleTask?.cancel() + lifecycleTask = nil + for task in pendingPersistTasks { + await task.value + } + pendingPersistTasks.removeAll() + emitter = nil + } + + /// Attaches session identity, retention metrics, and new-install flag to the event context. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var context = context + context.sessionID = currentSession + + if let firstDate = firstSessionDate { + context.addParameter(DefaultParams.Acquisition.firstSessionDate, value: firstDate) + } + + let totalCount = recentSessions.count + deletedSessionsCount + context.addParameter(DefaultParams.Retention.totalSessionsCount, value: totalCount) + context.addParameter(DefaultParams.Retention.distinctDaysUsed, value: distinctDaysUsed.count) + + let thirtyDaysAgo = dateString(from: Date().addingTimeInterval(-30 * 24 * 3600)) + let recentDays = distinctDaysUsed.filter { $0 >= thirtyDaysAgo } + context.addParameter(DefaultParams.Retention.distinctDaysUsedLastMonth, value: recentDays.count) + + let completedSessions = currentSessionStart == nil ? recentSessions : Array(recentSessions.dropLast()) + let averageSessionSeconds: Int + if completedSessions.isEmpty { + averageSessionSeconds = -1 + } else { + let totalSeconds = completedSessions.reduce(0) { $0 + $1.durationInSeconds } + averageSessionSeconds = Int(Double(totalSeconds) / Double(completedSessions.count)) + } + context.addParameter(DefaultParams.Retention.averageSessionSeconds, value: averageSessionSeconds) + + if recentSessions.count >= 2 { + let previousSession = recentSessions[recentSessions.count - 2] + context.addParameter( + DefaultParams.Retention.previousSessionSeconds, + value: previousSession.durationInSeconds + ) + } + + let wasNewInstall = isNewInstall + if wasNewInstall { + context.addParameter(DefaultParams.Acquisition.isNewInstall, value: true) + } + + let event = try await next(input, context) + if wasNewInstall { + isNewInstall = false + } + return event + } + + private func schedulePersist(_ work: @Sendable @escaping () async -> Void) { + pendingPersistTasks.removeAll { $0.isCancelled } + pendingPersistTasks.append(Task { await work() }) + } + + private func recordSessionStart() { + let now = Date() + currentSessionStart = now + currentSessionPausedAt = nil + currentSessionAccumulatedSeconds = 0 + + let dateStr = dateString(from: now) + if firstSessionDate == nil { + firstSessionDate = dateStr + schedulePersist { await self.persistFirstSessionDate() } + } + + distinctDaysUsed.insert(dateStr) + schedulePersist { await self.persistDistinctDays() } + + let newSession = StoredSession(startedAt: now, durationInSeconds: 0) + recentSessions.append(newSession) + schedulePersist { await self.persistSessions() } + } + + private func handleBackground() { + let now = Date() + backgroundDate = now + + guard let start = currentSessionStart else { return } + currentSessionPausedAt = now + + let elapsed = Int(now.timeIntervalSince(start)) - currentSessionAccumulatedSeconds + if var lastSession = recentSessions.last { + lastSession.durationInSeconds += elapsed + recentSessions[recentSessions.count - 1] = lastSession + schedulePersist { await self.persistSessions() } + } + currentSessionAccumulatedSeconds += elapsed + } + + private func handleForeground() async { + let didRotate: Bool + if let bgDate = backgroundDate, Date().timeIntervalSince(bgDate) > Self.backgroundThreshold { + backgroundDate = nil + if var lastSession = recentSessions.last, currentSessionAccumulatedSeconds > 0 { + lastSession.durationInSeconds = currentSessionAccumulatedSeconds + recentSessions[recentSessions.count - 1] = lastSession + schedulePersist { await self.persistSessions() } + } + currentSession = UUID() + recordSessionStart() + didRotate = true + } else { + backgroundDate = nil + currentSessionPausedAt = nil + didRotate = false + } + + if didRotate, sendSessionStartedEvent { + await emitter?.send(EventInput(DefaultEvents.Session.started.rawValue, skipsReservedPrefixValidation: true)) + } + } + + private func cleanOldSessions() { + let cutoff = Date().addingTimeInterval(-90 * 24 * 3600) + let kept = recentSessions.filter { $0.startedAt >= cutoff } + let removed = recentSessions.count - kept.count + if removed > 0 { + deletedSessionsCount += removed + recentSessions = kept + schedulePersist { + await self.persistSessions() + await self.persistDeletedCount() + } + } + } + + private func updateDistinctDays() { + var days = Set() + for session in recentSessions { + days.insert(dateString(from: session.startedAt)) + } + distinctDaysUsed = days + schedulePersist { await self.persistDistinctDays() } + } + + private func dateString(from date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate] + return formatter.string(from: date) + } + + private func persistSessions() async { + guard let data = try? JSONEncoder().encode(recentSessions) else { return } + await storage?.set(data, forKey: "recentSessions") + } + + private func persistDeletedCount() async { + await storage?.set(deletedSessionsCount, forKey: "deletedSessionsCount") + } + + private func persistFirstSessionDate() async { + await storage?.set(firstSessionDate, forKey: "firstSessionDate") + } + + private func persistDistinctDays() async { + guard let data = try? JSONEncoder().encode(distinctDaysUsed) else { return } + await storage?.set(data, forKey: "distinctDaysUsed") + } +} diff --git a/Sources/TelemetryDeck/Processors/TestModeProcessor.swift b/Sources/TelemetryDeck/Processors/TestModeProcessor.swift new file mode 100644 index 0000000..240ca7f --- /dev/null +++ b/Sources/TelemetryDeck/Processors/TestModeProcessor.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Determines whether events should be marked as test-mode, defaulting to `DEBUG` build configuration. +public struct TestModeProcessor: EventProcessor, TestModeProviding { + private let override: Bool? + + /// Creates a test mode processor with an optional explicit override. + public init(override: Bool? = nil) { + self.override = override + } + + /// Returns `true` when test mode is active, either via override or in DEBUG builds. + public func isTestMode() async -> Bool { + if let override { return override } + #if DEBUG + return true + #else + return false + #endif + } + + /// Sets the `isTestMode` flag on the context and passes through. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var context = context + context.isTestMode = await isTestMode() + return try await next(input, context) + } +} diff --git a/Sources/TelemetryDeck/Processors/TrialConversionProcessor.swift b/Sources/TelemetryDeck/Processors/TrialConversionProcessor.swift new file mode 100644 index 0000000..1d64722 --- /dev/null +++ b/Sources/TelemetryDeck/Processors/TrialConversionProcessor.swift @@ -0,0 +1,110 @@ +#if canImport(StoreKit) + import Foundation + import StoreKit + + /// Monitors StoreKit transaction updates to detect when a user converts from a free trial to a paid subscription. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public actor TrialConversionProcessor: EventProcessor { + private struct StoredTrial: Codable { + let productID: String + let originalTransactionID: UInt64 + } + + private static let lastTrialKey = "lastTrial" + + private var storage: (any ProcessorStorage)? + private var currentTrial: StoredTrial? + private var transactionUpdateTask: Task? + private var emitter: (any EventSending)? + + /// Creates a trial conversion processor. + public init() {} + + /// Restores any persisted trial state and starts observing StoreKit transaction updates. + public func start(storage: any ProcessorStorage, logger: any Logging, emitter: any EventSending) async { + self.storage = storage + self.emitter = emitter + if let data = await storage.data(forKey: Self.lastTrialKey), + let trial = try? JSONDecoder().decode(StoredTrial.self, from: data) + { + currentTrial = trial + startObservingTransactions() + } + } + + /// Stops observing StoreKit transaction updates. + public func stop() async { + stopObservingTransactions() + emitter = nil + } + + /// Passes the event through unchanged; trial conversion detection happens via StoreKit observers. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + try await next(input, context) + } + + /// Records the start of a free trial and begins watching for a conversion transaction. + public func freeTrialStarted(transaction: Transaction) { + let trial = StoredTrial(productID: transaction.productID, originalTransactionID: transaction.originalID) + currentTrial = trial + Task { await persistCurrentTrial() } + startObservingTransactions() + } + + private func clearCurrentTrial() async { + currentTrial = nil + await storage?.set(nil as Data?, forKey: Self.lastTrialKey) + stopObservingTransactions() + } + + private func persistCurrentTrial() async { + guard let trial = currentTrial, + let data = try? JSONEncoder().encode(trial) + else { return } + await storage?.set(data, forKey: Self.lastTrialKey) + } + + private func startObservingTransactions() { + stopObservingTransactions() + transactionUpdateTask = Task { [weak self] in + for await verificationResult in Transaction.updates { + guard let self else { return } + guard case .verified(let transaction) = verificationResult else { continue } + + let trial = await self.currentTrial + guard let trial, + transaction.productID == trial.productID, + transaction.originalID == trial.originalTransactionID + else { continue } + + if transaction.revocationDate != nil + || (transaction.expirationDate.map { $0.timeIntervalSinceNow < 0 } ?? false) + || transaction.isUpgraded + { + await self.clearCurrentTrial() + } else if !transaction.isFreeTrial { + let params = transaction.purchaseParameters() + let usdValue = transaction.priceInUSD() + let input = EventInput( + DefaultEvents.Purchase.convertedFromTrial.rawValue, + parameters: EventParameters(params), + floatValue: usdValue, + skipsReservedPrefixValidation: true + ) + await self.emitter?.send(input) + await self.clearCurrentTrial() + } + } + } + } + + private func stopObservingTransactions() { + transactionUpdateTask?.cancel() + transactionUpdateTask = nil + } + } +#endif diff --git a/Sources/TelemetryDeck/Processors/UserIdentifierProcessor.swift b/Sources/TelemetryDeck/Processors/UserIdentifierProcessor.swift new file mode 100644 index 0000000..4cafe64 --- /dev/null +++ b/Sources/TelemetryDeck/Processors/UserIdentifierProcessor.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Resolves and attaches the effective user identifier to each event context. +public actor UserIdentifierProcessor: EventProcessor, UserIdentifierManaging { + private let defaultUser: String? + private var explicitUserID: String? + private var cachedDefaultID: String? + + /// Creates a user identifier processor with an optional default user identifier. + public init(defaultUser: String? = nil) { + self.defaultUser = defaultUser + } + + /// Resolves and caches the default user identifier from persistent storage. + public func start(storage: any ProcessorStorage, logger: any Logging, emitter: any EventSending) async { + if let defaultUser { + cachedDefaultID = defaultUser + } else { + cachedDefaultID = await UserIdentifier.resolveDefaultUserIdentifier(storage: storage) + } + } + + /// Returns the explicitly set identifier, falling back to the cached default. + public func currentUserIdentifier() async -> String? { + explicitUserID ?? cachedDefaultID + } + + /// Sets an explicit user identifier that overrides the default for all subsequent events. + public func setUserIdentifier(_ value: String?) async { + explicitUserID = value + } + + /// Resolves the effective user identifier and attaches it to the event context. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var context = context + if let customID = input.customUserID { + context.userIdentifier = customID + } else if let explicitID = explicitUserID { + context.userIdentifier = explicitID + } else { + context.userIdentifier = cachedDefaultID ?? "unknown user" + } + return try await next(input, context) + } +} diff --git a/Sources/TelemetryDeck/Processors/ValidationProcessor.swift b/Sources/TelemetryDeck/Processors/ValidationProcessor.swift new file mode 100644 index 0000000..b08ee31 --- /dev/null +++ b/Sources/TelemetryDeck/Processors/ValidationProcessor.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Warns when event names or parameter keys use reserved TelemetryDeck identifiers. +public actor ValidationProcessor: EventProcessor { + private static let reservedKeysLowercased: Set = Set( + [ + "type", "clientUser", "appID", "sessionID", "floatValue", + "newSessionBegan", "platform", "systemVersion", "majorSystemVersion", "majorMinorSystemVersion", + "appVersion", "buildNumber", "isSimulator", "isDebug", "isTestFlight", "isAppStore", + "modelName", "architecture", "operatingSystem", "targetEnvironment", + "locale", "region", "appLanguage", "preferredLanguage", "telemetryClientVersion", + "extensionIdentifier", + ].map { $0.lowercased() } + ) + + private var logger: any Logging = DefaultLogger() + + /// Creates a validation processor. + public init() {} + + /// Captures the logger for use during event processing. + public func start(storage: any ProcessorStorage, logger: any Logging, emitter: any EventSending) async { + self.logger = logger + } + + /// Logs warnings for reserved event names or parameter keys, then passes through. + public func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + let nameLowercased = input.name.lowercased() + if !input.skipsReservedPrefixValidation && nameLowercased.hasPrefix("telemetrydeck.") { + logger.log(.error, "Event name '\(input.name)' uses reserved prefix 'TelemetryDeck.'") + } else if Self.reservedKeysLowercased.contains(nameLowercased) { + logger.log(.error, "Event name '\(input.name)' is a reserved name") + } + + for key in input.parameters.keys { + let keyLowercased = key.lowercased() + if !input.skipsReservedPrefixValidation && keyLowercased.hasPrefix("telemetrydeck.") { + logger.log(.error, "Parameter key '\(key)' uses reserved prefix 'TelemetryDeck.'") + } else if Self.reservedKeysLowercased.contains(keyLowercased) { + logger.log(.error, "Parameter key '\(key)' is a reserved name") + } + } + + return try await next(input, context) + } +} diff --git a/Sources/TelemetryDeck/Signals/Signal+Helpers.swift b/Sources/TelemetryDeck/Signals/Signal+Helpers.swift deleted file mode 100644 index 9ac52dc..0000000 --- a/Sources/TelemetryDeck/Signals/Signal+Helpers.swift +++ /dev/null @@ -1,440 +0,0 @@ -import Foundation - -#if os(iOS) - import UIKit -#elseif os(macOS) - import AppKit - import IOKit -#elseif os(watchOS) - import WatchKit -#elseif os(tvOS) - import TVUIKit -#endif - -extension DefaultSignalPayload { - - static var calendarParameters: [String: String] { - let calendar = Calendar(identifier: .gregorian) - let nowDate = Date() - - // Get components for all the metrics we need - let components = calendar.dateComponents( - [.day, .weekday, .weekOfYear, .month, .hour, .quarter, .yearForWeekOfYear], - from: nowDate - ) - - // Calculate day of year - let dayOfYear = calendar.ordinality(of: .day, in: .year, for: nowDate) ?? -1 - - // Convert Sunday=1..Saturday=7 to Monday=1..Sunday=7 - let dayOfWeek = components.weekday.map { $0 == 1 ? 7 : $0 - 1 } ?? -1 - - // Weekend is now days 6 (Saturday) and 7 (Sunday) - let isWeekend = dayOfWeek >= 6 - - return [ - // Day-based metrics - "TelemetryDeck.Calendar.dayOfMonth": "\(components.day ?? -1)", - "TelemetryDeck.Calendar.dayOfWeek": "\(dayOfWeek)", // 1 = Monday, 7 = Sunday - "TelemetryDeck.Calendar.dayOfYear": "\(dayOfYear)", - - // Week-based metrics - "TelemetryDeck.Calendar.weekOfYear": "\(components.weekOfYear ?? -1)", - "TelemetryDeck.Calendar.isWeekend": "\(isWeekend)", - - // Month and quarter - "TelemetryDeck.Calendar.monthOfYear": "\(components.month ?? -1)", - "TelemetryDeck.Calendar.quarterOfYear": "\(components.quarter ?? -1)", - - // Hours in 1-24 format - "TelemetryDeck.Calendar.hourOfDay": "\((components.hour ?? -1) + 1)", - ] - } - - @MainActor - static var accessibilityParameters: [String: String] { - var a11yParams: [String: String] = [:] - - #if os(iOS) || os(tvOS) - a11yParams["TelemetryDeck.Accessibility.isReduceMotionEnabled"] = "\(UIAccessibility.isReduceMotionEnabled)" - a11yParams["TelemetryDeck.Accessibility.isBoldTextEnabled"] = "\(UIAccessibility.isBoldTextEnabled)" - a11yParams["TelemetryDeck.Accessibility.isInvertColorsEnabled"] = "\(UIAccessibility.isInvertColorsEnabled)" - a11yParams["TelemetryDeck.Accessibility.isDarkerSystemColorsEnabled"] = "\(UIAccessibility.isDarkerSystemColorsEnabled)" - a11yParams["TelemetryDeck.Accessibility.isReduceTransparencyEnabled"] = "\(UIAccessibility.isReduceTransparencyEnabled)" - if #available(iOS 13.0, *) { - a11yParams["TelemetryDeck.Accessibility.shouldDifferentiateWithoutColor"] = "\(UIAccessibility.shouldDifferentiateWithoutColor)" - } - - // in app extensions `UIApplication.shared` is not available - if !TelemetryEnvironment.isAppExtension { - a11yParams["TelemetryDeck.Accessibility.preferredContentSizeCategory"] = UIApplication.shared.preferredContentSizeCategory.rawValue - .replacingOccurrences(of: "UICTContentSizeCategory", with: "") // replaces output "UICTContentSizeCategoryL" with "L" - } - #elseif os(macOS) - if let systemPrefs = UserDefaults.standard.persistentDomain(forName: "com.apple.universalaccess") { - a11yParams["TelemetryDeck.Accessibility.isReduceMotionEnabled"] = "\(systemPrefs["reduceMotion"] as? Bool ?? false)" - a11yParams["TelemetryDeck.Accessibility.isInvertColorsEnabled"] = "\(systemPrefs["InvertColors"] as? Bool ?? false)" - } - #endif - - return a11yParams - } - - static var isSimulatorOrTestFlight: Bool { - isSimulator || isTestFlight - } - - static var isSimulator: Bool { - #if targetEnvironment(simulator) - return true - #else - return false - #endif - } - - static var isDebug: Bool { - #if DEBUG - return true - #else - return false - #endif - } - - /// Detects if the app is running in a TestFlight environment. - /// This check is based on whether `Bundle.main.appStoreReceiptURL?.path` contains `sandboxReceipt`. - /// The property is always false when the `DEBUG` compiler flag has been set or when running in the simulator. - /// This check relies on the app receipt being present and available, otherwise it returns `false`. - /// - /// - Returns: `true` if running in TestFlight, `false` otherwise - static var isTestFlight: Bool { - guard !isDebug, !isSimulator else { return false } - guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false } - return receiptURL.lastPathComponent == "sandboxReceipt" || receiptURL.path.contains("sandboxReceipt") - } - - /// Detects if the app is running in an App Store production environment. - /// - /// Uses the same detection strategy as `isTestFlight` (see its documentation for details). - /// Returns `true` for App Store builds, `false` for debug, simulator, and TestFlight builds. - /// - /// - Returns: `true` if running in App Store production, `false` otherwise - static var isAppStore: Bool { - #if DEBUG - return false - #elseif TARGET_OS_OSX || TARGET_OS_MACCATALYST - return false - #elseif targetEnvironment(simulator) - return false - #else - return !isSimulatorOrTestFlight - #endif - } - - /// The operating system and its version - static var systemVersion: String { - let majorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion - let minorVersion = ProcessInfo.processInfo.operatingSystemVersion.minorVersion - let patchVersion = ProcessInfo.processInfo.operatingSystemVersion.patchVersion - return "\(platform) \(majorVersion).\(minorVersion).\(patchVersion)" - } - - /// The major system version, i.e. iOS 15 - static var majorSystemVersion: String { - "\(platform) \(ProcessInfo.processInfo.operatingSystemVersion.majorVersion)" - } - - /// The major system version, i.e. iOS 15 - static var majorMinorSystemVersion: String { - let majorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion - let minorVersion = ProcessInfo.processInfo.operatingSystemVersion.minorVersion - return "\(platform) \(majorVersion).\(minorVersion)" - } - - /// The Bundle Short Version String, as described in Info.plist - static var appVersion: String { - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - return appVersion ?? "0" - } - - /// The Bundle Version String, as described in Info.plist - static var buildNumber: String { - let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String - return buildNumber ?? "0" - } - - /// The extension identifer for the active resource, if available. - /// - /// This provides a value such as `com.apple.widgetkit-extension` when TelemetryDeck is run from a widget. - static var extensionIdentifier: String? { - let container = Bundle.main.infoDictionary?["NSExtension"] as? [String: Any] - return container?["NSExtensionPointIdentifier"] as? String - } - - /// The modelname as reported by systemInfo.machine - static var modelName: String { - #if os(iOS) - if #available(iOS 14.0, *) { - if ProcessInfo.processInfo.isiOSAppOnMac { - var size = 0 - sysctlbyname("hw.model", nil, &size, nil, 0) - var machine = [CChar](repeating: 0, count: size) - sysctlbyname("hw.model", &machine, &size, nil, 0) - return String(cString: machine) - } - } - #endif - - #if os(macOS) - if #available(macOS 11, *) { - let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice")) - var modelIdentifier: String? - - if let modelData = IORegistryEntryCreateCFProperty(service, "model" as CFString, kCFAllocatorDefault, 0).takeRetainedValue() as? Data - { - if let modelIdentifierCString = String(data: modelData, encoding: .utf8)?.cString(using: .utf8) { - modelIdentifier = String(cString: modelIdentifierCString) - } - } - - IOObjectRelease(service) - if let modelIdentifier = modelIdentifier { - return modelIdentifier - } - } - #endif - - var systemInfo = utsname() - uname(&systemInfo) - let machineMirror = Mirror(reflecting: systemInfo.machine) - let identifier = machineMirror.children.reduce("") { identifier, element in - guard let value = element.value as? Int8, value != 0 else { return identifier } - return identifier + String(UnicodeScalar(UInt8(value))) - } - return identifier - } - - /// The build architecture - static var architecture: String { - #if arch(x86_64) - return "x86_64" - #elseif arch(arm) - return "arm" - #elseif arch(arm64) - return "arm64" - #elseif arch(i386) - return "i386" - #elseif arch(powerpc64) - return "powerpc64" - #elseif arch(powerpc64le) - return "powerpc64le" - #elseif arch(s390x) - return "s390x" - #else - return "unknown" - #endif - } - - /// The operating system as reported by Swift. Note that this will report catalyst apps and iOS apps running on - /// macOS as "iOS". See `platform` for an alternative. - static var operatingSystem: String { - #if os(macOS) - return "macOS" - #elseif os(visionOS) - return "visionOS" - #elseif os(iOS) - return "iOS" - #elseif os(watchOS) - return "watchOS" - #elseif os(tvOS) - return "tvOS" - #else - return "Unknown Operating System" - #endif - } - - /// Based on the operating version reported by swift, but adding some smartness to better detect the actual - /// platform. Should correctly identify catalyst apps and iOS apps on macOS. - static var platform: String { - #if os(macOS) - return "macOS" - #elseif os(visionOS) - return "visionOS" - #elseif os(iOS) - #if targetEnvironment(macCatalyst) - return "macCatalyst" - #else - if #available(iOS 14.0, *), ProcessInfo.processInfo.isiOSAppOnMac { - return "isiOSAppOnMac" - } - return "iOS" - #endif - #elseif os(watchOS) - return "watchOS" - #elseif os(tvOS) - return "tvOS" - #else - return "Unknown Platform" - #endif - } - - /// The target environment as reported by swift. Either "simulator", "macCatalyst" or "native" - static var targetEnvironment: String { - #if targetEnvironment(simulator) - return "simulator" - #elseif targetEnvironment(macCatalyst) - return "macCatalyst" - #else - return "native" - #endif - } - - /// The locale identifier the app currently runs in. E.g. `en_DE` for an app that does not support German on a device with preferences `[German, English]`, and region Germany. - static var locale: String { - Locale.current.identifier - } - - /// The region identifier both the user most prefers and also the app is set to. They are always the same because formatters in apps always auto-adjust to the users preferences. - static var region: String { - if #available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) { - return Locale.current.region?.identifier ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_")).last! - } else { - return Locale.current.regionCode ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_")).last! - } - } - - /// The language identifier the app is currently running in. This represents the language the system (or the user) has chosen for the app to run in. - static var appLanguage: String { - if #available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) { - return Locale.current.language.languageCode?.identifier ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_"))[0] - } else { - return Locale.current.languageCode ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_"))[0] - } - } - - /// The language identifier of the users most preferred language set on the device. Returns also languages the current app is not even localized to. - static var preferredLanguage: String { - let preferredLocaleIdentifier = Locale.preferredLanguages.first ?? "zz-ZZ" - return preferredLocaleIdentifier.components(separatedBy: .init(charactersIn: "-_"))[0] - } - - /// The color scheme set by the user. Returns `N/A` on unsupported platforms - @MainActor - static var colorScheme: String { - #if os(iOS) || os(tvOS) - switch UIScreen.main.traitCollection.userInterfaceStyle { - case .dark: return "Dark" - case .light: return "Light" - default: return "N/A" - } - #elseif os(macOS) - if #available(macOS 10.14, *) { - switch NSAppearance.current.name { - case .aqua: return "Light" - case .darkAqua: return "Dark" - default: return "N/A" - } - } else { - return "Light" - } - #else - return "N/A" - #endif - } - - /// The user-preferred layout direction (left-to-right or right-to-left) based on the current language/region settings. - @MainActor - static var layoutDirection: String { - #if os(iOS) || os(tvOS) - if TelemetryEnvironment.isAppExtension { - // we're in an app extension, where `UIApplication.shared` is not available - return "N/A" - } else { - return UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? "leftToRight" : "rightToLeft" - } - #elseif os(macOS) - if let nsApp = NSApp { - return nsApp.userInterfaceLayoutDirection == .leftToRight ? "leftToRight" : "rightToLeft" - } else { - return "N/A" - } - #else - return "N/A" - #endif - } - - /// The current devices screen resolution width in points. - @MainActor - static var screenResolutionWidth: String { - #if os(iOS) || os(tvOS) - return "\(UIScreen.main.bounds.width)" - #elseif os(watchOS) - return "\(WKInterfaceDevice.current().screenBounds.width)" - #elseif os(macOS) - if let screen = NSScreen.main { - return "\(screen.frame.width)" - } - return "N/A" - #else - return "N/A" - #endif - } - - /// The current devices screen resolution height in points. - @MainActor - static var screenResolutionHeight: String { - #if os(iOS) || os(tvOS) - return "\(UIScreen.main.bounds.height)" - #elseif os(watchOS) - return "\(WKInterfaceDevice.current().screenBounds.height)" - #elseif os(macOS) - if let screen = NSScreen.main { - return "\(screen.frame.height)" - } - return "N/A" - #else - return "N/A" - #endif - } - - @MainActor - static var screenScaleFactor: String { - #if os(iOS) || os(tvOS) - return "\(UIScreen.main.scale)" - #elseif os(macOS) - if let screen = NSScreen.main { - return "\(screen.backingScaleFactor)" - } - return "N/A" - #else - return "N/A" - #endif - } - - /// The current devices screen orientation. Returns `Fixed` for devices that don't support an orientation change. - @MainActor - static var orientation: String { - #if os(iOS) - switch UIDevice.current.orientation { - case .portrait, .portraitUpsideDown: return "Portrait" - case .landscapeLeft, .landscapeRight: return "Landscape" - default: return "Unknown" - } - #else - return "Fixed" - #endif - } - - /// The devices current time zone in the modern `UTC` format, such as `UTC+1`, or `UTC-3:30`. - static var timeZone: String { - let secondsFromGMT = TimeZone.current.secondsFromGMT() - let hours = secondsFromGMT / 3600 - let minutes = abs(secondsFromGMT / 60 % 60) - - let sign = secondsFromGMT >= 0 ? "+" : "-" - if minutes > 0 { - return "UTC\(sign)\(hours):\(String(format: "%02d", minutes))" - } else { - return "UTC\(sign)\(hours)" - } - } -} diff --git a/Sources/TelemetryDeck/Signals/Signal.swift b/Sources/TelemetryDeck/Signals/Signal.swift deleted file mode 100644 index 9b93a2e..0000000 --- a/Sources/TelemetryDeck/Signals/Signal.swift +++ /dev/null @@ -1,135 +0,0 @@ -import Foundation - -#if os(iOS) - import UIKit -#elseif os(macOS) - import AppKit - import IOKit -#elseif os(watchOS) - import WatchKit -#elseif os(tvOS) - import TVUIKit -#endif - -/// Note: only use this when posting to the deprecated V1 ingest API -struct SignalPostBody: Codable, Equatable { - /// When was this signal generated - let receivedAt: Date - - /// The App ID of this signal - let appID: String - - /// A user identifier. This should be hashed on the client, and will be hashed + salted again - /// on the server to break any connection to personally identifiable data. - let clientUser: String - - /// A randomly generated session identifier. Should be the same over the course of the session - let sessionID: String - - /// A type name for this signal that describes the event that triggered the signal - let type: String - - /// An optional numerical value to send along with the signal. - let floatValue: Double? - - /// Tags in the form "key:value" to attach to the signal - let payload: [String: String] - - /// If "true", mark the signal as a testing signal and only show it in a dedicated test mode UI - let isTestMode: String -} - -/// The default payload that is included in payloads processed by TelemetryDeck. -public struct DefaultSignalPayload: Encodable { - @MainActor - public static var parameters: [String: String] { - var parameters: [String: String] = [ - // deprecated names - "platform": Self.platform, - "systemVersion": Self.systemVersion, - "majorSystemVersion": Self.majorSystemVersion, - "majorMinorSystemVersion": Self.majorMinorSystemVersion, - "appVersion": Self.appVersion, - "buildNumber": Self.buildNumber, - "isSimulator": "\(Self.isSimulator)", - "isDebug": "\(Self.isDebug)", - "isTestFlight": "\(Self.isTestFlight)", - "isAppStore": "\(Self.isAppStore)", - "modelName": Self.modelName, - "architecture": Self.architecture, - "operatingSystem": Self.operatingSystem, - "targetEnvironment": Self.targetEnvironment, - "locale": Self.locale, - "region": Self.region, - "appLanguage": Self.appLanguage, - "preferredLanguage": Self.preferredLanguage, - "telemetryClientVersion": sdkVersion, - - // new names - "TelemetryDeck.AppInfo.buildNumber": Self.buildNumber, - "TelemetryDeck.AppInfo.version": Self.appVersion, - "TelemetryDeck.AppInfo.versionAndBuildNumber": "\(Self.appVersion) (build \(Self.buildNumber))", - - "TelemetryDeck.Device.architecture": Self.architecture, - "TelemetryDeck.Device.modelName": Self.modelName, - "TelemetryDeck.Device.operatingSystem": Self.operatingSystem, - "TelemetryDeck.Device.orientation": Self.orientation, - "TelemetryDeck.Device.platform": Self.platform, - "TelemetryDeck.Device.screenResolutionHeight": Self.screenResolutionHeight, - "TelemetryDeck.Device.screenResolutionWidth": Self.screenResolutionWidth, - "TelemetryDeck.Device.screenScaleFactor": Self.screenScaleFactor, - "TelemetryDeck.Device.systemMajorMinorVersion": Self.majorMinorSystemVersion, - "TelemetryDeck.Device.systemMajorVersion": Self.majorSystemVersion, - "TelemetryDeck.Device.systemVersion": Self.systemVersion, - "TelemetryDeck.Device.timeZone": Self.timeZone, - - "TelemetryDeck.RunContext.isAppStore": "\(Self.isAppStore)", - "TelemetryDeck.RunContext.isDebug": "\(Self.isDebug)", - "TelemetryDeck.RunContext.isSimulator": "\(Self.isSimulator)", - "TelemetryDeck.RunContext.isTestFlight": "\(Self.isTestFlight)", - "TelemetryDeck.RunContext.language": Self.appLanguage, - "TelemetryDeck.RunContext.locale": Self.locale, - "TelemetryDeck.RunContext.targetEnvironment": Self.targetEnvironment, - - "TelemetryDeck.SDK.name": "SwiftSDK", - "TelemetryDeck.SDK.nameAndVersion": "SwiftSDK \(sdkVersion)", - "TelemetryDeck.SDK.version": sdkVersion, - - "TelemetryDeck.UserPreference.colorScheme": Self.colorScheme, - "TelemetryDeck.UserPreference.language": Self.preferredLanguage, - "TelemetryDeck.UserPreference.layoutDirection": Self.layoutDirection, - "TelemetryDeck.UserPreference.region": Self.region, - ] - - parameters.merge(self.accessibilityParameters, uniquingKeysWith: { $1 }) - parameters.merge(self.calendarParameters, uniquingKeysWith: { $1 }) - - if let extensionIdentifier = Self.extensionIdentifier { - // deprecated name - parameters["extensionIdentifier"] = extensionIdentifier - - // new name - parameters["TelemetryDeck.RunContext.extensionIdentifier"] = extensionIdentifier - } - - // Pirate Metrics - if #available(watchOS 7, *) { - parameters.merge( - [ - "TelemetryDeck.Acquisition.firstSessionDate": SessionManager.shared.firstSessionDate, - "TelemetryDeck.Retention.averageSessionSeconds": "\(SessionManager.shared.averageSessionSeconds)", - "TelemetryDeck.Retention.distinctDaysUsed": "\(SessionManager.shared.distinctDaysUsed.count)", - "TelemetryDeck.Retention.distinctDaysUsedLastMonth": "\(SessionManager.shared.distinctDaysUsedLastMonthCount)", - "TelemetryDeck.Retention.totalSessionsCount": "\(SessionManager.shared.totalSessionsCount)", - ], - uniquingKeysWith: { $1 } - ) - - if let previousSessionSeconds = SessionManager.shared.previousSessionSeconds { - parameters["TelemetryDeck.Retention.previousSessionSeconds"] = "\(previousSessionSeconds)" - } - } - - return parameters - } -} diff --git a/Sources/TelemetryDeck/Signals/SignalCache.swift b/Sources/TelemetryDeck/Signals/SignalCache.swift deleted file mode 100644 index fce4f4b..0000000 --- a/Sources/TelemetryDeck/Signals/SignalCache.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation - -/// A local cache for signals to be sent to the TelemetryDeck ingestion service -/// -/// There is no guarantee that Signals come out in the same order you put them in. This shouldn't matter though, -/// since all Signals automatically get a `receivedAt` property with a date, allowing the server to reorder them -/// correctly. -/// -/// Currently the cache is only in-memory. This will probably change in the near future. -internal class SignalCache: @unchecked Sendable where T: Codable { - internal var logHandler: LogHandler? - - private var cachedSignals: [T] = [] - private let maximumNumberOfSignalsToPopAtOnce = 100 - - let queue = DispatchQueue(label: "com.telemetrydeck.SignalCache", attributes: .concurrent) - - /// How many Signals are cached - func count() -> Int { - queue.sync { - self.cachedSignals.count - } - } - - /// Insert a Signal into the cache - func push(_ signal: T) { - queue.sync(flags: .barrier) { - self.cachedSignals.append(signal) - } - } - - /// Insert a number of Signals into the cache - func push(_ signals: [T]) { - queue.sync(flags: .barrier) { - self.cachedSignals.append(contentsOf: signals) - } - } - - /// Remove a number of Signals from the cache and return them - /// - /// You should hold on to the signals returned by this function. If the action you are trying to do with them fails - /// (e.g. sending them to a server) you should reinsert them into the cache with the `push` function. - func pop() -> [T] { - queue.sync(flags: .barrier) { - let sliceSize = min(maximumNumberOfSignalsToPopAtOnce, cachedSignals.count) - let poppedSignals = Array(cachedSignals[.. URL { - // swiftlint:disable force_try - let cacheFolderURL = try! FileManager.default.url( - for: .cachesDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: false - ) - // swiftlint:enable force_try - - return cacheFolderURL.appendingPathComponent("telemetrysignalcache") - } - - /// Save the entire signal cache to disk - func backupCache() { - queue.sync { - if let data = try? JSONEncoder().encode(self.cachedSignals) { - do { - try data.write(to: fileURL()) - logHandler?.log(message: "Saved Telemetry cache \(data) of \(self.cachedSignals.count) signals") - // After saving the cache, we need to clear our local cache otherwise - // it could get merged with the cache read back from disk later if - // it's still in memory - self.cachedSignals = [] - } catch { - logHandler?.log(.error, message: "Error saving Telemetry cache") - } - } - } - } - - /// Loads any previous signal cache from disk - init(logHandler: LogHandler?) { - self.logHandler = logHandler - - queue.sync { - logHandler?.log(message: "Loading Telemetry cache from: \(fileURL())") - - if let data = try? Data(contentsOf: fileURL()) { - // Loaded cache file, now delete it to stop it being loaded multiple times - try? FileManager.default.removeItem(at: fileURL()) - - // Decode the data into a new cache - if let signals = try? JSONDecoder().decode([T].self, from: data) { - logHandler?.log(message: "Loaded \(signals.count) signals") - self.cachedSignals = signals - } - } - } - } -} diff --git a/Sources/TelemetryDeck/Signals/SignalEnricher.swift b/Sources/TelemetryDeck/Signals/SignalEnricher.swift deleted file mode 100644 index b88f4c3..0000000 --- a/Sources/TelemetryDeck/Signals/SignalEnricher.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -public protocol SignalEnricher: Sendable { - func enrich( - signalType: String, - for clientUser: String?, - floatValue: Double? - ) -> [String: String] -} - -extension Dictionary where Key == String, Value == String { - func applying(_ other: [String: String]) -> [String: String] { - merging(other) { _, other in - other - } - } -} diff --git a/Sources/TelemetryDeck/Signals/SignalManager.swift b/Sources/TelemetryDeck/Signals/SignalManager.swift deleted file mode 100644 index 489bdb5..0000000 --- a/Sources/TelemetryDeck/Signals/SignalManager.swift +++ /dev/null @@ -1,404 +0,0 @@ -import Foundation - -#if os(iOS) || os(visionOS) - import UIKit -#elseif os(macOS) - import AppKit -#elseif os(watchOS) - import WatchKit -#elseif os(tvOS) - import TVUIKit -#endif - -protocol SignalManageable { - func processSignal( - _ signalName: String, - parameters: [String: String], - floatValue: Double?, - customUserID: String?, - configuration: TelemetryManagerConfiguration - ) - func attemptToSendNextBatchOfCachedSignals() - - @MainActor var defaultUserIdentifier: String { get } -} - -final class SignalManager: SignalManageable, @unchecked Sendable { - static let minimumSecondsToPassBetweenRequests: Double = 10 - - private var signalCache: SignalCache - let configuration: TelemetryManagerConfiguration - - private var sendTimerSource: DispatchSourceTimer? - private let timerQueue = DispatchQueue(label: "com.telemetrydeck.SignalTimer", qos: .utility) - - init(configuration: TelemetryManagerConfiguration) { - self.configuration = configuration - - // We automatically load any old signals from disk on initialisation - signalCache = SignalCache(logHandler: configuration.swiftUIPreviewMode ? nil : configuration.logHandler) - - // Before the app terminates, we want to save any pending signals to disk - // We need to monitor different notifications for different devices. - // macOS - We can simply wait for the app to terminate at which point we get enough time to save the cache - // which is then restored when the app is cold started and all init's fire. - // iOS - App termination is an unreliable method to do work, so we use moving to background and foreground to save/load the cache. - // watchOS and tvOS - We can only really monitor moving to background and foreground to save/load the cache. - // watchOS pre7.0 - Doesn't have any kind of notification to monitor. - #if os(macOS) - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillTerminate), - name: NSApplication.willTerminateNotification, - object: nil - ) - #elseif os(watchOS) - if #available(watchOS 7.0, *) { - // We need to use a delay with these type of notifications because they fire on app load which causes a double load of the cache from disk - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - NotificationCenter.default.addObserver( - self, - selector: #selector(self.didEnterForeground), - name: WKExtension.applicationWillEnterForegroundNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(self.didEnterBackground), - name: WKExtension.applicationDidEnterBackgroundNotification, - object: nil - ) - } - } else { - // Pre watchOS 7.0, this library will not use disk caching at all as there are no notifications we can observe. - } - #elseif os(tvOS) || os(iOS) || os(visionOS) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - // We need to use a delay with these type of notifications because they fire on app load which causes a double load of the cache from disk - NotificationCenter.default.addObserver( - self, - selector: #selector(self.didEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(self.didEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - } - #endif - - sendCachedSignalsRepeatedly() - } - - /// Send any cached Signals from previous sessions now and setup a timer to repeatedly send Signals from cache in regular time intervals. - private func sendCachedSignalsRepeatedly() { - attemptToSendNextBatchOfCachedSignals() - - sendTimerSource?.cancel() - let source = DispatchSource.makeTimerSource(queue: timerQueue) - source.schedule( - deadline: .now() + Self.minimumSecondsToPassBetweenRequests, - repeating: Self.minimumSecondsToPassBetweenRequests - ) - source.setEventHandler { [weak self] in - self?.attemptToSendNextBatchOfCachedSignals() - } - source.resume() - sendTimerSource = source - } - - /// Adds a signal to the process queue - func processSignal( - _ signalName: String, - parameters: [String: String], - floatValue: Double?, - customUserID: String?, - configuration: TelemetryManagerConfiguration - ) { - // enqueue signal to sending cache - DispatchQueue.main.async { - let defaultUserIdentifier = self.defaultUserIdentifier - let defaultParameters = DefaultSignalPayload.parameters - - DispatchQueue.global(qos: .utility).async { - let enrichedMetadata: [String: String] = configuration.metadataEnrichers - .map { $0.enrich(signalType: signalName, for: customUserID, floatValue: floatValue) } - .reduce([String: String]()) { $0.applying($1) } - - let payload = - defaultParameters - .applying(enrichedMetadata) - .applying(parameters) - - let signalPostBody = SignalPostBody( - receivedAt: Date(), - appID: configuration.telemetryAppID, - clientUser: CryptoHashing.sha256(string: customUserID ?? defaultUserIdentifier, salt: configuration.salt), - sessionID: configuration.sessionID.uuidString, - type: "\(signalName)", - floatValue: floatValue, - payload: payload, - isTestMode: configuration.testMode ? "true" : "false" - ) - - configuration.logHandler?.log(.debug, message: "Process signal: \(signalPostBody)") - - self.signalCache.push(signalPostBody) - } - } - } - - /// Sends one batch of signals from the cache if not empty. - /// If signals fail to send, we put them back into the cache to try again later. - @Sendable - func attemptToSendNextBatchOfCachedSignals() { - configuration.logHandler?.log(.debug, message: "Current signal cache count: \(signalCache.count())") - - let queuedSignals: [SignalPostBody] = signalCache.pop() - if !queuedSignals.isEmpty { - configuration.logHandler?.log(message: "Sending \(queuedSignals.count) signals leaving a cache of \(signalCache.count()) signals") - - send(queuedSignals) { [configuration, signalCache] data, response, error in - - if let error = error { - configuration.logHandler?.log(.error, message: "\(error)") - - // The send failed, put the signal back into the queue - signalCache.push(queuedSignals) - return - } - - // Check for valid status code response - guard response?.statusCodeError() == nil else { - let statusError = response!.statusCodeError()! - configuration.logHandler?.log(.error, message: "\(statusError)") - // The send failed, put the signal back into the queue - signalCache.push(queuedSignals) - return - } - - if let data = data, let messageString = String(data: data, encoding: .utf8) { - configuration.logHandler?.log(.debug, message: messageString) - } - } - } - } -} - -// MARK: - Notifications - -extension SignalManager { - @MainActor - @objc fileprivate func appWillTerminate() { - configuration.logHandler?.log(.debug, message: #function) - - #if os(watchOS) || os(macOS) - self.signalCache.backupCache() - #else - if TelemetryEnvironment.isAppExtension { - // we're in an app extension, where `UIApplication.shared` is not available - self.signalCache.backupCache() - } else { - // run backup in background task to avoid blocking main thread while ensuring app stays open during write - let backgroundTaskID = UIApplication.shared.beginBackgroundTask() - DispatchQueue.global(qos: .background).async { - self.signalCache.backupCache() - - DispatchQueue.main.async { - UIApplication.shared.endBackgroundTask(backgroundTaskID) - } - } - } - #endif - } - - /// WatchOS doesn't have a notification before it's killed, so we have to use background/foreground - /// This means our `init()` above doesn't always run when coming back to foreground, so we have to manually - /// reload the cache. This also means we miss any signals sent during watchDidEnterForeground - /// so we merge them into the new cache. - #if os(watchOS) || os(tvOS) || os(iOS) || os(visionOS) - @objc func didEnterForeground() { - configuration.logHandler?.log(.debug, message: #function) - - let currentCache = signalCache.pop() - configuration.logHandler?.log(.debug, message: "current cache is \(currentCache.count) signals") - signalCache = SignalCache(logHandler: configuration.logHandler) - signalCache.push(currentCache) - - sendCachedSignalsRepeatedly() - } - - @MainActor - @objc func didEnterBackground() { - configuration.logHandler?.log(.debug, message: #function) - - sendTimerSource?.cancel() - sendTimerSource = nil - - #if os(watchOS) || os(macOS) - self.signalCache.backupCache() - #else - if TelemetryEnvironment.isAppExtension { - // we're in an app extension, where `UIApplication.shared` is not available - self.signalCache.backupCache() - } else { - // run backup in background task to avoid blocking main thread while ensuring app stays open during write - let backgroundTaskID = UIApplication.shared.beginBackgroundTask() - DispatchQueue.global(qos: .background).async { - self.signalCache.backupCache() - - DispatchQueue.main.async { - UIApplication.shared.endBackgroundTask(backgroundTaskID) - } - } - } - #endif - } - #endif -} - -// MARK: - Comms - -extension SignalManager { - private func send(_ signalPostBodies: [SignalPostBody], completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) { - DispatchQueue.global(qos: .utility).async { - guard let url = SignalManager.getServiceUrl(baseURL: self.configuration.apiBaseURL, namespace: self.configuration.namespace) else { - self.configuration.logHandler?.log( - .error, - message: "Unable to construct signal API URL for namespace \(self.configuration.namespace ?? "nil")" - ) - DispatchQueue.main.async { completionHandler(nil, nil, TelemetryError.invalidEndpointUrl) } - return - } - - var urlRequest = URLRequest(url: url) - urlRequest.httpMethod = "POST" - urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") - - guard let body = try? JSONEncoder.telemetryEncoder.encode(signalPostBodies) else { - return - } - - urlRequest.httpBody = body - - if let data = urlRequest.httpBody, let messageString = String(data: data, encoding: .utf8) { - self.configuration.logHandler?.log(.debug, message: messageString) - } - - let task = self.configuration.urlSession.dataTask(with: urlRequest, completionHandler: completionHandler) - task.resume() - } - } - - static func getServiceUrl(baseURL: URL, namespace: String? = nil) -> URL? { - var base = baseURL.absoluteString - if !base.hasSuffix("/") { - base += "/" - } - - let suffix: String - if let namespace, !namespace.isEmpty { - suffix = "v2/namespace/\(namespace)/" - } else { - suffix = "v2/" - } - - let serviceURL = URL(string: base + suffix) - assert(serviceURL != nil, "Failed to construct service URL from base: \(baseURL)") - return serviceURL - } -} - -// MARK: - Helpers - -extension SignalManager { - /// The default user identifier. If the platform supports it, the ``identifierForVendor``. Otherwise, a self-generated `UUID` which is persisted in custom `UserDefaults` if available. - @MainActor - var defaultUserIdentifier: String { - guard configuration.defaultUser == nil else { return configuration.defaultUser! } - - #if os(iOS) || os(tvOS) || os(visionOS) - return UIDevice.current.identifierForVendor?.uuidString - ?? "unknown user \(DefaultSignalPayload.systemVersion) \(DefaultSignalPayload.buildNumber)" - #elseif os(watchOS) - if #available(watchOS 6.2, *) { - return WKInterfaceDevice.current().identifierForVendor?.uuidString - ?? "unknown user \(DefaultSignalPayload.systemVersion) \(DefaultSignalPayload.buildNumber)" - } else { - return "unknown user \(DefaultSignalPayload.platform) \(DefaultSignalPayload.systemVersion) \(DefaultSignalPayload.buildNumber)" - } - #elseif os(macOS) - if let customDefaults = TelemetryDeck.customDefaults, let defaultUserIdentifier = customDefaults.string(forKey: "defaultUserIdentifier") { - return defaultUserIdentifier - } else { - let defaultUserIdentifier = UUID().uuidString - TelemetryDeck.customDefaults?.set(defaultUserIdentifier, forKey: "defaultUserIdentifier") - return defaultUserIdentifier - } - #else - #if DEBUG - let line1 = "[Telemetry] On this platform, Telemetry can't generate a unique user identifier." - let line2 = "It is recommended you supply one yourself. More info: https://telemetrydeck.com/pages/signal-reference.html" - configuration.logHandler?.log(message: "\(line1) \(line2)") - #endif - return "unknown user \(DefaultSignalPayload.platform) \(DefaultSignalPayload.systemVersion) \(DefaultSignalPayload.buildNumber)" - #endif - } -} - -extension URLResponse { - /// Returns the HTTP status code - fileprivate func statusCode() -> Int? { - if let httpResponse = self as? HTTPURLResponse { - return httpResponse.statusCode - } - return nil - } - - /// Returns an `Error` if not a valid statusCode - fileprivate func statusCodeError() -> Error? { - // Check for valid response in the 200-299 range - guard (200...299).contains(statusCode() ?? 0) else { - if statusCode() == 401 { - return TelemetryError.unauthorised - } else if statusCode() == 403 { - return TelemetryError.forbidden - } else if statusCode() == 413 { - return TelemetryError.payloadTooLarge - } else { - return TelemetryError.invalidStatusCode(statusCode: statusCode() ?? 0) - } - } - return nil - } -} - -// MARK: - Errors - -private enum TelemetryError: Error { - case unauthorised - case forbidden - case payloadTooLarge - case invalidStatusCode(statusCode: Int) - case invalidEndpointUrl -} - -extension TelemetryError: LocalizedError { - public var errorDescription: String? { - switch self { - case .invalidStatusCode(let statusCode): - return "Invalid status code \(statusCode)" - case .unauthorised: - return "Unauthorized (401)" - case .forbidden: - return "Forbidden (403)" - case .payloadTooLarge: - return "Payload is too large (413)" - case .invalidEndpointUrl: - return "Invalid endpoint URL" - } - } -} diff --git a/Sources/TelemetryDeck/Storage/UserDefaultsProcessorStorage.swift b/Sources/TelemetryDeck/Storage/UserDefaultsProcessorStorage.swift new file mode 100644 index 0000000..04b0434 --- /dev/null +++ b/Sources/TelemetryDeck/Storage/UserDefaultsProcessorStorage.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A `ProcessorStorage` implementation backed by a `UserDefaults` suite. +public actor UserDefaultsProcessorStorage: ProcessorStorage { + private let defaults: UserDefaults + + /// Creates a storage backed by the `UserDefaults` suite with the given name. + public init(suiteName: String) { + self.defaults = UserDefaults(suiteName: suiteName) ?? .standard + } + + /// Returns the data stored for the given key, or `nil` if absent. + public func data(forKey key: String) -> Data? { defaults.data(forKey: key) } + /// Stores or removes data for the given key. + public func set(_ data: Data?, forKey key: String) { defaults.set(data, forKey: key) } + /// Returns the string stored for the given key, or `nil` if absent. + public func string(forKey key: String) -> String? { defaults.string(forKey: key) } + /// Stores or removes a string for the given key. + public func set(_ value: String?, forKey key: String) { defaults.set(value, forKey: key) } + /// Returns the integer stored for the given key, or `0` if absent. + public func integer(forKey key: String) -> Int { defaults.integer(forKey: key) } + /// Stores an integer for the given key. + public func set(_ value: Int, forKey key: String) { defaults.set(value, forKey: key) } + /// Returns the boolean stored for the given key, or `false` if absent. + public func bool(forKey key: String) -> Bool { defaults.bool(forKey: key) } + /// Stores a boolean for the given key. + public func set(_ value: Bool, forKey key: String) { defaults.set(value, forKey: key) } + /// Returns the string array stored for the given key, or `nil` if absent. + public func stringArray(forKey key: String) -> [String]? { defaults.stringArray(forKey: key) } +} diff --git a/Sources/TelemetryDeck/Storage/V2DataMigrator.swift b/Sources/TelemetryDeck/Storage/V2DataMigrator.swift new file mode 100644 index 0000000..3d7747f --- /dev/null +++ b/Sources/TelemetryDeck/Storage/V2DataMigrator.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Migrates persisted data from the v2 SDK format into the v3 format. +/// +/// v2 encoded session dates as seconds since the Unix epoch (1970-01-01) while v3 uses Swift's +/// default `JSONEncoder`, which encodes dates as seconds since the reference date (2001-01-01). +/// v2 also stored `distinctDaysUsed` as a plist string array while v3 stores a JSON-encoded `Set`. +/// Finally, v2 had no `installID` key; its absence causes v3 to fire a false `newInstallDetected` event. +/// +/// Migration is idempotent: once `installID` is set the migrator does nothing on subsequent launches. +enum V2DataMigrator { + private struct V2Session: Decodable { + let startedAt: Date + let durationInSeconds: Int + + private enum CodingKeys: String, CodingKey { + case startedAt = "st" + case durationInSeconds = "dn" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let epochSeconds = try container.decode(Double.self, forKey: .startedAt) + self.startedAt = Date(timeIntervalSince1970: epochSeconds) + self.durationInSeconds = try container.decode(Int.self, forKey: .durationInSeconds) + } + } + + private struct V3Session: Encodable { + let startedAt: Date + let durationInSeconds: Int + + private enum CodingKeys: String, CodingKey { + case startedAt = "st" + case durationInSeconds = "dn" + } + } + + static func migrateIfNeeded(storage: any ProcessorStorage) async { + let existingInstallID = await storage.string(forKey: "installID") + guard existingInstallID == nil else { return } + + var didMigrateAnything = false + + if let rawData = await storage.data(forKey: "recentSessions"), + let v2Sessions = try? JSONDecoder().decode([V2Session].self, from: rawData) + { + let v3Sessions = v2Sessions.map { V3Session(startedAt: $0.startedAt, durationInSeconds: $0.durationInSeconds) } + if let v3Data = try? JSONEncoder().encode(v3Sessions) { + await storage.set(v3Data, forKey: "recentSessions") + didMigrateAnything = true + } + } + + if let days = await storage.stringArray(forKey: "distinctDaysUsed") { + let daysSet = Set(days) + if let daysData = try? JSONEncoder().encode(daysSet) { + await storage.set(daysData, forKey: "distinctDaysUsed") + didMigrateAnything = true + } + } + + let hasFirstSessionDate = await storage.string(forKey: "firstSessionDate") != nil + if didMigrateAnything || hasFirstSessionDate { + await storage.set(UUID().uuidString, forKey: "installID") + } + } +} diff --git a/Sources/TelemetryDeck/TelemetryClient.swift b/Sources/TelemetryDeck/TelemetryClient.swift deleted file mode 100644 index 35e02c5..0000000 --- a/Sources/TelemetryDeck/TelemetryClient.swift +++ /dev/null @@ -1,493 +0,0 @@ -import Foundation - -#if os(iOS) - import UIKit -#elseif os(macOS) - import AppKit -#elseif os(watchOS) - import WatchKit -#elseif os(tvOS) - import TVUIKit -#endif - -let sdkVersion = "2.12.0" - -/// Configuration for TelemetryManager -/// -/// Use an instance of this class to specify settings for TelemetryManager. If these settings change during the course of -/// your runtime, it might be a good idea to hold on to the instance and update it as needed. TelemetryManager's behaviour -/// will update as well. -public final class TelemetryManagerConfiguration: @unchecked Sendable { - /// Your app's ID for Telemetry. Set this during initialization. - public let telemetryAppID: String - - /// The domain to send signals to. Defaults to the default Telemetry API server. - /// (Don't change this unless you know exactly what you're doing) - public let apiBaseURL: URL - - /// The namespace to send signals to. Defaults to the default Telemetry API server namespace. - /// (Don't change this unless you know exactly what you're doing) - public let namespace: String? - - /// This string will be appended to to all user identifiers before hashing them. - /// - /// Set the salt to a random string of 64 letters, integers and special characters to prevent the unlikely - /// possibility of uncovering the original user identifiers through calculation. - /// - /// Note: Once you set the salt, it should not change. If you change the salt, every single one of your - /// user identifers wll be different, so even existing users will look like new users to TelemetryDeck. - public let salt: String - - /// Instead of specifying a user identifier with each `send` call, you can set your user's name/email/identifier here and - /// it will be sent with every signal from now on. - /// - /// Note that just as with specifying the user identifier with the `send` call, the identifier will never leave the device. - /// Instead it is used to create a hash, which is included in your signal to allow you to count distinct users. - public var defaultUser: String? - - /// Specify this if you want us to prefix all your signals with a specific text. - /// For example, if you are already adding `AppName.` in front of every signal, just specify it here and no need to repeat over and over again. - public var defaultSignalPrefix: String? - - /// Specify this if you want us to prefix all your signal parameters with a specific text. - /// For example, if you are already adding `AppName.` in front of every parameter name, just specify it here and no need to repeat over and over again. - public var defaultParameterPrefix: String? - - /// Specify this if you want to attach some parameters globally to all signals you're sending. - /// For example, this could be useful to report your users' paid status or their user preferences to be able to filter by these fields in various insights. - /// - /// - NOTE: If you are using ``defaultParameterPrefix``, note that it applies even here, so no need to add your prefix in all the default parameters. - public var defaultParameters: @Sendable () -> [String: String] = { [:] } - - /// If `true`, sends a "newSessionBegan" Signal on each app foreground or cold launch - /// - /// Defaults to true. Set to false to prevent automatically sending this signal. - public var sendNewSessionBeganSignal: Bool = true - - /// A random identifier for the current user session. - /// - /// On iOS, tvOS, and watchOS, the session identifier will automatically update whenever your app returns from background after 5 minutes, - /// or if it is launched from cold storage. On other platforms, a new identifier will be generated each time your app launches. If you'd like - /// more fine-grained session support, write a new random session identifier into this property each time a new session begins. - /// - /// Beginning a new session automatically sends a "TelemetryDeck.Session.started" Signal if `sendNewSessionBeganSignal` is `true` - public var sessionID = UUID() { - didSet { - if #available(watchOS 7, *), self.sessionStatsEnabled { - SessionManager.shared.startNewSession() - } - - if sendNewSessionBeganSignal { - TelemetryDeck.internalSignal("TelemetryDeck.Session.started") - } - } - } - - /// A customizable `URLSession` used for network requests within TelemetryDeck. - /// - /// This property allows you to override the default `URLSession.shared` for cases where - /// a custom session configuration is needed (e.g., for network interception, caching strategies, - /// or debugging). If not set, the `URLSession.shared` instance will be used. - public var urlSession: URLSession = URLSession.shared - - @available(*, deprecated, message: "Please use the testMode property instead") - public var sendSignalsInDebugConfiguration: Bool = false - - /// If `true` any signals sent will be marked as *Testing* signals. - /// - /// Testing signals are only shown when your Telemetry Viewer App is in Testing mode. In live mode, they are ignored. - /// - /// By default, this is the same value as `DEBUG`, i.e. you'll be in Testing Mode when you develop and in live mode when - /// you release. You can manually override this, however. - public var testMode: Bool { - get { - if let testMode = _testMode { return testMode } - - #if DEBUG - return true - #else - return false - #endif - } - - set { _testMode = newValue } - } - - private var _testMode: Bool? - - /// If `true` no signals will be sent. - /// - /// SwiftUI previews are built by Xcode automatically and events sent during this mode are not considered actual user-initiated usage. - /// - /// By default, this checks for the `XCODE_RUNNING_FOR_PREVIEWS` environment variable as described in this StackOverflow answer: - /// https://stackoverflow.com/a/61741858/3451975 - public var swiftUIPreviewMode: Bool { - get { - if let swiftUIPreviewMode = _swiftUIPreviewMode { return swiftUIPreviewMode } - - if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { - return true - } else { - return false - } - } - - set { _swiftUIPreviewMode = newValue } - } - - private var _swiftUIPreviewMode: Bool? - - /// If `true` no signals will be sent. - /// - /// Can be used to manually opt out users of tracking. - /// - /// Works together with `swiftUIPreviewMode` if either of those values is `true` no analytics events are sent. - /// However it won't interfere with SwiftUI Previews, when explicitly settings this value to `false`. - public var analyticsDisabled: Bool = false - - /// Determines whether to log warnings when user-provided custom parameters use reserved parameter names that are internal to TelemetryDeck SDKs. - /// - /// - NOTE: Do not change this property if you're using our SDK in your app. This is for usage from other TelemetryDeck SDKs only. - public var reservedParameterWarningsEnabled: Bool = true - - /// If `true` the TelemetryDeck SDK will automatically track some basic session length and count statistics in a privacy-preserving manner for built-in insights. Defaults to `true`. - /// - /// Can be used to manually opt-out of session tracking if for some reason you don't want us to write to UserDefaults once every second. - public var sessionStatsEnabled: Bool = true - - /// Log the current status to the signal cache to the console. - @available(*, deprecated, message: "Please use the logHandler property instead") - public var showDebugLogs: Bool = false - - /// A strategy for handling logs. - /// - /// Defaults to `OSLog.Logger` with info/errror messages - debug messages are not outputted. Set to `nil` to disable all logging from TelemetryDeck SDK. - /// - /// - NOTE: If ``swiftUIPreviewMode`` is `true` (by default only when running SwiftUI previews), this value is effectively ignored, working like it's set to `nil`. - public var logHandler: LogHandler? = LogHandler.standard(.info) - - /// An array of signal metadata enrichers: a system for adding dynamic metadata to signals as they are recorded. - /// - /// Defaults to an empty array. - public var metadataEnrichers: [SignalEnricher] = [] - - /// Creates a new configuration for the TelemetryDeck analytics service. - /// - /// - Parameters: - /// - appID: Your application's unique identifier for TelemetryDeck - /// - salt: A string used to salt user identifiers before hashing. If not provided, an empty string will be used. - /// - baseURL: The base URL for the TelemetryDeck API. Defaults to the standard TelemetryDeck server if not specified. - /// - namespace: An optional namespace for segregating signals. Do not specify unless you know what you're doing. - public init(appID: String, salt: String? = nil, baseURL: URL? = nil, namespace: String? = nil) { - self.telemetryAppID = appID - - if let baseURL = baseURL { - self.apiBaseURL = baseURL - } else { - self.apiBaseURL = URL(string: "https://nom.telemetrydeck.com")! - } - - self.namespace = namespace - - if let salt = salt { - self.salt = salt - } else { - self.salt = "" - } - } - - @available(*, deprecated, renamed: "sendSignalsInDebugConfiguration") - public var telemetryAllowDebugBuilds: Bool { - get { sendSignalsInDebugConfiguration } - set { sendSignalsInDebugConfiguration = newValue } - } -} - -/// Accepts signals that signify events in your app's life cycle, collects and caches them, and pushes them to the Telemetry API. -/// -/// Use an instance of `TelemetryManagerConfiguration` to configure this at initialization and during its lifetime. -public final class TelemetryManager: @unchecked Sendable { - /// Returns `true` when the TelemetryManager already has been initialized correctly, `false` otherwise. - public static var isInitialized: Bool { - initializedTelemetryManager != nil - } - - @available( - *, - deprecated, - renamed: "TelemetryDeck.initialize(config:)", - message: "This call was renamed to `TelemetryDeck.initialize(config:)`. Please migrate – a fix-it is available." - ) - public static func initialize(with configuration: TelemetryManagerConfiguration) { - initializedTelemetryManager = TelemetryManager(configuration: configuration) - } - - static func initialize(with configuration: TelemetryManagerConfiguration, signalManager: SignalManageable) { - initializedTelemetryManager = TelemetryManager(configuration: configuration, signalManager: signalManager) - } - - /// Shuts down the SDK and deinitializes the current `TelemetryManager`. - /// - /// Once called, you must call `TelemetryManager.initialize(with:)` again before using the manager. - @available( - *, - deprecated, - renamed: "TelemetryDeck.terminate()", - message: "This call was renamed to `TelemetryDeck.terminate()`. Please migrate – a fix-it is available." - ) - public static func terminate() { - TelemetryDeck.terminate() - } - - /// Send a Signal to TelemetryDeck, to record that an event has occurred. - /// - /// If you specify parameters, they will be sent in addition to the default parameters which include OS Version, App Version, and more. - @available( - *, - deprecated, - renamed: "TelemetryDeck.signal(_:parameters:)", - message: "This call was renamed to `TelemetryDeck.signal(_:parameters:)`. Please migrate – a fix-it is available." - ) - public static func send(_ signalName: String, with parameters: [String: String] = [:]) { - send(signalName, for: nil, floatValue: nil, with: parameters) - } - - /// Send a Signal to TelemetryDeck, to record that an event has occurred. - /// - /// If you specify a user identifier here, it will take precedence over the default user identifier specified in the `TelemetryManagerConfiguration`. - /// - /// If you specify a payload, it will be sent in addition to the default payload which includes OS Version, App Version, and more. - @_disfavoredOverload - @available( - *, - deprecated, - message: - "This call was renamed to `TelemetryDeck.signal(_:parameters:floatValue:customUserID:)`. Please migrate – no fix-it possible due to the changed order of arguments." - ) - public static func send(_ signalName: String, for customUserID: String? = nil, floatValue: Double? = nil, with parameters: [String: String] = [:]) - { - TelemetryManager.shared.send(signalName, for: customUserID, floatValue: floatValue, with: parameters) - } - - /// 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 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 ``send`` 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 data because a user closes your app and doesn't reopen it anytime soon (if at all). - @available( - *, - deprecated, - renamed: "TelemetryDeck.requestImmediateSync()", - message: "This call was renamed to `TelemetryDeck.requestImmediateSync()`. Please migrate – a fix-it is available." - ) - public static func requestImmediateSync() { - TelemetryManager.shared.requestImmediateSync() - } - - public static var shared: TelemetryManager { - if let telemetryManager = initializedTelemetryManager { - return telemetryManager - } else if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { - // Xcode is building and running the app for SwiftUI Previews, this is not a real launch of the app, therefore mock data is used - initializedTelemetryManager = .init(configuration: .init(appID: "")) - return initializedTelemetryManager! - } else { - assertionFailure("Please call TelemetryManager.initialize(...) before accessing the shared telemetryManager instance.") - return .init(configuration: .init(appID: "")) - } - } - - /// Change the default user identifier sent with each signal. - /// - /// Instead of specifying a user identifier with each `send` call, you can set your user's name/email/identifier here and - /// it will be sent with every signal from now on. If you still specify a user in the `send` call, that takes precedence. - /// - /// Set to `nil` to disable this behavior. - /// - /// Note that just as with specifying the user identifier with the `send` call, the identifier will never leave the device. - /// Instead it is used to create a hash, which is included in your signal to allow you to count distinct users. - @available( - *, - deprecated, - renamed: "TelemetryDeck.updateDefaultUserID(to:)", - message: "This call was renamed to `TelemetryDeck.updateDefaultUserID(to:)`. Please migrate – a fix-it is available." - ) - public static func updateDefaultUser(to newDefaultUser: String?) { - TelemetryManager.shared.updateDefaultUser(to: newDefaultUser) - } - - public func updateDefaultUser(to newDefaultUser: String?) { - TelemetryDeck.updateDefaultUserID(to: newDefaultUser) - } - - @MainActor - public var hashedDefaultUser: String { - let defaultUser = self.signalManager.defaultUserIdentifier - return CryptoHashing.sha256(string: defaultUser, salt: configuration.salt) - } - - /// Generate a new Session ID for all new Signals, in order to begin a new session instead of continuing the old one. - @available( - *, - deprecated, - renamed: "TelemetryDeck.generateNewSession()", - message: "This call was renamed to `TelemetryDeck.generateNewSession()`. Please migrate – a fix-it is available." - ) - public static func generateNewSession() { - TelemetryManager.shared.generateNewSession() - } - - public func generateNewSession() { - TelemetryDeck.generateNewSession() - } - - /// Send a Signal to TelemetryDeck, to record that an event has occurred. - /// - /// If you specify a user identifier here, it will take precedence over the default user identifier specified in the `TelemetryManagerConfiguration`. - /// - /// If you specify a payload, it will be sent in addition to the default payload which includes OS Version, App Version, and more. - @available( - *, - deprecated, - message: - "This call was renamed to `TelemetryDeck.signal(_:parameters:floatValue:customUserID:)`. Please migrate – no fix-it possible due to the changed order of arguments." - ) - public func send(_ signalName: String, with parameters: [String: String] = [:]) { - send(signalName, for: nil, floatValue: nil, with: parameters) - } - - /// Send a Signal to TelemetryDeck, to record that an event has occurred. - /// - /// If you specify a user identifier here, it will take precedence over the default user identifier specified in the `TelemetryManagerConfiguration`. - /// - /// If you specify a payload, it will be sent in addition to the default payload which includes OS Version, App Version, and more. - @_disfavoredOverload - @available( - *, - deprecated, - message: - "This call was renamed to `TelemetryDeck.signal(_:parameters:floatValue:customUserID:)`. Please migrate – no fix-it possible due to the changed order of arguments." - ) - public func send(_ signalName: String, for customUserID: String? = nil, floatValue: Double? = nil, with parameters: [String: String] = [:]) { - TelemetryDeck.signal(signalName, parameters: parameters, floatValue: floatValue, customUserID: customUserID) - } - - /// 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 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 ``send`` 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 data because a user closes your app and doesn't reopen it anytime soon (if at all). - public func requestImmediateSync() { - TelemetryDeck.requestImmediateSync() - } - - init(configuration: TelemetryManagerConfiguration) { - self._configuration = configuration - signalManager = SignalManager(configuration: configuration) - - self.startSessionAndObserveAppForegrounding() - } - - private init(configuration: TelemetryManagerConfiguration, signalManager: SignalManageable) { - self._configuration = configuration - self.signalManager = signalManager - - self.startSessionAndObserveAppForegrounding() - } - - private final class TelemetryManagerStorage: @unchecked Sendable { - static let shared = TelemetryManagerStorage() - private let queue = DispatchQueue(label: "com.telemetrydeck.TelemetryManager.static", attributes: .concurrent) - private var _manager: TelemetryManager? - - var manager: TelemetryManager? { - get { queue.sync { _manager } } - set { queue.sync(flags: .barrier) { _manager = newValue } } - } - } - - static var initializedTelemetryManager: TelemetryManager? { - get { TelemetryManagerStorage.shared.manager } - set { TelemetryManagerStorage.shared.manager = newValue } - } - - let signalManager: SignalManageable - - private let queue = DispatchQueue(label: "com.telemetrydeck.TelemetryManager", attributes: .concurrent) - - private var _configuration: TelemetryManagerConfiguration - var configuration: TelemetryManagerConfiguration { - get { queue.sync(flags: .barrier) { _configuration } } - set { queue.sync(flags: .barrier) { _configuration = newValue } } - } - - private var _lastTimeImmediateSyncRequested: Date = .distantPast - var lastTimeImmediateSyncRequested: Date { - get { queue.sync(flags: .barrier) { _lastTimeImmediateSyncRequested } } - set { queue.sync(flags: .barrier) { _lastTimeImmediateSyncRequested = newValue } } - } - - private var _lastDateAppEnteredBackground: Date = .distantPast - private var lastDateAppEnteredBackground: Date { - get { queue.sync(flags: .barrier) { _lastDateAppEnteredBackground } } - set { queue.sync(flags: .barrier) { _lastDateAppEnteredBackground = newValue } } - } - - private func startSessionAndObserveAppForegrounding() { - // initially start a new session upon app start (delayed so that `didSet` triggers) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - TelemetryDeck.generateNewSession() - } - - // subscribe to notification to start a new session on app entering foreground (if needed) - #if os(iOS) || os(tvOS) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - NotificationCenter.default.addObserver( - self, - selector: #selector(self.willEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(self.didEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - } - #elseif os(watchOS) - if #available(watchOS 7.0, *) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - NotificationCenter.default.addObserver( - self, - selector: #selector(self.willEnterForeground), - name: WKExtension.applicationWillEnterForegroundNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(self.didEnterBackground), - name: WKExtension.applicationDidEnterBackgroundNotification, - object: nil - ) - } - } else { - // Pre watchOS 7.0, this library will not use multiple sessions after backgrounding since there are no notifications we can observe. - } - #endif - } - - @objc func willEnterForeground() { - // check if at least 5 minutes have passed since last app entered background - if self.lastDateAppEnteredBackground.addingTimeInterval(5 * 60) < Date() { - // generate a new session identifier - TelemetryDeck.generateNewSession() - } - } - - @objc func didEnterBackground() { - lastDateAppEnteredBackground = Date() - } -} diff --git a/Sources/TelemetryDeck/TelemetryDeck.swift b/Sources/TelemetryDeck/TelemetryDeck.swift index acfddbc..8cfa04f 100644 --- a/Sources/TelemetryDeck/TelemetryDeck.swift +++ b/Sources/TelemetryDeck/TelemetryDeck.swift @@ -1,268 +1,343 @@ import Foundation -/// A namespace for TelemetryDeck related functionalities. +private actor TelemetryDeckStorage { + var client: TelemetryEngine? + var logger: any Logging = DefaultLogger() + private var buffer: [EventInput] = [] + + func setClient(_ client: TelemetryEngine?) { + self.client = client + } + + func setLogger(_ logger: any Logging) { + self.logger = logger + } + + func send(_ input: EventInput) async { + if let client { + await client.send(input) + } else { + buffer.append(input) + } + } + + func drainBuffer() async { + guard let client, !buffer.isEmpty else { return } + let pending = buffer + buffer = [] + for input in pending { + await client.send(input) + } + } + + func clearBuffer() { + buffer = [] + } +} + +private let storage = TelemetryDeckStorage() + +/// The primary namespace for the TelemetryDeck SDK, providing static methods for initialisation, event sending, and session management. public enum TelemetryDeck { - /// This alias makes it easier to migrate the configuration type into the TelemetryDeck namespace in future versions when deprecated code is fully removed. - public typealias Config = TelemetryManagerConfiguration - static let reservedKeysLowercased: Set = Set( + /// Returns the default set of event processors used when initialising without a custom processor list. + public static func defaultProcessors( + defaultUser: String? = nil, + testMode: Bool? = nil, + eventPrefix: String? = nil, + parameterPrefix: String? = nil, + sendSessionStartedEvent: Bool = true, + defaultParameters: EventParameters = [:] + ) -> [any EventProcessor] { [ - "type", "clientUser", "appID", "sessionID", "floatValue", - "newSessionBegan", "platform", "systemVersion", "majorSystemVersion", "majorMinorSystemVersion", "appVersion", "buildNumber", - "isSimulator", "isDebug", "isTestFlight", "isAppStore", "modelName", "architecture", "operatingSystem", "targetEnvironment", - "locale", "region", "appLanguage", "preferredLanguage", "telemetryClientVersion", - ].map { $0.lowercased() } - ) - - /// Initializes TelemetryDeck with a customizable configuration. - /// - /// - Parameter configuration: An instance of `Configuration` which includes all the settings required to configure TelemetryDeck. - /// - /// This function sets up the telemetry system with the specified configuration. It is necessary to call this method before sending any telemetry signals. - /// For example, you might want to call this in your `init` method of your app's `@main` entry point. - public static func initialize(config: Config) { - TelemetryManager.initializedTelemetryManager = TelemetryManager(configuration: config) + PreviewFilterProcessor(), + DefaultParametersProcessor(parameters: defaultParameters), + DefaultPrefixProcessor(eventPrefix: eventPrefix, parameterPrefix: parameterPrefix), + ValidationProcessor(), + TestModeProcessor(override: testMode), + UserIdentifierProcessor(defaultUser: defaultUser), + SessionTrackingProcessor(sendSessionStartedEvent: sendSessionStartedEvent), + DeviceProcessor(), + AppInfoProcessor(), + LocaleProcessor(), + CalendarProcessor(), + AccessibilityProcessor(), + TrialConversionProcessor(), + ] } - /// Sends a telemetry signal with optional parameters to TelemetryDeck. - /// - /// - Parameters: - /// - signalName: The name of the signal to be sent. This is a string that identifies the type of event or action being reported. - /// - parameters: A dictionary of additional string key-value pairs that provide further context about the signal. Default is empty. - /// - floatValue: An optional floating-point number that can be used to provide numerical data about the signal. Default is `nil`. - /// - customUserID: An optional string specifying a custom user identifier. If provided, it will override the default user identifier from the configuration. Default is `nil`. - /// - /// This function wraps the `TelemetryManager.send` method, providing a streamlined way to send signals from anywhere in the app. - public static func signal( - _ signalName: String, - parameters: [String: String] = [:], - floatValue: Double? = nil, - customUserID: String? = nil - ) { - let manager = TelemetryManager.shared - let configuration = manager.configuration + /// Initialises the SDK with the given app identity and processor-level options. + public static func initialize( + appID: String, + namespace: String, + salt: String = "", + defaultUser: String? = nil, + testMode: Bool? = nil, + eventPrefix: String? = nil, + parameterPrefix: String? = nil, + sendSessionStartedEvent: Bool = true, + defaultParameters: EventParameters = [:] + ) async throws(TelemetryDeckError) { + let configuration = Config(appID: appID, namespace: namespace, salt: salt) + try await initialize( + configuration: configuration, + processors: defaultProcessors( + defaultUser: defaultUser, + testMode: testMode, + eventPrefix: eventPrefix, + parameterPrefix: parameterPrefix, + sendSessionStartedEvent: sendSessionStartedEvent, + defaultParameters: defaultParameters + ) + ) + } - // make sure to not send any signals when run by Xcode via SwiftUI previews - guard !configuration.swiftUIPreviewMode, !configuration.analyticsDisabled else { return } + /// Initialises the SDK with default processors and the given configuration. + public static func initialize(configuration: Config) async throws(TelemetryDeckError) { + try await initialize( + configuration: configuration, + processors: defaultProcessors() + ) + } - let combinedSignalName = (configuration.defaultSignalPrefix ?? "") + signalName - let prefixedParameters = parameters.mapKeys { parameter in - guard !parameter.hasPrefix("TelemetryDeck.") else { return parameter } - return (configuration.defaultParameterPrefix ?? "") + parameter + /// Initialises the SDK with a custom processor list and optional dependency overrides. + public static func initialize( + configuration: Config, + processors: [any EventProcessor], + cache: (any EventCaching)? = nil, + transmitter: (any EventTransmitting)? = nil, + logger: (any Logging)? = nil, + storage processorStorage: (any ProcessorStorage)? = nil + ) async throws(TelemetryDeckError) { + try configuration.validate() + + guard await storage.client == nil else { + await log(.error, "TelemetryDeck.initialize() called more than once. Ignoring subsequent call. Remove the duplicate initialization.") + return } - if configuration.reservedParameterWarningsEnabled { - // warn users about reserved keys to avoid unexpected behavior - if combinedSignalName.lowercased().hasPrefix("telemetrydeck.") { - configuration.logHandler?.log( - .error, - message: "Sending signal with reserved prefix 'TelemetryDeck.' will cause unexpected behavior. Please use another prefix instead." - ) - } else if Self.reservedKeysLowercased.contains(combinedSignalName.lowercased()) { - configuration.logHandler?.log( - .error, - message: - "Sending signal with reserved name '\(combinedSignalName)' will cause unexpected behavior. Please use another name instead." - ) - } + let resolvedLogger = logger ?? DefaultLogger() + await storage.setLogger(resolvedLogger) - // only check parameters (not default ones) - for parameterKey in prefixedParameters.keys { - if parameterKey.lowercased().hasPrefix("telemetrydeck.") { - configuration.logHandler?.log( - .error, - message: - "Sending parameter with reserved key prefix 'TelemetryDeck.' will cause unexpected behavior. Please use another prefix instead." - ) - } else if Self.reservedKeysLowercased.contains(parameterKey.lowercased()) { - configuration.logHandler?.log( - .error, - message: - "Sending parameter with reserved key '\(parameterKey)' will cause unexpected behavior. Please use another key instead." - ) - } - } - } + let client = await TelemetryEngine.create( + configuration: configuration, + processors: processors, + cache: cache, + transmitter: transmitter, + logger: resolvedLogger, + storage: processorStorage + ) + await storage.setClient(client) + await storage.drainBuffer() + } - self.internalSignal(combinedSignalName, parameters: prefixedParameters, floatValue: floatValue, customUserID: customUserID) + static func client() async -> TelemetryEngine? { + await storage.client } - /// 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. - /// - includeBackgroundTime: An optional Bool where you can specify to actually include (and not exclude) the time when your app is in the background. - /// - /// 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] = [:], - includeBackgroundTime: Bool = false - ) { - DurationSignalTracker.shared.startTracking(signalName, parameters: parameters, includeBackgroundTime: includeBackgroundTime) + static func log(_ level: LogLevel, _ message: @autoclosure () -> String) async { + let logger = await storage.logger + logger.log(level, message()) } - /// Cancels tracking of a duration signal without sending it. - /// - /// - Parameter signalName: The name of the signal that was previously started with ``startDurationSignal(_:parameters:includeBackgroundTime:)``. - /// - /// Call this function when you want to discard a duration signal entirely without transmitting it. - /// This is useful in cases such as when a user disables telemetry mid-session and any active timers should be cancelled without sending data. - /// - /// If no matching signal was started, this function does nothing. - @MainActor - @available(watchOS 7.0, *) - public static func cancelDurationSignal(_ signalName: String) { - DurationSignalTracker.shared.stopTracking(signalName) + /// Sends an event whose name is provided as a raw-representable value. + public static func event( + _ name: S, + parameters: EventParameters = [:], + floatValue: Double? = nil, + customUserID: String? = nil + ) async where S.RawValue == String { + await event(name.rawValue, parameters: parameters, floatValue: floatValue, customUserID: customUserID) } - /// 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. - /// - floatValue: An optional floating-point number that can be used to provide numerical data about the signal. Default is `nil`. - /// - customUserID: An optional string specifying a custom user identifier. If provided, it will override the default user identifier from the configuration. Default is `nil`. - /// - /// 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 by default) - /// 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] = [:], + /// Sends an event with the given name, parameters, optional float value, and optional user ID override. + public static func event( + _ name: String, + parameters: EventParameters = [:], floatValue: Double? = nil, customUserID: String? = nil - ) { - guard let (exactDuration, startParameters) = DurationSignalTracker.shared.stopTracking(signalName) else { return } - let roundedDuration = (exactDuration * 1_000).rounded(.down) / 1_000 // rounds down to 3 fraction digits + ) async { + let input = EventInput( + name, + parameters: parameters, + floatValue: floatValue, + customUserID: customUserID + ) + await storage.send(input) + } - var durationParameters = ["TelemetryDeck.Signal.durationInSeconds": String(roundedDuration)] - durationParameters.merge(startParameters) { $1 } + static func sdkEvent( + _ name: S, + parameters: EventParameters = [:], + floatValue: Double? = nil, + customUserID: String? = nil + ) async where S.RawValue == String { + await sdkEvent(name.rawValue, parameters: parameters, floatValue: floatValue, customUserID: customUserID) + } - self.internalSignal( - signalName, - parameters: durationParameters.merging(parameters) { $1 }, + static func sdkEvent( + _ name: String, + parameters: EventParameters = [:], + floatValue: Double? = nil, + customUserID: String? = nil + ) async { + let input = EventInput( + name, + parameters: parameters, floatValue: floatValue, - customUserID: customUserID + customUserID: customUserID, + skipsReservedPrefixValidation: true ) + await storage.send(input) } - /// 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, - parameters: [String: String] = [:], + /// Sends an event without awaiting completion; suitable for fire-and-forget usage. + public static func event( + _ name: String, + parameters: EventParameters = [:], floatValue: Double? = nil, customUserID: String? = nil ) { - let manager = TelemetryManager.shared - let configuration = manager.configuration + Task { await event(name, parameters: parameters, floatValue: floatValue, customUserID: customUserID) } + } - // make sure to not send any signals when run by Xcode via SwiftUI previews - guard !configuration.swiftUIPreviewMode, !configuration.analyticsDisabled else { return } + /// Sends an event whose name is a raw-representable value without awaiting completion. + public static func event( + _ name: S, + parameters: EventParameters = [:], + floatValue: Double? = nil, + customUserID: String? = nil + ) where S.RawValue == String { + let rawName = name.rawValue + event(rawName, parameters: parameters, floatValue: floatValue, customUserID: customUserID) + } - let prefixedDefaultParameters = configuration.defaultParameters().mapKeys { parameter in - guard !parameter.hasPrefix("TelemetryDeck.") else { return parameter } - return (configuration.defaultParameterPrefix ?? "") + parameter + /// Immediately transmits all queued events without waiting for the next scheduled interval. + public static func flush() async { + guard let client = await storage.client else { return } + await client.flush() + } + + /// Flushes pending events, shuts down the engine, and clears the shared instance. + public static func terminate() async { + if let client = await storage.client { + await client.flush() + await client.shutdown() } - let combinedParameters = prefixedDefaultParameters.merging(parameters) { $1 } - - // check only default parameters - for parameterKey in prefixedDefaultParameters.keys { - if parameterKey.lowercased().hasPrefix("telemetrydeck.") { - configuration.logHandler?.log( - .error, - message: - "Sending parameter with reserved key prefix 'TelemetryDeck.' will cause unexpected behavior. Please use another prefix instead." - ) - } else if Self.reservedKeysLowercased.contains(parameterKey.lowercased()) { - configuration.logHandler?.log( - .error, - message: "Sending parameter with reserved key '\(parameterKey)' will cause unexpected behavior. Please use another key instead." - ) - } + await storage.setClient(nil) + await storage.clearBuffer() + } + + // MARK: - Analytics Disabled + + /// Enables or disables analytics collection; while disabled, events are silently dropped. + public static func setAnalyticsDisabled(_ disabled: Bool) async { + guard let client = await storage.client else { return } + await client.setAnalyticsDisabled(disabled) + } + + /// Whether analytics collection is currently disabled. + public static var isAnalyticsDisabled: Bool { + get async { + guard let client = await storage.client else { return false } + return await client.isAnalyticsDisabled } + } - manager.signalManager.processSignal( - signalName, - parameters: combinedParameters, - floatValue: floatValue, - customUserID: customUserID, - configuration: configuration - ) + // MARK: - User Identifier + + /// Sets the user identifier applied to all subsequent events; pass `nil` to revert to the default. + public static func setUserIdentifier(_ value: String?) async { + guard let client = await storage.client else { + await log(.error, "TelemetryDeck not initialized") + return + } + if let processor = await client.processor(conformingTo: (any UserIdentifierManaging).self) { + await processor.setUserIdentifier(value) + } else { + await log(.error, "No UserIdentifierManaging processor in pipeline") + } } - /// 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 - /// 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 - /// doing that. - /// - /// 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 - - // this check ensures that the number of requests can only double in the worst case where a developer calls this after each `send` - if Date().timeIntervalSince(manager.lastTimeImmediateSyncRequested) > SignalManager.minimumSecondsToPassBetweenRequests { - manager.lastTimeImmediateSyncRequested = Date() - - // give the signal manager some short amount of time to process the signal that was sent right before calling sync - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .milliseconds(50)) { [weak manager] in - manager?.signalManager.attemptToSendNextBatchOfCachedSignals() + // MARK: - Session + + /// The identifier of the current session, or `nil` if the SDK has not been initialised. + public static var sessionID: UUID? { + get async { + guard let client = await storage.client else { return nil } + guard let processor = await client.processor(conformingTo: (any SessionManaging).self) else { + return nil } + return await processor.currentSessionID() } } - /// Shuts down the SDK and deinitializes the current `TelemetryManager`. - /// - /// Once called, you must call `TelemetryManager.initialize(with:)` again before using the manager. - public static func terminate() { - TelemetryManager.initializedTelemetryManager = nil + /// Starts a new session and returns its identifier, or `nil` if the SDK is not initialised. + @discardableResult + public static func newSession() async -> UUID? { + guard let client = await storage.client else { + await log(.error, "TelemetryDeck not initialized") + return nil + } + guard let sessionProcessor = await client.processor(conformingTo: (any SessionManaging).self) else { + await log(.error, "No SessionManaging processor in pipeline") + return nil + } + return await sessionProcessor.startNewSession() } - /// Change the default user identifier sent with each signal. - /// - /// Instead of specifying a user identifier with each `signal` call, you can set your user's name/email/identifier here and - /// it will be sent with every signal from now on. If you still specify a user in the `signal` call, that takes precedence. - /// - /// Set to `nil` to disable this behavior. - /// - /// Note that just as with specifying the user identifier with the `signal` call, the identifier will never leave the device. - /// Instead it is used to create a hash, which is included in your signal to allow you to count distinct users. - public static func updateDefaultUserID(to customUserID: String?) { - TelemetryManager.shared.configuration.defaultUser = customUserID + // MARK: - Test Mode + + /// Returns whether the SDK is currently operating in test mode. + public static func isTestMode() async -> Bool { + guard let client = await storage.client else { return false } + guard let processor = await client.processor(conformingTo: (any TestModeProviding).self) else { + return false + } + return await processor.isTestMode() } - /// Generate a new Session ID for all new Signals, in order to begin a new session instead of continuing the old one. - public static func generateNewSession() { - TelemetryManager.shared.configuration.sessionID = UUID() + // MARK: - Duration Tracking + + /// Begins measuring elapsed time for the named event, optionally including time spent in the background. + public static func startDurationEvent( + _ eventName: String, + parameters: EventParameters = [:], + includeBackgroundTime: Bool = false + ) async { + guard let client = await storage.client else { + await log(.error, "TelemetryDeck not initialized") + return + } + await client.durationTracker.startDuration( + eventName, + parameters: parameters, + includeBackgroundTime: includeBackgroundTime + ) } - // MARK: - Internals - /// A custom ``UserDefaults`` instance specific to TelemetryDeck and the current application. - static var customDefaults: UserDefaults? { - guard let configuration = TelemetryManager.initializedTelemetryManager?.configuration else { return nil } + /// Stops the duration measurement for the named event and sends the event with the elapsed time as a parameter and float value. + public static func stopAndSendDurationEvent( + _ eventName: String, + parameters: EventParameters = [:] + ) async { + guard let client = await storage.client else { + await log(.error, "TelemetryDeck not initialized") + return + } + guard let result = await client.durationTracker.stopDuration(eventName) else { return } + let roundedDuration = (result.durationInSeconds * 1_000).rounded(.down) / 1_000 + + var mergedParams: EventParameters = [DefaultParams.Event.durationInSeconds.rawValue: roundedDuration] + mergedParams.merge(result.startParameters) + mergedParams.merge(parameters) + + await event(eventName, parameters: mergedParams, floatValue: roundedDuration) + } - let appIdHash = CryptoHashing.sha256(string: configuration.telemetryAppID, salt: "") - return UserDefaults(suiteName: "com.telemetrydeck.\(appIdHash.suffix(12))") + /// Cancels an in-progress duration measurement without sending an event. + public static func cancelDurationEvent(_ eventName: String) async { + guard let client = await storage.client else { return } + await client.durationTracker.cancelDuration(eventName) } } diff --git a/Sources/TelemetryDeck/Transport/DefaultEventCache.swift b/Sources/TelemetryDeck/Transport/DefaultEventCache.swift new file mode 100644 index 0000000..1f74e5c --- /dev/null +++ b/Sources/TelemetryDeck/Transport/DefaultEventCache.swift @@ -0,0 +1,51 @@ +import Foundation + +/// An event cache that stores events in memory and persists them to a JSON file on disk. +public actor DefaultEventCache: EventCaching { + private var events: [Event] = [] + private let maxBatchSize = 100 + private let fileURL: URL + + /// Creates a cache that stores events in the default caches directory. + public init() { + let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + self.fileURL = cachesURL.appendingPathComponent("telemetrysignalcache.json") + } + + /// Creates a cache that stores events at the given file URL. + public init(fileURL: URL) { + self.fileURL = fileURL + } + + /// Appends an event to the in-memory store. + public func add(_ event: Event) { + events.append(event) + } + + /// Removes and returns up to `maxBatchSize` events from the front of the queue. + public func pop() -> [Event] { + let batch = Array(events.prefix(maxBatchSize)) + events.removeFirst(min(maxBatchSize, events.count)) + return batch + } + + /// Returns the number of events currently cached in memory. + public func count() -> Int { + events.count + } + + /// Encodes and writes the current events to the cache file on disk. + public func persist() async { + guard let data = try? JSONEncoder.telemetryEncoder.encode(events) else { return } + try? data.write(to: fileURL) + } + + /// Reads previously persisted events from disk and prepends them to the in-memory queue. + public func restore() async { + guard let data = try? Data(contentsOf: fileURL), + let loaded = try? JSONDecoder.telemetryDecoder.decode([Event].self, from: data) + else { return } + try? FileManager.default.removeItem(at: fileURL) + events = loaded + events + } +} diff --git a/Sources/TelemetryDeck/Transport/DefaultEventTransmitter.swift b/Sources/TelemetryDeck/Transport/DefaultEventTransmitter.swift new file mode 100644 index 0000000..931858c --- /dev/null +++ b/Sources/TelemetryDeck/Transport/DefaultEventTransmitter.swift @@ -0,0 +1,94 @@ +import Foundation + +/// Transmits events to the TelemetryDeck API on a repeating timer, retrying failed events via the cache. +public actor DefaultEventTransmitter: EventTransmitting { + private let configuration: TelemetryDeck.Config + private let cache: any EventCaching + private let logger: any Logging + private let urlSession: URLSession + private var transmitTask: Task? + private let transmitInterval: TimeInterval = 10 + + /// Creates a transmitter with the given configuration, cache, logger, and URL session. + public init( + configuration: TelemetryDeck.Config, + cache: any EventCaching, + logger: any Logging, + urlSession: URLSession = .shared + ) { + self.configuration = configuration + self.cache = cache + self.logger = logger + self.urlSession = urlSession + } + + /// Starts the repeating transmission timer. + public func start() { + guard transmitTask == nil else { return } + transmitTask = Task { [weak self] in + while !Task.isCancelled { + await self?.transmitBatch() + let interval = self?.transmitInterval ?? 10 + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } + } + + /// Cancels the repeating transmission timer. + public func stop() { + transmitTask?.cancel() + transmitTask = nil + } + + /// Sends the given events to the API, returning any that could not be delivered. + public func transmit(_ events: [Event]) async -> [Event] { + guard let url = serviceURL else { return events } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + guard let body = try? JSONEncoder.telemetryEncoder.encode(events) else { + logger.log(.error, "Failed to encode \(events.count) events, dropping batch") + assertionFailure("Failed to encode events for transmission") + return [] + } + request.httpBody = body + + do { + let (_, response) = try await urlSession.data(for: request) + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) + else { + return events + } + return [] + } catch { + return events + } + } + + /// Immediately transmits all cached events without waiting for the next timer tick. + public func flush() async { + await transmitBatch() + } + + private func transmitBatch() async { + let events = await cache.pop() + guard !events.isEmpty else { return } + let failed = await transmit(events) + for event in failed { + await cache.add(event) + } + } + + private var serviceURL: URL? { + var base = configuration.apiBaseURL.absoluteString + if !base.hasSuffix("/") { + base += "/" + } + let url = URL(string: base + "v2/namespace/\(configuration.namespace)/") + assert(url != nil, "Failed to construct service URL from base: \(configuration.apiBaseURL)") + return url + } +} diff --git a/Sources/TelemetryDeck/Transport/DurationTracker.swift b/Sources/TelemetryDeck/Transport/DurationTracker.swift new file mode 100644 index 0000000..d07c45e --- /dev/null +++ b/Sources/TelemetryDeck/Transport/DurationTracker.swift @@ -0,0 +1,102 @@ +import Foundation + +actor DurationTracker: DurationTracking { + private struct ActiveDuration: Codable { + let startDate: Date + let parameters: [String: PayloadValue] + let includeBackgroundTime: Bool + } + + private var activeDurations: [String: ActiveDuration] = [:] + private var lastEnteredBackground: Date? + private var storage: (any ProcessorStorage)? + private var lifecycleTask: Task? + + private static let storageKey = "durationTrackerState" + + func start(storage: any ProcessorStorage) async { + self.storage = storage + await restoreState() + lifecycleTask = Task { + for await event in LifecycleNotifier.events() { + switch event { + case .background: + handleBackground() + case .foreground: + handleForeground() + case .termination: + break + } + } + } + } + + func stop() async { + lifecycleTask?.cancel() + lifecycleTask = nil + } + + func handleBackground() { + lastEnteredBackground = Date() + } + + func handleForeground() { + guard let backgroundDate = lastEnteredBackground else { return } + let backgroundDuration = Date().timeIntervalSince(backgroundDate) + lastEnteredBackground = nil + + for (name, duration) in activeDurations where !duration.includeBackgroundTime { + activeDurations[name] = ActiveDuration( + startDate: duration.startDate.addingTimeInterval(backgroundDuration), + parameters: duration.parameters, + includeBackgroundTime: duration.includeBackgroundTime + ) + } + } + + func startDuration( + _ eventName: String, + parameters: EventParameters, + includeBackgroundTime: Bool + ) { + activeDurations[eventName] = ActiveDuration( + startDate: Date(), + parameters: parameters.payloadDictionary, + includeBackgroundTime: includeBackgroundTime + ) + Task { await persistState() } + } + + func stopDuration(_ eventName: String) -> DurationResult? { + guard let duration = activeDurations.removeValue(forKey: eventName) else { + return nil + } + Task { await persistState() } + let elapsed = Date().timeIntervalSince(duration.startDate) + return DurationResult( + durationInSeconds: elapsed, + startParameters: EventParameters(duration.parameters) + ) + } + + func cancelDuration(_ eventName: String) { + activeDurations.removeValue(forKey: eventName) + Task { await persistState() } + } + + private func persistState() async { + guard let storage else { return } + guard let data = try? JSONEncoder().encode(activeDurations) else { return } + await storage.set(data, forKey: Self.storageKey) + } + + private func restoreState() async { + guard let storage else { return } + guard let data = await storage.data(forKey: Self.storageKey), + let restored = try? JSONDecoder().decode([String: ActiveDuration].self, from: data) + else { + return + } + activeDurations = restored + } +} diff --git a/Sources/TelemetryDeck/Transport/EventCaching.swift b/Sources/TelemetryDeck/Transport/EventCaching.swift new file mode 100644 index 0000000..b69a75e --- /dev/null +++ b/Sources/TelemetryDeck/Transport/EventCaching.swift @@ -0,0 +1,27 @@ +import Foundation + +/// A queue of events waiting to be transmitted, with optional disk persistence. +public protocol EventCaching: Sendable { + func add(_ event: Event) async + func add(_ events: [Event]) async + func pop() async -> [Event] + func count() async -> Int + /// Persists the current in-memory events to disk. + func persist() async + /// Restores previously persisted events from disk into memory. + func restore() async +} + +extension EventCaching { + /// Adds a sequence of events one by one. + public func add(_ events: [Event]) async { + for event in events { + await add(event) + } + } + + /// Default no-op implementation. + public func persist() async {} + /// Default no-op implementation. + public func restore() async {} +} diff --git a/Sources/TelemetryDeck/Transport/EventTransmitting.swift b/Sources/TelemetryDeck/Transport/EventTransmitting.swift new file mode 100644 index 0000000..5c0fa02 --- /dev/null +++ b/Sources/TelemetryDeck/Transport/EventTransmitting.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Sends batches of events to the TelemetryDeck ingestion API, returning any that failed. +public protocol EventTransmitting: Sendable { + func transmit(_ events: [Event]) async -> [Event] + func flush() async + func start() async + func stop() async +} diff --git a/Sources/TelemetryDeck/Transport/InMemoryEventCache.swift b/Sources/TelemetryDeck/Transport/InMemoryEventCache.swift new file mode 100644 index 0000000..e4667da --- /dev/null +++ b/Sources/TelemetryDeck/Transport/InMemoryEventCache.swift @@ -0,0 +1,26 @@ +import Foundation + +/// A non-persistent event cache that stores events only in memory; suitable for testing. +public actor InMemoryEventCache: EventCaching { + private var events: [Event] = [] + + /// Creates an empty in-memory event cache. + public init() {} + + /// Appends an event to the in-memory store. + public func add(_ event: Event) { + events.append(event) + } + + /// Removes and returns all cached events. + public func pop() -> [Event] { + let all = events + events.removeAll() + return all + } + + /// Returns the number of events currently in the cache. + public func count() -> Int { + events.count + } +} diff --git a/Sources/TelemetryDeck/Transport/SpyEventTransmitter.swift b/Sources/TelemetryDeck/Transport/SpyEventTransmitter.swift new file mode 100644 index 0000000..3d1cd80 --- /dev/null +++ b/Sources/TelemetryDeck/Transport/SpyEventTransmitter.swift @@ -0,0 +1,23 @@ +import Foundation + +/// A test transmitter that records all transmitted events without sending them to the network. +public actor SpyEventTransmitter: EventTransmitting { + /// All events that have been passed to `transmit(_:)`. + public private(set) var transmittedEvents: [Event] = [] + + /// Creates an empty spy transmitter. + public init() {} + + /// Appends the events to the recorded list and reports success. + public func transmit(_ events: [Event]) async -> [Event] { + transmittedEvents.append(contentsOf: events) + return [] + } + + /// No-op; no batched transmission is pending. + public func flush() async {} + /// No-op; no timer to start. + public func start() async {} + /// No-op; no timer to stop. + public func stop() async {} +} diff --git a/Sources/TelemetryDeck/Transport/TelemetryEngine.swift b/Sources/TelemetryDeck/Transport/TelemetryEngine.swift new file mode 100644 index 0000000..df20bff --- /dev/null +++ b/Sources/TelemetryDeck/Transport/TelemetryEngine.swift @@ -0,0 +1,203 @@ +import Foundation + +#if canImport(UIKit) && !os(watchOS) + import UIKit +#endif + +/// The central coordinator that owns the processor pipeline, event cache, and transmitter. +actor TelemetryEngine: EventSending { + let configuration: TelemetryDeck.Config + private let processors: [any EventProcessor] + private let pipeline: ProcessorPipeline + private let cache: any EventCaching + private let transmitter: any EventTransmitting + private let logger: any Logging + private let storage: any ProcessorStorage + let durationTracker: DurationTracker + private var analyticsDisabled = false + private var started = false + private var lifecycleTask: Task? + + #if canImport(UIKit) && !os(watchOS) + private final class BackgroundTaskHolder: @unchecked Sendable { + var identifier = UIBackgroundTaskIdentifier.invalid + } + #endif + + private init( + configuration: TelemetryDeck.Config, + processors: [any EventProcessor], + cache: any EventCaching, + transmitter: any EventTransmitting, + logger: any Logging, + storage: any ProcessorStorage + ) { + self.configuration = configuration + self.processors = processors + self.cache = cache + self.transmitter = transmitter + self.logger = logger + self.storage = storage + self.durationTracker = DurationTracker() + self.pipeline = ProcessorPipeline( + processors: processors, + finalizer: EventFinalizer(configuration: configuration) + ) + } + + /// Creates, configures, and starts a `TelemetryEngine` with the provided or default dependencies. + static func create( + configuration: TelemetryDeck.Config, + processors: [any EventProcessor], + cache: (any EventCaching)? = nil, + transmitter: (any EventTransmitting)? = nil, + logger: (any Logging)? = nil, + storage: (any ProcessorStorage)? = nil + ) async -> TelemetryEngine { + let resolvedLogger = logger ?? DefaultLogger() + let resolvedCache = cache ?? DefaultEventCache() + let appIdHash = CryptoHashing.sha256(string: configuration.appID, salt: "") + let resolvedStorage = + storage + ?? UserDefaultsProcessorStorage( + suiteName: "com.telemetrydeck.\(appIdHash.suffix(12))" + ) + let resolvedTransmitter = + transmitter + ?? DefaultEventTransmitter( + configuration: configuration, + cache: resolvedCache, + logger: resolvedLogger + ) + + let client = TelemetryEngine( + configuration: configuration, + processors: processors, + cache: resolvedCache, + transmitter: resolvedTransmitter, + logger: resolvedLogger, + storage: resolvedStorage + ) + await client.start() + return client + } + + private func start() async { + guard !started else { return } + started = true + await cache.restore() + for processor in processors { + await processor.start(storage: storage, logger: logger, emitter: self) + } + await durationTracker.start(storage: storage) + await transmitter.start() + setupLifecycleObservers() + } + + /// Stops all processors and the transmitter, persists the cache, and cancels lifecycle observers. + func shutdown() async { + for processor in processors { + await processor.stop() + } + await durationTracker.stop() + await transmitter.stop() + await cache.persist() + lifecycleTask?.cancel() + lifecycleTask = nil + started = false + } + + /// Processes the input through the pipeline and adds the resulting event to the cache. + func send(_ input: EventInput) async { + guard !analyticsDisabled else { return } + let context = EventContext() + do { + let event = try await pipeline.process(input, context: context) + await cache.add(event) + } catch let error as ProcessorError { + switch error { + case .eventFiltered: + logger.log(.debug, "Event filtered by processor pipeline") + case .processingFailed(let underlying): + logger.log(.error, "Pipeline processing failed: \(underlying)") + } + } catch { + logger.log(.error, "Pipeline error: \(error)") + } + } + + /// Enables or disables analytics; while disabled, events are silently dropped. + func setAnalyticsDisabled(_ disabled: Bool) { + analyticsDisabled = disabled + } + + /// Indicates whether analytics is currently disabled. + var isAnalyticsDisabled: Bool { + analyticsDisabled + } + + /// Immediately transmits all cached events. + func flush() async { + await transmitter.flush() + } + + /// Returns the first processor in the pipeline that is exactly of the given concrete type. + func processor(ofType type: T.Type) -> T? { + processors.first { $0 is T } as? T + } + + /// Returns the first processor in the pipeline that conforms to the given protocol. + func processor(conformingTo type: T.Type) -> T? { + processors.first { $0 is T } as? T + } + + private func handleBackground() async { + #if canImport(UIKit) && !os(watchOS) + if !Environment.isAppExtension { + let app = await MainActor.run { UIApplication.shared } + let holder = BackgroundTaskHolder() + holder.identifier = await MainActor.run { + app.beginBackgroundTask { + app.endBackgroundTask(holder.identifier) + } + } + await transmitter.stop() + await cache.persist() + await MainActor.run { + app.endBackgroundTask(holder.identifier) + } + } else { + await transmitter.stop() + await cache.persist() + } + #else + await transmitter.stop() + await cache.persist() + #endif + } + + private func handleForeground() async { + await cache.restore() + await transmitter.start() + } + + private func handleTermination() async { + await cache.persist() + await transmitter.stop() + } + + private func setupLifecycleObservers() { + lifecycleTask = Task { + for await event in LifecycleNotifier.events() { + switch event { + case .background: + await handleBackground() + case .foreground: + await handleForeground() + case .termination: + await handleTermination() + } + } + } + } +} diff --git a/Sources/TelemetryDeck/Utilities/CryptoHashing.swift b/Sources/TelemetryDeck/Utilities/CryptoHashing.swift new file mode 100644 index 0000000..c690f5b --- /dev/null +++ b/Sources/TelemetryDeck/Utilities/CryptoHashing.swift @@ -0,0 +1,36 @@ +import CommonCrypto +import Foundation + +#if canImport(CryptoKit) + import CryptoKit +#endif + +enum CryptoHashing { + static func sha256(string: String, salt: String) -> String { + if let strData = (string + salt).data(using: String.Encoding.utf8) { + #if canImport(CryptoKit) + if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) { + let digest = SHA256.hash(data: strData) + return digest.compactMap { String(format: "%02x", $0) }.joined() + } else { + return commonCryptoSha256(strData: strData) + } + #else + return commonCryptoSha256(strData: strData) + #endif + } + return "" + } + + static func commonCryptoSha256(strData: Data) -> String { + var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + _ = strData.withUnsafeBytes { + CC_SHA256($0.baseAddress, UInt32(strData.count), &digest) + } + var sha256String = "" + for byte in digest { + sha256String += String(format: "%02x", UInt8(byte)) + } + return sha256String + } +} diff --git a/Sources/TelemetryDeck/Utilities/Environment.swift b/Sources/TelemetryDeck/Utilities/Environment.swift new file mode 100644 index 0000000..d201335 --- /dev/null +++ b/Sources/TelemetryDeck/Utilities/Environment.swift @@ -0,0 +1,7 @@ +import Foundation + +enum Environment { + static let isAppExtension: Bool = { + Bundle.main.bundlePath.hasSuffix(".appex") + }() +} diff --git a/Sources/TelemetryDeck/Helpers/JSONFormatting.swift b/Sources/TelemetryDeck/Utilities/JSONFormatting.swift similarity index 97% rename from Sources/TelemetryDeck/Helpers/JSONFormatting.swift rename to Sources/TelemetryDeck/Utilities/JSONFormatting.swift index a78558f..f133d4a 100644 --- a/Sources/TelemetryDeck/Helpers/JSONFormatting.swift +++ b/Sources/TelemetryDeck/Utilities/JSONFormatting.swift @@ -58,13 +58,11 @@ extension JSONEncoder { } extension Data { - /// NSString gives us a nice sanitized debugDescription var prettyPrintedJSONString: NSString? { guard let object = try? JSONSerialization.jsonObject(with: self, options: []), let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]), let prettyPrintedString = NSString(data: data, encoding: String.Encoding.utf8.rawValue) else { return nil } - return prettyPrintedString } } diff --git a/Sources/TelemetryDeck/Utilities/LifecycleNotifier.swift b/Sources/TelemetryDeck/Utilities/LifecycleNotifier.swift new file mode 100644 index 0000000..66307e7 --- /dev/null +++ b/Sources/TelemetryDeck/Utilities/LifecycleNotifier.swift @@ -0,0 +1,90 @@ +@preconcurrency import Foundation + +#if canImport(WatchKit) + import WatchKit +#elseif canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +enum LifecycleEvent: Sendable { + case background + case foreground + case termination +} + +struct LifecycleNotifier: Sendable { + private final class ObserverBox: @unchecked Sendable { + var observers: [NSObjectProtocol] = [] + } + + static func events() -> AsyncStream { + AsyncStream { continuation in + let box = ObserverBox() + + #if canImport(WatchKit) + box.observers.append( + NotificationCenter.default.addObserver( + forName: WKApplication.didEnterBackgroundNotification, + object: nil, + queue: nil + ) { _ in continuation.yield(.background) } + ) + box.observers.append( + NotificationCenter.default.addObserver( + forName: WKApplication.willEnterForegroundNotification, + object: nil, + queue: nil + ) { _ in continuation.yield(.foreground) } + ) + #elseif canImport(UIKit) && !os(watchOS) + box.observers.append( + NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: nil + ) { _ in continuation.yield(.background) } + ) + box.observers.append( + NotificationCenter.default.addObserver( + forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: nil + ) { _ in continuation.yield(.foreground) } + ) + #elseif canImport(AppKit) + box.observers.append( + NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: nil + ) { _ in continuation.yield(.background) } + ) + box.observers.append( + NotificationCenter.default.addObserver( + forName: NSApplication.willBecomeActiveNotification, + object: nil, + queue: nil + ) { _ in continuation.yield(.foreground) } + ) + #endif + + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + box.observers.append( + NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, + object: nil, + queue: nil + ) { _ in continuation.yield(.termination) } + ) + #endif + + continuation.onTermination = { @Sendable _ in + for observer in box.observers { + NotificationCenter.default.removeObserver(observer) + } + } + } + } +} diff --git a/Sources/TelemetryDeck/Utilities/TimezoneFormatting.swift b/Sources/TelemetryDeck/Utilities/TimezoneFormatting.swift new file mode 100644 index 0000000..9687116 --- /dev/null +++ b/Sources/TelemetryDeck/Utilities/TimezoneFormatting.swift @@ -0,0 +1,14 @@ +import Foundation + +enum TimezoneFormatting { + static func utcOffsetString(from timeZone: TimeZone = .current) -> String { + let secondsFromGMT = timeZone.secondsFromGMT() + let hours = abs(secondsFromGMT) / 3600 + let minutes = abs(secondsFromGMT) / 60 % 60 + let sign = secondsFromGMT >= 0 ? "+" : "-" + if minutes > 0 { + return "UTC\(sign)\(hours):\(String(format: "%02d", minutes))" + } + return "UTC\(sign)\(hours)" + } +} diff --git a/Sources/TelemetryDeck/Utilities/UserIdentifier.swift b/Sources/TelemetryDeck/Utilities/UserIdentifier.swift new file mode 100644 index 0000000..cb96f46 --- /dev/null +++ b/Sources/TelemetryDeck/Utilities/UserIdentifier.swift @@ -0,0 +1,51 @@ +import Foundation + +#if os(iOS) || os(tvOS) || os(visionOS) + import UIKit +#elseif os(watchOS) + import WatchKit +#endif + +enum UserIdentifier { + static func resolveDefaultUserIdentifier(storage: any ProcessorStorage) async -> String { + #if os(iOS) || os(tvOS) || os(visionOS) + if let vendorID = await MainActor.run(body: { UIDevice.current.identifierForVendor?.uuidString }) { + return vendorID + } + return fallbackIdentifier + #elseif os(watchOS) + if let vendorID = await MainActor.run(body: { WKInterfaceDevice.current().identifierForVendor?.uuidString }) { + return vendorID + } + return fallbackIdentifier + #elseif os(macOS) + if let stored = await storage.string(forKey: "defaultUserIdentifier") { + return stored + } + let newID = UUID().uuidString + await storage.set(newID, forKey: "defaultUserIdentifier") + return newID + #else + return fallbackIdentifier + #endif + } + + private static var fallbackIdentifier: String { + let version = ProcessInfo.processInfo.operatingSystemVersion + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0" + #if os(macOS) + let platform = "macOS" + #elseif os(visionOS) + let platform = "visionOS" + #elseif os(iOS) + let platform = "iOS" + #elseif os(watchOS) + let platform = "watchOS" + #elseif os(tvOS) + let platform = "tvOS" + #else + let platform = "Unknown" + #endif + return "unknown user \(platform) \(version.majorVersion).\(version.minorVersion).\(version.patchVersion) \(buildNumber)" + } +} diff --git a/TelemetryDeck.podspec b/TelemetryDeck.podspec deleted file mode 100644 index d64c7db..0000000 --- a/TelemetryDeck.podspec +++ /dev/null @@ -1,32 +0,0 @@ -Pod::Spec.new do |spec| - spec.name = "TelemetryDeck" - spec.version = ENV['LIB_VERSION'] || '2.0.0' #fallback to major version - spec.summary = "Swift SDK for TelemetryDeck" - spec.swift_versions = "5.9" - spec.summary = "Swift SDK for TelemetryDeck, a privacy-first analytics service for apps. Sign up for a free account at telemetrydeck.com." - spec.description = <<-DESC - Build better products with live usage data. - Capture and analyize users moving through your app - and get help deciding how to grow, all without - compromising privacy! - - Setting up TelemetryDeck takes less than 10 minutes. - Immediately after publishing your app, TelemetryDeck - can show you a lot of base level information: - - How many users are new to your app? - How many users are active? - Which versions of your app are people running, and - on which operating system and device type are they? - DESC - spec.homepage = "https://telemetrydeck.com/?source=cocoapods" - spec.license = { :type => "MIT", :file => "LICENSE" } - spec.author = { "Daniel Jilg" => "daniel@telemetrydeck.com" } - spec.ios.deployment_target = "12.0" - spec.osx.deployment_target = "10.13" - spec.watchos.deployment_target = "5.0" - spec.visionos.deployment_target = "1.0" - spec.tvos.deployment_target = "13.0" - spec.source = { :git => "https://github.com/TelemetryDeck/SwiftSDK.git", :tag => "#{spec.version}" } - spec.source_files = "Sources/TelemetryDeck/**/*.swift" -end diff --git a/Tests/TelemetryDeckApproachableConcurrencyTests/ApproachableConcurrencyTests.swift b/Tests/TelemetryDeckApproachableConcurrencyTests/ApproachableConcurrencyTests.swift new file mode 100644 index 0000000..083537c --- /dev/null +++ b/Tests/TelemetryDeckApproachableConcurrencyTests/ApproachableConcurrencyTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct ApproachableConcurrencyTests { + @Test + func customProcessorCompilesUnderMainActorIsolation() async throws { + let processor = TestProcessor() + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let finalizer = EventFinalizer(configuration: config) + let pipeline = ProcessorPipeline(processors: [processor], finalizer: finalizer) + + let input = EventInput("Test.signal") + let context = EventContext() + let signal = try await pipeline.process(input, context: context) + + #expect(signal.type == "Test.signal") + #expect(signal.payload["customKey"] == "customValue") + } + + @Test + func eventParametersDictionaryLiteralWorksUnderMainActorIsolation() { + let params: EventParameters = [ + "stringParam": "value", + "intParam": 42, + "boolParam": true, + ] + + #expect(params["stringParam"] as? String == "value") + #expect(params["intParam"] as? Int == 42) + #expect(params["boolParam"] as? Bool == true) + } + + @Test + func telemetryDeckEventCallCompilesUnderMainActorIsolation() async throws { + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "test-concurrency", namespace: "test") + let client = await TelemetryEngine.create( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await client.send(EventInput("Test.approachableConcurrency", parameters: ["testKey": "testValue"])) + + let count = await cache.count() + #expect(count == 1) + + await client.shutdown() + } + + @Test + func eventInputCreationWorksUnderMainActorIsolation() { + let input = EventInput( + "Test.signal", + parameters: ["key": "value"], + floatValue: 1.23, + customUserID: "user@example.com" + ) + + #expect(input.name == "Test.signal") + #expect(input.parameters["key"] as? String == "value") + #expect(input.floatValue == 1.23) + #expect(input.customUserID == "user@example.com") + } + + @Test + func syncEventCompilesUnderMainActorIsolation() { + let input = EventInput("Test.syncSignal", parameters: ["key": "value"], floatValue: 1.0) + #expect(input.name == "Test.syncSignal") + #expect(input.floatValue == 1.0) + } + + @Test + func eventContextMutationWorksUnderMainActorIsolation() { + var context = EventContext() + + context.addParameter("key1", value: "value1") + context.addParameter("key2", value: 42) + + context.sessionID = UUID() + context.userIdentifier = "user123" + context.isTestMode = true + + #expect(context.metadata["key1"] as? String == "value1") + #expect(context.metadata["key2"] as? Int == 42) + #expect(context.sessionID != nil) + #expect(context.userIdentifier == "user123") + #expect(context.isTestMode == true) + } +} + +private struct TestProcessor: EventProcessor { + func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var context = context + context.addParameter("customKey", value: "customValue") + return try await next(input, context) + } +} diff --git a/Tests/TelemetryDeckTests/AccessibilityProcessorTests.swift b/Tests/TelemetryDeckTests/AccessibilityProcessorTests.swift new file mode 100644 index 0000000..6677dfd --- /dev/null +++ b/Tests/TelemetryDeckTests/AccessibilityProcessorTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +#if os(iOS) || os(tvOS) || os(visionOS) + import UIKit +#elseif os(macOS) + import AppKit +#endif + +struct AccessibilityProcessorTests { + #if os(iOS) || os(tvOS) || os(visionOS) + @Test + func leftToRightDirectionString() { + #expect(AccessibilityProcessor.directionString(from: .leftToRight) == "leftToRight") + } + + @Test + func rightToLeftDirectionString() { + #expect(AccessibilityProcessor.directionString(from: .rightToLeft) == "rightToLeft") + } + #elseif os(macOS) + @Test + func leftToRightDirectionString() { + #expect(AccessibilityProcessor.directionString(from: .leftToRight) == "leftToRight") + } + + @Test + func rightToLeftDirectionString() { + #expect(AccessibilityProcessor.directionString(from: .rightToLeft) == "rightToLeft") + } + #endif +} diff --git a/Tests/TelemetryDeckTests/ArrayExtensionTests.swift b/Tests/TelemetryDeckTests/ArrayExtensionTests.swift deleted file mode 100644 index 50ae268..0000000 --- a/Tests/TelemetryDeckTests/ArrayExtensionTests.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Testing - -@testable import TelemetryDeck - -enum ArrayExtensionTests { - enum CountISODatesOnOrAfter { - @Test - static func typicalCase() { - let dates = ["2025-01-01", "2025-01-15", "2025-02-01", "2025-03-01"] - - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2025-01-15") == 3) - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2025-02-01") == 2) - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2025-03-02") == 0) - } - - @Test - static func edgeCases() { - // Empty array - let emptyDates: [String] = [] - #expect(emptyDates.countISODatesOnOrAfter(cutoffISODate: "2025-01-01") == 0) - - // Single date, various cutoffs - let singleDate = ["2025-01-15"] - #expect(singleDate.countISODatesOnOrAfter(cutoffISODate: "2025-01-14") == 1) - #expect(singleDate.countISODatesOnOrAfter(cutoffISODate: "2025-01-15") == 1) - #expect(singleDate.countISODatesOnOrAfter(cutoffISODate: "2025-01-16") == 0) - } - - @Test - static func duplicateDates() { - let datesWithDuplicates = [ - "2025-01-01", - "2025-01-01", // Duplicate - "2025-02-01", - "2025-02-01", // Duplicate - "2025-03-01", - ] - - #expect(datesWithDuplicates.countISODatesOnOrAfter(cutoffISODate: "2025-01-01") == 5) - #expect(datesWithDuplicates.countISODatesOnOrAfter(cutoffISODate: "2025-02-01") == 3) - #expect(datesWithDuplicates.countISODatesOnOrAfter(cutoffISODate: "2025-03-01") == 1) - } - - @Test - static func complexDateRanges() { - let dates = [ - "2020-12-31", // End of 2020 - "2021-01-01", // Start of 2021 - "2021-12-31", // End of 2021 - "2022-01-01", // Start of 2022 - "2022-09-30", // End of September - "2022-10-01", // Start of October - "2023-01-09", // Single digit day - "2023-01-10", // Double digit day - "2023-09-09", // Both single digit - "2023-09-10", // Mixed digits - "2023-10-09", // Mixed digits different order - "2023-10-10", // Both double digits - "2024-02-28", // End of February - "2024-02-29", // Leap year day - "2024-03-01", // Start of March - "2025-01-01", // Far future - ] - - // Test year boundaries - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2020-12-31") == 16) - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2021-01-01") == 15) - - // Test month transitions - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2022-09-30") == 12) - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2022-10-01") == 11) - - // Test single/double digit transitions - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2023-01-09") == 10) - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2023-01-10") == 9) - - // Test leap year period - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2024-02-28") == 4) - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2024-02-29") == 3) - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2024-03-01") == 2) - - // Test future date - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2025-01-01") == 1) - #expect(dates.countISODatesOnOrAfter(cutoffISODate: "2025-01-02") == 0) - } - } -} diff --git a/Tests/TelemetryDeckTests/CalendarProcessorTests.swift b/Tests/TelemetryDeckTests/CalendarProcessorTests.swift new file mode 100644 index 0000000..854bef0 --- /dev/null +++ b/Tests/TelemetryDeckTests/CalendarProcessorTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct CalendarProcessorTests { + let testConfig = TelemetryDeck.Config(appID: "test-app", namespace: "test-ns") + + @Test + func calendarProcessorAddsDateComponents() async throws { + let processor = CalendarProcessor() + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: testConfig) + ) + + let input = EventInput("Test.signal") + let context = EventContext() + let signal = try await pipeline.process(input, context: context) + + #expect(signal.payload["TelemetryDeck.Calendar.dayOfMonth"] != nil) + #expect(signal.payload["TelemetryDeck.Calendar.dayOfWeek"] != nil) + #expect(signal.payload["TelemetryDeck.Calendar.dayOfYear"] != nil) + #expect(signal.payload["TelemetryDeck.Calendar.weekOfYear"] != nil) + #expect(signal.payload["TelemetryDeck.Calendar.isWeekend"] != nil) + #expect(signal.payload["TelemetryDeck.Calendar.monthOfYear"] != nil) + #expect(signal.payload["TelemetryDeck.Calendar.quarterOfYear"] != nil) + #expect(signal.payload["TelemetryDeck.Calendar.hourOfDay"] != nil) + } + + @Test + func weekdayMappingSundayIsSeven() async throws { + let calendar = Calendar(identifier: .gregorian) + let components = DateComponents(year: 2025, month: 1, day: 5) + let sunday = calendar.date(from: components)! + + let sundayWeekday = calendar.component(.weekday, from: sunday) + #expect(sundayWeekday == 1) + + let processor = CalendarProcessor() + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: testConfig) + ) + let input = EventInput("Test.signal") + let context = EventContext() + let signal = try await pipeline.process(input, context: context) + + let calendar2 = Calendar(identifier: .gregorian) + let nowComponents = calendar2.dateComponents([.weekday], from: input.timestamp) + let expectedDayOfWeek = nowComponents.weekday.map { $0 == 1 ? 7 : $0 - 1 } ?? -1 + + if case .int(let dayOfWeek) = signal.payload["TelemetryDeck.Calendar.dayOfWeek"] { + #expect(Int(dayOfWeek) == expectedDayOfWeek) + } else { + Issue.record("dayOfWeek not found in payload") + } + } + + @Test + func hourRangeIs1To24() async throws { + let processor = CalendarProcessor() + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: testConfig) + ) + let input = EventInput("Test.signal") + let context = EventContext() + let signal = try await pipeline.process(input, context: context) + + if case .int(let hour) = signal.payload["TelemetryDeck.Calendar.hourOfDay"] { + #expect(hour >= 1) + #expect(hour <= 24) + } else { + Issue.record("hourOfDay not found in payload") + } + } + + @Test + func weekendDetection() async throws { + let processor = CalendarProcessor() + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: testConfig) + ) + let input = EventInput("Test.signal") + let context = EventContext() + let signal = try await pipeline.process(input, context: context) + + let calendar = Calendar(identifier: .gregorian) + let nowComponents = calendar.dateComponents([.weekday], from: input.timestamp) + let dayOfWeek = nowComponents.weekday.map { $0 == 1 ? 7 : $0 - 1 } ?? -1 + let expectedIsWeekend = dayOfWeek >= 6 + + if case .bool(let isWeekend) = signal.payload["TelemetryDeck.Calendar.isWeekend"] { + #expect(isWeekend == expectedIsWeekend) + } else { + Issue.record("isWeekend not found in payload") + } + } + + @Test + func quarterCalculation() async throws { + let processor = CalendarProcessor() + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: testConfig) + ) + let input = EventInput("Test.signal") + let context = EventContext() + let signal = try await pipeline.process(input, context: context) + + if case .int(let quarter) = signal.payload["TelemetryDeck.Calendar.quarterOfYear"] { + #expect(quarter >= 1) + #expect(quarter <= 4) + } else { + Issue.record("quarterOfYear not found in payload") + } + } + + @Test + func weekOfYear() async throws { + let processor = CalendarProcessor() + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: testConfig) + ) + let input = EventInput("Test.signal") + let context = EventContext() + let signal = try await pipeline.process(input, context: context) + + if case .int(let week) = signal.payload["TelemetryDeck.Calendar.weekOfYear"] { + #expect(week >= 1) + #expect(week <= 53) + } else { + Issue.record("weekOfYear not found in payload") + } + } +} diff --git a/Tests/TelemetryDeckTests/ConfigTests.swift b/Tests/TelemetryDeckTests/ConfigTests.swift new file mode 100644 index 0000000..8dbc214 --- /dev/null +++ b/Tests/TelemetryDeckTests/ConfigTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct ConfigTests { + @Test + func defaultValues() { + let config = TelemetryDeck.Config( + appID: "test-app-id", + namespace: "test-namespace" + ) + + #expect(config.appID == "test-app-id") + #expect(config.namespace == "test-namespace") + #expect(config.apiBaseURL == URL(string: "https://nom.telemetrydeck.com")!) + #expect(config.salt == "") + } + + @Test + func customAPIBaseURL() { + let customURL = URL(string: "https://custom.telemetry.example.com")! + let config = TelemetryDeck.Config( + appID: "test-app-id", + namespace: "test-namespace", + apiBaseURL: customURL + ) + + #expect(config.apiBaseURL == customURL) + } + + @Test + func customSalt() { + let customSalt = "my-custom-salt-12345" + let config = TelemetryDeck.Config( + appID: "test-app-id", + namespace: "test-namespace", + salt: customSalt + ) + + #expect(config.salt == customSalt) + } + + // MARK: - Validation + + @Test + func validateThrowsForEmptyAppID() { + let config = TelemetryDeck.Config(appID: "", namespace: "test-namespace") + #expect(throws: TelemetryDeckError.self) { + try config.validate() + } + } + + @Test + func validateThrowsForWhitespaceOnlyAppID() { + let config = TelemetryDeck.Config(appID: " \t\n", namespace: "test-namespace") + #expect(throws: TelemetryDeckError.self) { + try config.validate() + } + } + + @Test + func validateThrowsForEmptyNamespace() { + let config = TelemetryDeck.Config(appID: "valid-app-id", namespace: "") + #expect(throws: TelemetryDeckError.self) { + try config.validate() + } + } + + @Test + func validateThrowsForWhitespaceOnlyNamespace() { + let config = TelemetryDeck.Config(appID: "valid-app-id", namespace: " ") + #expect(throws: TelemetryDeckError.self) { + try config.validate() + } + } + + @Test + func validateSucceedsForValidConfig() throws { + let config = TelemetryDeck.Config(appID: "valid-app-id", namespace: "valid-namespace") + try config.validate() + } + + @Test + func validateErrorCodeIsInvalidConfig() { + let config = TelemetryDeck.Config(appID: "", namespace: "test") + do { + try config.validate() + Issue.record("Expected TelemetryDeckError to be thrown") + } catch { + #expect(error.code == .invalidConfiguration) + } + } +} diff --git a/Tests/TelemetryDeckTests/DefaultEventTransmitterTests.swift b/Tests/TelemetryDeckTests/DefaultEventTransmitterTests.swift new file mode 100644 index 0000000..8312f06 --- /dev/null +++ b/Tests/TelemetryDeckTests/DefaultEventTransmitterTests.swift @@ -0,0 +1,567 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +private final class MockURLProtocol: URLProtocol { + private static let handlerLock = NSLock() + private nonisolated(unsafe) static var responseHandler: ((URLRequest) -> (HTTPURLResponse?, Error?))? + + static func setResponseHandler(_ handler: @escaping (URLRequest) -> (HTTPURLResponse?, Error?)) { + handlerLock.lock() + defer { handlerLock.unlock() } + responseHandler = handler + } + + static func reset() { + handlerLock.lock() + defer { handlerLock.unlock() } + responseHandler = nil + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.handlerLock.lock() + let handler = Self.responseHandler + Self.handlerLock.unlock() + + guard let handler = handler else { + client?.urlProtocol(self, didFailWithError: NSError(domain: "MockError", code: -1)) + return + } + + let (response, error) = handler(request) + + if let error = error { + client?.urlProtocol(self, didFailWithError: error) + } else if let response = response { + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + } + + override func stopLoading() {} +} + +private func createMockURLSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: config) +} + +private func createTestEvent(type: String = "test.event") -> Event { + Event( + appID: "test-app-id", + type: type, + clientUser: "test-user", + sessionID: "test-session", + receivedAt: Date(), + payload: ["key": "value"], + floatValue: nil, + isTestMode: false + ) +} + +@Suite("DefaultEventTransmitter Tests", .serialized) +struct DefaultEventTransmitterTests { + @Test + func serviceURLConstructedCorrectly() async { + let baseURL = URL(string: "https://api.example.com")! + let namespace = "test-namespace" + let config = TelemetryDeck.Config( + appID: "test-app", + namespace: namespace, + apiBaseURL: baseURL + ) + let cache = InMemoryEventCache() + + let requestCapture = Locked(nil) + MockURLProtocol.setResponseHandler { request in + requestCapture.withLock { $0 = request } + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ) + return (response, nil) + } + + let session = createMockURLSession() + let testTransmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: session + ) + + let event = createTestEvent() + _ = await testTransmitter.transmit([event]) + + let capturedRequest = requestCapture.withLock { $0 } + #expect(capturedRequest != nil) + + if let url = capturedRequest?.url { + let expectedPath = "/v2/namespace/\(namespace)" + #expect(url.path == expectedPath) + #expect(url.scheme == baseURL.scheme) + #expect(url.host == baseURL.host) + } + + MockURLProtocol.reset() + } + + @Test + func serviceURLPreservesBaseURLPathComponents() async { + let baseURL = URL(string: "https://example.com/array/sensors")! + let namespace = "test-namespace" + let config = TelemetryDeck.Config( + appID: "test-app", + namespace: namespace, + apiBaseURL: baseURL + ) + let cache = InMemoryEventCache() + + let requestCapture = Locked(nil) + MockURLProtocol.setResponseHandler { request in + requestCapture.withLock { $0 = request } + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ) + return (response, nil) + } + + let session = createMockURLSession() + let transmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: session + ) + + _ = await transmitter.transmit([createTestEvent()]) + + let capturedRequest = requestCapture.withLock { $0 } + #expect(capturedRequest != nil) + + if let url = capturedRequest?.url { + #expect(url.path == "/array/sensors/v2/namespace/\(namespace)") + #expect(url.scheme == "https") + #expect(url.host == "example.com") + } + + MockURLProtocol.reset() + } + + @Test + func serviceURLPreservesBaseURLPathComponentsWithTrailingSlash() async { + let baseURL = URL(string: "https://example.com/array/sensors/")! + let namespace = "test-namespace" + let config = TelemetryDeck.Config( + appID: "test-app", + namespace: namespace, + apiBaseURL: baseURL + ) + let cache = InMemoryEventCache() + + let requestCapture = Locked(nil) + MockURLProtocol.setResponseHandler { request in + requestCapture.withLock { $0 = request } + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ) + return (response, nil) + } + + let session = createMockURLSession() + let transmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: session + ) + + _ = await transmitter.transmit([createTestEvent()]) + + let capturedRequest = requestCapture.withLock { $0 } + #expect(capturedRequest != nil) + + if let url = capturedRequest?.url { + #expect(url.path == "/array/sensors/v2/namespace/\(namespace)") + #expect(url.scheme == "https") + #expect(url.host == "example.com") + } + + MockURLProtocol.reset() + } + + @Test + func transmitReturnsEmptyOnSuccess() async { + let config = TelemetryDeck.Config( + appID: "test-app", + namespace: "test" + ) + let cache = InMemoryEventCache() + + MockURLProtocol.setResponseHandler { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ) + return (response, nil) + } + + let session = createMockURLSession() + let transmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: session + ) + + let events = [ + createTestEvent(type: "event.one"), + createTestEvent(type: "event.two"), + createTestEvent(type: "event.three"), + ] + + let failed = await transmitter.transmit(events) + + #expect(failed.isEmpty) + + MockURLProtocol.reset() + } + + @Test + func transmitReturnsEmptyOn201Created() async { + let config = TelemetryDeck.Config( + appID: "test-app", + namespace: "test" + ) + let cache = InMemoryEventCache() + + MockURLProtocol.setResponseHandler { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 201, + httpVersion: nil, + headerFields: nil + ) + return (response, nil) + } + + let session = createMockURLSession() + let transmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: session + ) + + let events = [createTestEvent()] + let failed = await transmitter.transmit(events) + + #expect(failed.isEmpty) + + MockURLProtocol.reset() + } + + @Test + func transmitReturnsOriginalEventsOnFailure() async { + let config = TelemetryDeck.Config( + appID: "test-app", + namespace: "test" + ) + let cache = InMemoryEventCache() + + MockURLProtocol.setResponseHandler { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 500, + httpVersion: nil, + headerFields: nil + ) + return (response, nil) + } + + let session = createMockURLSession() + let transmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: session + ) + + let events = [ + createTestEvent(type: "event.one"), + createTestEvent(type: "event.two"), + ] + + let failed = await transmitter.transmit(events) + + #expect(failed.count == events.count) + #expect(failed[0].type == "event.one") + #expect(failed[1].type == "event.two") + + MockURLProtocol.reset() + } + + @Test + func transmitReturnsOriginalEventsOn400BadRequest() async { + let config = TelemetryDeck.Config( + appID: "test-app", + namespace: "test" + ) + let cache = InMemoryEventCache() + + MockURLProtocol.setResponseHandler { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 400, + httpVersion: nil, + headerFields: nil + ) + return (response, nil) + } + + let session = createMockURLSession() + let transmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: session + ) + + let events = [createTestEvent()] + let failed = await transmitter.transmit(events) + + #expect(failed.count == events.count) + + MockURLProtocol.reset() + } + + @Test + func transmitReturnsOriginalEventsOnNetworkError() async { + let config = TelemetryDeck.Config( + appID: "test-app", + namespace: "test" + ) + let cache = InMemoryEventCache() + + MockURLProtocol.setResponseHandler { request in + let error = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorNotConnectedToInternet, + userInfo: nil + ) + return (nil, error) + } + + let session = createMockURLSession() + let transmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: session + ) + + let events = [createTestEvent()] + let failed = await transmitter.transmit(events) + + #expect(failed.count == events.count) + + MockURLProtocol.reset() + } + + @Test + func transmitSetsCorrectHTTPHeaders() async { + let config = TelemetryDeck.Config( + appID: "test-app", + namespace: "test" + ) + let cache = InMemoryEventCache() + + let requestCapture = Locked(nil) + MockURLProtocol.setResponseHandler { request in + requestCapture.withLock { $0 = request } + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ) + return (response, nil) + } + + let session = createMockURLSession() + let transmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: session + ) + + let events = [createTestEvent()] + _ = await transmitter.transmit(events) + + let capturedRequest = requestCapture.withLock { $0 } + #expect(capturedRequest?.httpMethod == "POST") + #expect(capturedRequest?.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(capturedRequest?.httpBodyStream != nil) + + MockURLProtocol.reset() + } + + @Test + func flushCallsTransmitForCachedEvents() async { + let config = TelemetryDeck.Config( + appID: "test-app", + namespace: "test" + ) + let cache = InMemoryEventCache() + + let transmitCallCount = Locked(0) + MockURLProtocol.setResponseHandler { request in + transmitCallCount.withLock { $0 += 1 } + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ) + return (response, nil) + } + + let session = createMockURLSession() + let transmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: session + ) + + await cache.add(createTestEvent(type: "event.one")) + await cache.add(createTestEvent(type: "event.two")) + await cache.add(createTestEvent(type: "event.three")) + + let countBefore = await cache.count() + #expect(countBefore == 3) + + await transmitter.flush() + + let countAfter = await cache.count() + #expect(countAfter == 0) + + let callCount = transmitCallCount.withLock { $0 } + #expect(callCount == 1) + + MockURLProtocol.reset() + } + + @Test + func flushReAddsFailedEventsToCache() async { + let config = TelemetryDeck.Config( + appID: "test-app", + namespace: "test" + ) + let cache = InMemoryEventCache() + + MockURLProtocol.setResponseHandler { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 503, + httpVersion: nil, + headerFields: nil + ) + return (response, nil) + } + + let session = createMockURLSession() + let transmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: session + ) + + await cache.add(createTestEvent(type: "event.one")) + await cache.add(createTestEvent(type: "event.two")) + + let countBefore = await cache.count() + #expect(countBefore == 2) + + await transmitter.flush() + + let countAfter = await cache.count() + #expect(countAfter == 2) + + MockURLProtocol.reset() + } + + @Test + func flushWithEmptyCacheDoesNothing() async { + let config = TelemetryDeck.Config( + appID: "test-app", + namespace: "test" + ) + let cache = InMemoryEventCache() + + let transmitCallCount = Locked(0) + MockURLProtocol.setResponseHandler { request in + transmitCallCount.withLock { $0 += 1 } + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ) + return (response, nil) + } + + let session = createMockURLSession() + let transmitter = DefaultEventTransmitter( + configuration: config, + cache: cache, + logger: DefaultLogger(), + urlSession: session + ) + + await transmitter.flush() + + let callCount = transmitCallCount.withLock { $0 } + #expect(callCount == 0) + + MockURLProtocol.reset() + } +} + +private final class Locked: @unchecked Sendable { + private var value: T + private let lock = NSLock() + + init(_ value: T) { + self.value = value + } + + func withLock(_ body: (inout T) -> R) -> R { + lock.lock() + defer { lock.unlock() } + return body(&value) + } +} diff --git a/Tests/TelemetryDeckTests/DefaultParametersProcessorTests.swift b/Tests/TelemetryDeckTests/DefaultParametersProcessorTests.swift new file mode 100644 index 0000000..ac61907 --- /dev/null +++ b/Tests/TelemetryDeckTests/DefaultParametersProcessorTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct DefaultParametersProcessorTests { + private let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + + @Test + func defaultParametersAreMergedIntoEvent() async throws { + let processor = DefaultParametersProcessor(parameters: ["environment": "staging", "region": "eu"]) + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("App.launched") + let event = try await pipeline.process(input, context: EventContext()) + + #expect(event.payload["environment"] == "staging") + #expect(event.payload["region"] == "eu") + } + + @Test + func userParametersOverrideDefaults() async throws { + let processor = DefaultParametersProcessor(parameters: ["tier": "free"]) + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("App.launched", parameters: ["tier": "premium"]) + let event = try await pipeline.process(input, context: EventContext()) + + #expect(event.payload["tier"] == "premium") + } + + @Test + func defaultParametersGetPrefixed() async throws { + let pipeline = ProcessorPipeline( + processors: [ + DefaultParametersProcessor(parameters: ["tier": "free"]), + DefaultPrefixProcessor(parameterPrefix: "app."), + ], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("App.launched") + let event = try await pipeline.process(input, context: EventContext()) + + #expect(event.payload["app.tier"] == "free") + #expect(event.payload["tier"] == nil) + } + + @Test + func emptyDefaultParametersHasNoEffect() async throws { + let processor = DefaultParametersProcessor() + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("App.launched", parameters: ["key": "value"]) + let event = try await pipeline.process(input, context: EventContext()) + + #expect(event.payload["key"] == "value") + #expect(event.payload.count == 1) + } +} diff --git a/Tests/TelemetryDeckTests/DefaultPrefixProcessorTests.swift b/Tests/TelemetryDeckTests/DefaultPrefixProcessorTests.swift new file mode 100644 index 0000000..6bc34c2 --- /dev/null +++ b/Tests/TelemetryDeckTests/DefaultPrefixProcessorTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct DefaultPrefixProcessorTests { + private let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + + @Test + func signalPrefixIsApplied() async throws { + let processor = DefaultPrefixProcessor(eventPrefix: "MyApp.") + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("UserAction") + let signal = try await pipeline.process(input, context: EventContext()) + + #expect(signal.type == "MyApp.UserAction") + } + + @Test + func parameterPrefixIsApplied() async throws { + let processor = DefaultPrefixProcessor(parameterPrefix: "app.") + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput( + "Test.signal", + parameters: [ + "customKey": "value", + "anotherKey": 42, + ] + ) + let signal = try await pipeline.process(input, context: EventContext()) + + #expect(signal.payload["app.customKey"] == "value") + #expect(signal.payload["app.anotherKey"] == 42) + } + + @Test + func telemetryDeckParametersNotPrefixed() async throws { + let processor = DefaultPrefixProcessor(parameterPrefix: "app.") + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput( + "Test.signal", + parameters: [ + "TelemetryDeck.Device.modelName": "iPhone", + "customKey": "value", + ] + ) + let signal = try await pipeline.process(input, context: EventContext()) + + #expect(signal.payload["TelemetryDeck.Device.modelName"] == "iPhone") + #expect(signal.payload["app.customKey"] == "value") + } + + @Test + func telemetryDeckSignalsNotPrefixed() async throws { + let processor = DefaultPrefixProcessor(eventPrefix: "MyApp.") + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("TelemetryDeck.Session.started") + let signal = try await pipeline.process(input, context: EventContext()) + + #expect(signal.type == "TelemetryDeck.Session.started") + } + + @Test + func noPrefixWhenProcessorHasNil() async throws { + let processor = DefaultPrefixProcessor() + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("UserAction", parameters: ["key": "value"]) + let signal = try await pipeline.process(input, context: EventContext()) + + #expect(signal.type == "UserAction") + #expect(signal.payload["key"] == "value") + } + + @Test + func signalAlreadyPrefixedNotDoublePrefixed() async throws { + let processor = DefaultPrefixProcessor(eventPrefix: "MyApp.") + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("MyApp.UserAction") + let signal = try await pipeline.process(input, context: EventContext()) + + #expect(signal.type == "MyApp.UserAction") + } +} diff --git a/Tests/TelemetryDeckTests/DurationTrackerTests.swift b/Tests/TelemetryDeckTests/DurationTrackerTests.swift new file mode 100644 index 0000000..d50a4a8 --- /dev/null +++ b/Tests/TelemetryDeckTests/DurationTrackerTests.swift @@ -0,0 +1,186 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct DurationTrackerTests { + @Test + func startAndStopReturnsElapsedDuration() async throws { + let tracker = DurationTracker() + let storage = InMemoryProcessorStorage() + await tracker.start(storage: storage) + + await tracker.startDuration( + "test.duration", + parameters: EventParameters(), + includeBackgroundTime: true + ) + + try await Task.sleep(nanoseconds: 50_000_000) + + let result = await tracker.stopDuration("test.duration") + + #expect(result != nil) + #expect(result!.durationInSeconds > 0) + + await tracker.stop() + } + + @Test + func stopNonexistentDurationReturnsNil() async throws { + let tracker = DurationTracker() + let storage = InMemoryProcessorStorage() + await tracker.start(storage: storage) + + let result = await tracker.stopDuration("nonexistent.duration") + + #expect(result == nil) + + await tracker.stop() + } + + @Test + func cancelDurationPreventsStop() async throws { + let tracker = DurationTracker() + let storage = InMemoryProcessorStorage() + await tracker.start(storage: storage) + + await tracker.startDuration( + "test.duration", + parameters: EventParameters(), + includeBackgroundTime: true + ) + + await tracker.cancelDuration("test.duration") + + let result = await tracker.stopDuration("test.duration") + + #expect(result == nil) + + await tracker.stop() + } + + @Test + func startParametersAreReturnedOnStop() async throws { + let tracker = DurationTracker() + let storage = InMemoryProcessorStorage() + await tracker.start(storage: storage) + + var params = EventParameters() + params["key1"] = "value1" + params["key2"] = "value2" + + await tracker.startDuration( + "test.duration", + parameters: params, + includeBackgroundTime: true + ) + + try await Task.sleep(nanoseconds: 50_000_000) + + let result = await tracker.stopDuration("test.duration") + + #expect(result != nil) + #expect(result!.startParameters.payloadDictionary["key1"] == .string("value1")) + #expect(result!.startParameters.payloadDictionary["key2"] == .string("value2")) + + await tracker.stop() + } + + @Test + func backgroundTimeExcludedWhenFlagIsFalse() async throws { + let tracker = DurationTracker() + let storage = InMemoryProcessorStorage() + await tracker.start(storage: storage) + + let startTime = Date() + + await tracker.startDuration( + "test.duration", + parameters: EventParameters(), + includeBackgroundTime: false + ) + + try await Task.sleep(nanoseconds: 50_000_000) + + await tracker.handleBackground() + + try await Task.sleep(nanoseconds: 100_000_000) + + await tracker.handleForeground() + + try await Task.sleep(nanoseconds: 50_000_000) + + let result = await tracker.stopDuration("test.duration") + let wallClockElapsed = Date().timeIntervalSince(startTime) + + #expect(result != nil) + #expect(result!.durationInSeconds < wallClockElapsed) + #expect(result!.durationInSeconds < 0.25) + + await tracker.stop() + } + + @Test + func backgroundTimeIncludedWhenFlagIsTrue() async throws { + let tracker = DurationTracker() + let storage = InMemoryProcessorStorage() + await tracker.start(storage: storage) + + await tracker.startDuration( + "test.duration", + parameters: EventParameters(), + includeBackgroundTime: true + ) + + try await Task.sleep(nanoseconds: 50_000_000) + + await tracker.handleBackground() + + try await Task.sleep(nanoseconds: 100_000_000) + + await tracker.handleForeground() + + try await Task.sleep(nanoseconds: 50_000_000) + + let result = await tracker.stopDuration("test.duration") + + #expect(result != nil) + + await tracker.stop() + } + + @Test + func persistAndRestoreRoundtrip() async throws { + let storage = InMemoryProcessorStorage() + + let tracker1 = DurationTracker() + await tracker1.start(storage: storage) + + var params = EventParameters() + params["key"] = "value" + + await tracker1.startDuration( + "test.duration", + parameters: params, + includeBackgroundTime: false + ) + + try await Task.sleep(nanoseconds: 50_000_000) + + await tracker1.stop() + + let tracker2 = DurationTracker() + await tracker2.start(storage: storage) + + try await Task.sleep(nanoseconds: 50_000_000) + + let result = await tracker2.stopDuration("test.duration") + + #expect(result != nil) + #expect(result!.durationInSeconds > 0.05) + #expect(result!.startParameters.payloadDictionary["key"] == .string("value")) + + await tracker2.stop() + } +} diff --git a/Tests/TelemetryDeckTests/ErrorPresetsTests.swift b/Tests/TelemetryDeckTests/ErrorPresetsTests.swift new file mode 100644 index 0000000..cc32ad0 --- /dev/null +++ b/Tests/TelemetryDeckTests/ErrorPresetsTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct ErrorPresetsTests { + @Test + func errorCategoryRawValues() { + #expect(ErrorCategory.thrownException.rawValue == "thrown-exception") + #expect(ErrorCategory.userInput.rawValue == "user-input") + #expect(ErrorCategory.appState.rawValue == "app-state") + } + + @Test + func anyIdentifiableErrorWrapsError() { + let originalError = NSError(domain: "test", code: 42, userInfo: [NSLocalizedDescriptionKey: "Test error"]) + let wrappedError = AnyIdentifiableError(id: "test-id", error: originalError) + + #expect(wrappedError.id == "test-id") + #expect(wrappedError.errorDescription == "Test error") + } + + @Test + func errorWithIdCreatesWrapper() { + let originalError = NSError(domain: "test", code: 123) + let identifiableError = originalError.with(id: "error-123") + + #expect(identifiableError.id == "error-123") + #expect((identifiableError.error as NSError).code == originalError.code) + } + + @Test + func identifiableErrorConformsToLocalizedError() { + let originalError = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Localized"]) + let wrappedError = AnyIdentifiableError(id: "test", error: originalError) + + #expect(wrappedError.errorDescription == "Localized") + } + + @Test + func identifiableErrorWithNonLocalizedError() { + struct SimpleError: Error {} + let error = SimpleError() + let wrapped = AnyIdentifiableError(id: "simple", error: error) + + #expect(wrapped.id == "simple") + } +} diff --git a/Tests/TelemetryDeckTests/EventCacheTests.swift b/Tests/TelemetryDeckTests/EventCacheTests.swift new file mode 100644 index 0000000..f32e14b --- /dev/null +++ b/Tests/TelemetryDeckTests/EventCacheTests.swift @@ -0,0 +1,210 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +@Suite(.serialized) +struct EventCacheTests { + @Test + func defaultCacheAddsEventsAndIncreasesCount() async { + let cache = DefaultEventCache() + let event = createTestEvent() + + await cache.add(event) + let count = await cache.count() + #expect(count == 1) + + await cache.add(event) + let newCount = await cache.count() + #expect(newCount == 2) + } + + @Test + func defaultCachePopReturnsBatchOf100Max() async { + let cache = DefaultEventCache() + for _ in 0..<150 { + await cache.add(createTestEvent()) + } + + let batch1 = await cache.pop() + #expect(batch1.count == 100) + + let remainingCount = await cache.count() + #expect(remainingCount == 50) + + let batch2 = await cache.pop() + #expect(batch2.count == 50) + + let finalCount = await cache.count() + #expect(finalCount == 0) + } + + @Test + func defaultCachePopClearsPoppedEvents() async { + let cache = DefaultEventCache() + await cache.add(createTestEvent()) + await cache.add(createTestEvent()) + + let initialCount = await cache.count() + #expect(initialCount == 2) + + let popped = await cache.pop() + #expect(popped.count == 2) + + let afterPopCount = await cache.count() + #expect(afterPopCount == 0) + } + + @Test + func inMemoryCacheReturnsAllEventsInOnePop() async { + let cache = InMemoryEventCache() + for _ in 0..<150 { + await cache.add(createTestEvent()) + } + + let all = await cache.pop() + #expect(all.count == 150) + + let afterCount = await cache.count() + #expect(afterCount == 0) + } + + @Test + func defaultCachePersistAndRestoreRoundtrip() async { + let tempDir = FileManager.default.temporaryDirectory + let testFileURL = tempDir.appendingPathComponent("test-cache-\(UUID().uuidString).json") + + defer { + try? FileManager.default.removeItem(at: testFileURL) + } + + let event1 = createTestEvent(type: "Signal.one") + let event2 = createTestEvent(type: "Signal.two") + + let events = [event1, event2] + guard let data = try? JSONEncoder.telemetryEncoder.encode(events) else { + Issue.record("Failed to encode events") + return + } + + do { + try data.write(to: testFileURL) + } catch { + Issue.record("Failed to write file: \(error)") + return + } + + #expect(FileManager.default.fileExists(atPath: testFileURL.path)) + + guard let readData = try? Data(contentsOf: testFileURL), + let decoded = try? JSONDecoder.telemetryDecoder.decode([Event].self, from: readData) + else { + Issue.record("Failed to read and decode file") + return + } + + #expect(decoded.count == 2) + #expect(decoded[0].type == "Signal.one") + #expect(decoded[1].type == "Signal.two") + } + + @Test + func manualPersistWorks() async { + let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + let fileURL = cachesURL.appendingPathComponent("test-manual-persist.json") + + defer { + try? FileManager.default.removeItem(at: fileURL) + } + + let event = createTestEvent() + let data = try? JSONEncoder.telemetryEncoder.encode([event]) + #expect(data != nil) + + do { + try data?.write(to: fileURL) + } catch { + Issue.record("Failed to write: \(error)") + } + + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test + func restorePrependsPersistedEventsBeforeInMemory() async { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("test-restore-merge-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let writerCache = DefaultEventCache(fileURL: fileURL) + await writerCache.add(createTestEvent(type: "Signal.B")) + await writerCache.add(createTestEvent(type: "Signal.C")) + await writerCache.persist() + + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + let cache = DefaultEventCache(fileURL: fileURL) + await cache.add(createTestEvent(type: "Signal.A")) + #expect(await cache.count() == 1) + + await cache.restore() + + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + #expect(await cache.count() == 3) + + let popped = await cache.pop() + #expect(popped.count == 3) + #expect(popped[0].type == "Signal.B") + #expect(popped[1].type == "Signal.C") + #expect(popped[2].type == "Signal.A") + } + + @Test + func persistCreatesFileOnDisk() async { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("test-persist-creates-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let cache = DefaultEventCache(fileURL: fileURL) + await cache.add(createTestEvent()) + await cache.persist() + + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test + func restoreWithNoFilePreservesInMemoryEvents() async { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("test-no-file-\(UUID().uuidString).json") + + let cache = DefaultEventCache(fileURL: fileURL) + + let eventA = createTestEvent(type: "Signal.A") + let eventB = createTestEvent(type: "Signal.B") + await cache.add(eventA) + await cache.add(eventB) + + let initialCount = await cache.count() + #expect(initialCount == 2) + + await cache.restore() + + let countAfterRestore = await cache.count() + #expect(countAfterRestore == 2) + + let popped = await cache.pop() + #expect(popped.count == 2) + #expect(popped[0].type == "Signal.A") + #expect(popped[1].type == "Signal.B") + } + + private func createTestEvent(type: String = "Test.signal") -> Event { + Event( + appID: "test-app", + type: type, + clientUser: "test-user-hash", + sessionID: UUID().uuidString, + receivedAt: Date(), + payload: ["test": "data"], + floatValue: nil, + isTestMode: true + ) + } +} diff --git a/Tests/TelemetryDeckTests/EventCodableTests.swift b/Tests/TelemetryDeckTests/EventCodableTests.swift new file mode 100644 index 0000000..eea83f2 --- /dev/null +++ b/Tests/TelemetryDeckTests/EventCodableTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct EventCodableTests { + @Test + func signalEncodesAndDecodesWithTelemetryEncoder() throws { + let originalEvent = Event( + appID: "test-app-id", + type: "TestEvent", + clientUser: "user123", + sessionID: "session-abc", + receivedAt: Date(timeIntervalSince1970: 1_609_459_200), + payload: ["key1": "value1", "key2": "value2"], + floatValue: 42.5, + isTestMode: true + ) + + let encoded = try JSONEncoder.telemetryEncoder.encode(originalEvent) + let decoded = try JSONDecoder.telemetryDecoder.decode(Event.self, from: encoded) + + #expect(decoded.appID == originalEvent.appID) + #expect(decoded.type == originalEvent.type) + #expect(decoded.clientUser == originalEvent.clientUser) + #expect(decoded.sessionID == originalEvent.sessionID) + #expect(decoded.receivedAt.timeIntervalSince1970 == originalEvent.receivedAt.timeIntervalSince1970) + #expect(decoded.payload == originalEvent.payload) + #expect(decoded.floatValue == originalEvent.floatValue) + #expect(decoded.isTestMode == originalEvent.isTestMode) + } + + @Test + func dateFormattedCorrectly() throws { + let knownDate = Date(timeIntervalSince1970: 1_609_459_200) + let signal = Event( + appID: "test-app-id", + type: "TestEvent", + clientUser: "user123", + sessionID: "session-abc", + receivedAt: knownDate, + payload: [:], + floatValue: nil, + isTestMode: false + ) + + let encoded = try JSONEncoder.telemetryEncoder.encode(signal) + let jsonString = String(data: encoded, encoding: .utf8) + + #expect(jsonString != nil) + #expect(jsonString?.contains("2021-01-01T00:00:00+0000") == true) + } + + @Test + func isTestModeTrueEncodesAsStringTrue() throws { + let signal = Event( + appID: "test-app-id", + type: "TestEvent", + clientUser: "user123", + sessionID: "session-abc", + receivedAt: Date(), + payload: [:], + floatValue: nil, + isTestMode: true + ) + + let encoded = try JSONEncoder.telemetryEncoder.encode(signal) + let decoded = try JSONDecoder.telemetryDecoder.decode(Event.self, from: encoded) + + #expect(decoded.isTestMode == "true") + } + + @Test + func floatValueNilRoundtrips() throws { + let signal = Event( + appID: "test-app-id", + type: "TestEvent", + clientUser: "user123", + sessionID: "session-abc", + receivedAt: Date(), + payload: ["key": "value"], + floatValue: nil, + isTestMode: false + ) + + let encoded = try JSONEncoder.telemetryEncoder.encode(signal) + let decoded = try JSONDecoder.telemetryDecoder.decode(Event.self, from: encoded) + + #expect(decoded.floatValue == nil) + } + + @Test + func emptyPayloadRoundtrips() throws { + let signal = Event( + appID: "test-app-id", + type: "TestEvent", + clientUser: "user123", + sessionID: "session-abc", + receivedAt: Date(), + payload: [:], + floatValue: nil, + isTestMode: false + ) + + let encoded = try JSONEncoder.telemetryEncoder.encode(signal) + let decoded = try JSONDecoder.telemetryDecoder.decode(Event.self, from: encoded) + + #expect(decoded.payload.isEmpty) + #expect(decoded.payload == [:]) + } +} diff --git a/Tests/TelemetryDeckTests/EventContextTests.swift b/Tests/TelemetryDeckTests/EventContextTests.swift new file mode 100644 index 0000000..2c2a109 --- /dev/null +++ b/Tests/TelemetryDeckTests/EventContextTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct EventContextTests { + @Test + func addParameterStoresValue() { + var context = EventContext() + + context.addParameter("stringKey", value: "stringValue") + context.addParameter("intKey", value: 42) + context.addParameter("boolKey", value: true) + + #expect(context.metadata["stringKey"] as? String == "stringValue") + #expect(context.metadata["intKey"] as? Int == 42) + #expect(context.metadata["boolKey"] as? Bool == true) + } + + @Test + func removeParameterDeletesKey() { + var context = EventContext() + + context.addParameter("key1", value: "value1") + #expect(context.metadata["key1"] as? String == "value1") + + context.removeParameter("key1") + #expect(context.metadata["key1"] == nil) + } + + @Test + func addParametersDictionaryMergesAll() { + var context = EventContext() + + let dictionary = [ + "key1": "value1", + "key2": "value2", + "key3": "value3", + ] + + context.addParameters(dictionary) + + #expect(context.metadata["key1"] as? String == "value1") + #expect(context.metadata["key2"] as? String == "value2") + #expect(context.metadata["key3"] as? String == "value3") + } + + @Test + func addParametersEventParametersMergesAll() { + var context = EventContext() + + let params: EventParameters = [ + "stringParam": "stringValue", + "intParam": 42, + "boolParam": true, + ] + + context.addParameters(params) + + #expect(context.metadata["stringParam"] as? String == "stringValue") + #expect(context.metadata["intParam"] as? Int == 42) + #expect(context.metadata["boolParam"] as? Bool == true) + } +} diff --git a/Tests/TelemetryDeckTests/EventFinalizerTests.swift b/Tests/TelemetryDeckTests/EventFinalizerTests.swift new file mode 100644 index 0000000..25cf413 --- /dev/null +++ b/Tests/TelemetryDeckTests/EventFinalizerTests.swift @@ -0,0 +1,82 @@ +import Testing + +@testable import TelemetryDeck + +struct EventFinalizerTests { + @Test + func saltIsUsedInHashing() { + let configWithSalt1 = TelemetryDeck.Config(appID: "test-app", namespace: "test-ns", salt: "salt1") + let configWithSalt2 = TelemetryDeck.Config(appID: "test-app", namespace: "test-ns", salt: "salt2") + + let finalizer1 = EventFinalizer(configuration: configWithSalt1) + let finalizer2 = EventFinalizer(configuration: configWithSalt2) + + var context = EventContext() + context.userIdentifier = "user@example.com" + + let input = EventInput("Test.signal") + + let signal1 = finalizer1.finalize(input, context: context) + let signal2 = finalizer2.finalize(input, context: context) + + #expect(signal1.clientUser != signal2.clientUser) + } + + @Test + func nilUserIdentifierFallsBackToDefault() { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test-ns") + let finalizer = EventFinalizer(configuration: config) + + var context = EventContext() + context.userIdentifier = nil + + let input = EventInput("Test.signal") + let signal = finalizer.finalize(input, context: context) + + let expectedHash = CryptoHashing.sha256(string: "unknown user", salt: "") + #expect(signal.clientUser == expectedHash) + #expect(!signal.clientUser.isEmpty) + } + + @Test + func nilIsTestModeDefaultsToFalse() { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test-ns") + let finalizer = EventFinalizer(configuration: config) + + var context = EventContext() + context.isTestMode = nil + + let input = EventInput("Test.signal") + let signal = finalizer.finalize(input, context: context) + + #expect(signal.isTestMode == "false") + } + + @Test + func sessionIDIsNilWhenContextHasNoSession() { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test-ns") + let finalizer = EventFinalizer(configuration: config) + + var context = EventContext() + context.sessionID = nil + + let input = EventInput("Test.signal") + let signal = finalizer.finalize(input, context: context) + + #expect(signal.sessionID == nil) + } + + @Test + func inputParametersOverrideContextParameters() { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test-ns") + let finalizer = EventFinalizer(configuration: config) + + var context = EventContext() + context.addParameter("key", value: "A") + + let input = EventInput("Test.signal", parameters: ["key": "B"]) + let signal = finalizer.finalize(input, context: context) + + #expect(signal.payload["key"] == "B") + } +} diff --git a/Tests/TelemetryDeckTests/EventParametersTests.swift b/Tests/TelemetryDeckTests/EventParametersTests.swift new file mode 100644 index 0000000..744728f --- /dev/null +++ b/Tests/TelemetryDeckTests/EventParametersTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct EventParametersTests { + @Test + func dictionaryLiteralInit() { + let params: EventParameters = [ + "key1": "value1", + "key2": 42, + "key3": true, + ] + + #expect(params["key1"] as? String == "value1") + #expect(params["key2"] as? Int == 42) + #expect(params["key3"] as? Bool == true) + } + + @Test + func mergeBehavior() { + var params1: EventParameters = ["key1": "original", "key2": "stays"] + let params2: EventParameters = ["key1": "override", "key3": "new"] + + params1.merge(params2) + + #expect(params1["key1"] as? String == "override") + #expect(params1["key2"] as? String == "stays") + #expect(params1["key3"] as? String == "new") + } + + @Test + func mergeStringDictionary() { + var params: EventParameters = ["key1": "value1"] + let stringDict = ["key2": "value2", "key1": "override"] + + params.merge(stringDict) + + #expect(params["key1"] as? String == "override") + #expect(params["key2"] as? String == "value2") + } + + @Test + func payloadDictionaryConvertsTypes() { + let params: EventParameters = [ + "stringValue": "hello", + "intValue": 42, + "doubleValue": 3.14, + "boolValue": true, + "uuidValue": UUID(uuidString: "12345678-1234-1234-1234-123456789012")!, + "dateValue": Date(timeIntervalSince1970: 0), + ] + + let dict = params.payloadDictionary + + #expect(dict["stringValue"] == .string("hello")) + #expect(dict["intValue"] == .int(42)) + #expect(dict["doubleValue"] == .double(3.14)) + #expect(dict["boolValue"] == .bool(true)) + #expect(dict["uuidValue"] == .string("12345678-1234-1234-1234-123456789012")) + #expect(dict["dateValue"]?.description.contains("1970-01-01") == true) + } + + @Test + func subscriptGetAndSet() { + var params = EventParameters() + + params["key1"] = "value1" + #expect(params["key1"] as? String == "value1") + + params["key1"] = "updated" + #expect(params["key1"] as? String == "updated") + + params["key1"] = nil + #expect(params["key1"] == nil) + } + + @Test + func countAndIsEmpty() { + var params = EventParameters() + #expect(params.isEmpty) + #expect(params.count == 0) + + params["key1"] = "value1" + #expect(!params.isEmpty) + #expect(params.count == 1) + + params["key2"] = "value2" + #expect(params.count == 2) + } + + @Test + func iterationOverParameters() { + let params: EventParameters = [ + "key1": "value1", + "key2": 42, + ] + + var foundKeys = Set() + for (key, _) in params { + foundKeys.insert(key) + } + + #expect(foundKeys.contains("key1")) + #expect(foundKeys.contains("key2")) + #expect(foundKeys.count == 2) + } + + @Test + func keysProperty() { + let params: EventParameters = [ + "key1": "value1", + "key2": "value2", + ] + + let keys = Set(params.keys) + #expect(keys.contains("key1")) + #expect(keys.contains("key2")) + #expect(keys.count == 2) + } +} diff --git a/Tests/TelemetryDeckTests/IntegrationTests.swift b/Tests/TelemetryDeckTests/IntegrationTests.swift new file mode 100644 index 0000000..7150c86 --- /dev/null +++ b/Tests/TelemetryDeckTests/IntegrationTests.swift @@ -0,0 +1,242 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct IntegrationTests { + @Test + func fullEventFlowToCache() async throws { + let cache = InMemoryEventCache() + let spy = SpyEventTransmitter() + let config = TelemetryDeck.Config(appID: "integration-test", namespace: "test") + + let client = await TelemetryEngine.create( + configuration: config, + processors: [TestModeProcessor()], + cache: cache, + transmitter: spy + ) + + let input = EventInput("Integration.test", parameters: ["key": "value"]) + await client.send(input) + + let count = await cache.count() + #expect(count == 1) + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == "Integration.test") + #expect(events[0].payload["key"] == "value") + + await client.shutdown() + } + + @Test + func analyticsDisabledPreventsEvents() async throws { + let cache = InMemoryEventCache() + let spy = SpyEventTransmitter() + let config = TelemetryDeck.Config(appID: "test", namespace: "test") + + let client = await TelemetryEngine.create( + configuration: config, + processors: [], + cache: cache, + transmitter: spy + ) + + await client.setAnalyticsDisabled(true) + await client.send(EventInput("Should.not.send")) + + let count = await cache.count() + #expect(count == 0) + + await client.shutdown() + } + + @Test + func previewFilterBlocksEvents() async throws { + setenv("XCODE_RUNNING_FOR_PREVIEWS", "1", 1) + + let cache = InMemoryEventCache() + let spy = SpyEventTransmitter() + let config = TelemetryDeck.Config(appID: "test", namespace: "test") + + let client = await TelemetryEngine.create( + configuration: config, + processors: [PreviewFilterProcessor()], + cache: cache, + transmitter: spy + ) + + await client.send(EventInput("Should.be.filtered")) + + let count = await cache.count() + #expect(count == 0) + + setenv("XCODE_RUNNING_FOR_PREVIEWS", "0", 1) + await client.shutdown() + } + + @Test + func clientShutdownPersistsCache() async throws { + let cache = InMemoryEventCache() + let spy = SpyEventTransmitter() + let config = TelemetryDeck.Config(appID: "test", namespace: "test") + + let client = await TelemetryEngine.create( + configuration: config, + processors: [], + cache: cache, + transmitter: spy + ) + + await client.send(EventInput("Before.shutdown")) + + let countBeforeShutdown = await cache.count() + #expect(countBeforeShutdown == 1) + + await client.shutdown() + + let countAfterShutdown = await cache.count() + #expect(countAfterShutdown == 1) + } + + @Test + func floatValueIsPreserved() async throws { + let cache = InMemoryEventCache() + let spy = SpyEventTransmitter() + let config = TelemetryDeck.Config(appID: "test", namespace: "test") + + let client = await TelemetryEngine.create( + configuration: config, + processors: [], + cache: cache, + transmitter: spy + ) + + let input = EventInput("Test.floatValue", floatValue: 42.5) + await client.send(input) + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].floatValue == 42.5) + + await client.shutdown() + } + + @Test + func customUserIDIsUsedForHashing() async throws { + let cache = InMemoryEventCache() + let spy = SpyEventTransmitter() + let config = TelemetryDeck.Config(appID: "test", namespace: "test") + + let client = await TelemetryEngine.create( + configuration: config, + processors: [UserIdentifierProcessor()], + cache: cache, + transmitter: spy + ) + + let input = EventInput("Test.customUser", customUserID: "custom@user.com") + await client.send(input) + + let events = await cache.pop() + #expect(events.count == 1) + + let expectedHash = CryptoHashing.sha256(string: "custom@user.com", salt: "") + #expect(events[0].clientUser == expectedHash) + + await client.shutdown() + } + + @Test + func newInstallDetectedIncludesFirstSessionDate() async throws { + let cache = InMemoryEventCache() + let spy = SpyEventTransmitter() + let config = TelemetryDeck.Config(appID: "test", namespace: "test") + + let client = await TelemetryEngine.create( + configuration: config, + processors: [], + cache: cache, + transmitter: spy + ) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate] + let firstSessionDate = formatter.string(from: Date()) + + let input = EventInput( + DefaultEvents.Acquisition.newInstallDetected.rawValue, + parameters: [DefaultParams.Acquisition.firstSessionDate.rawValue: firstSessionDate] + ) + await client.send(input) + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == DefaultEvents.Acquisition.newInstallDetected.rawValue) + + let dateParam = events[0].payload[DefaultParams.Acquisition.firstSessionDate.rawValue] + #expect(dateParam != nil) + + guard case .string(let dateStr) = dateParam else { + Issue.record("firstSessionDate is not a string PayloadValue") + return + } + let parsedDate = formatter.date(from: dateStr) + #expect(parsedDate != nil) + + await client.shutdown() + } + + @Test + func syncSignalFiresEvent() async throws { + let cache = InMemoryEventCache() + let spy = SpyEventTransmitter() + let config = TelemetryDeck.Config(appID: "test-sync", namespace: "test") + + let client = await TelemetryEngine.create( + configuration: config, + processors: [], + cache: cache, + transmitter: spy + ) + + let input = EventInput("Sync.test", parameters: ["key": "value"]) + Task { await client.send(input) } + + try await Task.sleep(nanoseconds: 200_000_000) + + let count = await cache.count() + #expect(count == 1) + + await client.shutdown() + } + + @Test + func testModeIsSetInDebugBuild() async throws { + let cache = InMemoryEventCache() + let spy = SpyEventTransmitter() + let config = TelemetryDeck.Config(appID: "test", namespace: "test") + + let client = await TelemetryEngine.create( + configuration: config, + processors: [TestModeProcessor()], + cache: cache, + transmitter: spy + ) + + await client.send(EventInput("Test.testMode")) + + let events = await cache.pop() + #expect(events.count == 1) + + #if DEBUG + #expect(events[0].isTestMode == "true") + #else + #expect(events[0].isTestMode == "false") + #endif + + await client.shutdown() + } +} diff --git a/Tests/TelemetryDeckTests/LogHandlerTests.swift b/Tests/TelemetryDeckTests/LogHandlerTests.swift deleted file mode 100644 index 06ca078..0000000 --- a/Tests/TelemetryDeckTests/LogHandlerTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Testing - -@testable import TelemetryDeck - -actor LogHandlerTests { - var counter: Int = 0 - var lastLevel: LogHandler.LogLevel? - - @Test - func logHandler_stdoutLogLevelDefined() { - #expect(LogHandler.standard(.error).logLevel == .error) - } - - @Test - func logHandler_logLevelRespected() async throws { - let handler = LogHandler(logLevel: .info) { _, _ in - Task { - await self.increment() - } - } - - #expect(counter == 0) - - handler.log(.debug, message: "") - try await Task.sleep(nanoseconds: 10_000_000) // 10 milliseconds - #expect(counter == 0) - - handler.log(.info, message: "") - try await Task.sleep(nanoseconds: 10_000_000) // 10 milliseconds - #expect(counter == 1) - - handler.log(.error, message: "") - try await Task.sleep(nanoseconds: 10_000_000) // 10 milliseconds - #expect(counter == 2) - } - - @Test - func logHandler_defaultLogLevel() async throws { - let handler = LogHandler(logLevel: .debug) { level, _ in - Task { - await self.setLastLevel(level) - } - } - - handler.log(message: "") - try await Task.sleep(nanoseconds: 10_000_000) // 10 milliseconds - #expect(lastLevel == .info) - } - - private func increment() { - self.counter += 1 - } - - private func setLastLevel(_ lastLevel: LogHandler.LogLevel?) { - self.lastLevel = lastLevel - } -} diff --git a/Tests/TelemetryDeckTests/NewInstallProcessorTests.swift b/Tests/TelemetryDeckTests/NewInstallProcessorTests.swift new file mode 100644 index 0000000..930f731 --- /dev/null +++ b/Tests/TelemetryDeckTests/NewInstallProcessorTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct NewInstallProcessorTests { + private let config = TelemetryDeck.Config(appID: "test-app", namespace: "test-ns") + + @Test + func firstStartEmitsNewInstallEvent() async throws { + let processor = SessionTrackingProcessor(sendSessionStartedEvent: false) + let storage = InMemoryProcessorStorage() + let logger = NoOpLogger() + let emitter = CapturingEventSender() + + await processor.start(storage: storage, logger: logger, emitter: emitter) + + let sentEvents = await emitter.sentEvents + let eventNames = sentEvents.map(\.name) + #expect(eventNames.contains(DefaultEvents.Acquisition.newInstallDetected.rawValue)) + + await processor.stop() + } + + @Test + func firstSignalIncludesNewInstallParameter() async throws { + let processor = SessionTrackingProcessor(sendSessionStartedEvent: false) + let storage = InMemoryProcessorStorage() + let logger = NoOpLogger() + + await processor.start(storage: storage, logger: logger, emitter: MockEventSender()) + + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("Test.signal") + let signal = try await pipeline.process(input, context: EventContext()) + + #expect(signal.payload["TelemetryDeck.Acquisition.isNewInstall"] == true) + + await processor.stop() + } + + @Test + func secondSignalOmitsNewInstallParameter() async throws { + let processor = SessionTrackingProcessor(sendSessionStartedEvent: false) + let storage = InMemoryProcessorStorage() + let logger = NoOpLogger() + + await processor.start(storage: storage, logger: logger, emitter: MockEventSender()) + + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let signal1 = try await pipeline.process(EventInput("First.signal"), context: EventContext()) + #expect(signal1.payload["TelemetryDeck.Acquisition.isNewInstall"] == true) + + let signal2 = try await pipeline.process(EventInput("Second.signal"), context: EventContext()) + #expect(signal2.payload["TelemetryDeck.Acquisition.isNewInstall"] == nil) + + await processor.stop() + } + + @Test + func subsequentStartDoesNotEmitNewInstallEvent() async throws { + let storage = InMemoryProcessorStorage() + let logger = NoOpLogger() + + await storage.set("existing-install-id", forKey: "installID") + + let processor = SessionTrackingProcessor(sendSessionStartedEvent: false) + let emitter = CapturingEventSender() + + await processor.start(storage: storage, logger: logger, emitter: emitter) + + let sentEvents = await emitter.sentEvents + let eventNames = sentEvents.map(\.name) + #expect(!eventNames.contains(DefaultEvents.Acquisition.newInstallDetected.rawValue)) + + await processor.stop() + } +} diff --git a/Tests/TelemetryDeckTests/PayloadValueTests.swift b/Tests/TelemetryDeckTests/PayloadValueTests.swift new file mode 100644 index 0000000..6a97185 --- /dev/null +++ b/Tests/TelemetryDeckTests/PayloadValueTests.swift @@ -0,0 +1,183 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct PayloadValueTests { + + // MARK: - Encoding + + @Test + func stringEncodesToJSONString() throws { + let encoded = try JSONEncoder().encode(PayloadValue.string("hello")) + let json = String(data: encoded, encoding: .utf8) + #expect(json == "\"hello\"") + } + + @Test + func intEncodesToJSONNumber() throws { + let encoded = try JSONEncoder().encode(PayloadValue.int(42)) + let json = String(data: encoded, encoding: .utf8) + #expect(json == "42") + } + + @Test + func doubleEncodesToJSONNumber() throws { + let encoded = try JSONEncoder().encode(PayloadValue.double(3.14)) + let decoded = try JSONDecoder().decode(Double.self, from: encoded) + #expect(abs(decoded - 3.14) < 1e-10) + } + + @Test + func boolTrueEncodesToJSONTrue() throws { + let encoded = try JSONEncoder().encode(PayloadValue.bool(true)) + let json = String(data: encoded, encoding: .utf8) + #expect(json == "true") + } + + @Test + func boolFalseEncodesToJSONFalse() throws { + let encoded = try JSONEncoder().encode(PayloadValue.bool(false)) + let json = String(data: encoded, encoding: .utf8) + #expect(json == "false") + } + + // MARK: - Decoding + + @Test + func jsonStringDecodesAsString() throws { + let data = Data("\"hello\"".utf8) + let value = try JSONDecoder().decode(PayloadValue.self, from: data) + #expect(value == .string("hello")) + } + + @Test + func jsonIntegerDecodesAsInt() throws { + let data = Data("42".utf8) + let value = try JSONDecoder().decode(PayloadValue.self, from: data) + #expect(value == .int(42)) + } + + @Test + func jsonDecimalDecodesAsDouble() throws { + let data = Data("3.14".utf8) + let value = try JSONDecoder().decode(PayloadValue.self, from: data) + #expect(value == .double(3.14)) + } + + @Test + func jsonTrueDecodesAsBool() throws { + let data = Data("true".utf8) + let value = try JSONDecoder().decode(PayloadValue.self, from: data) + #expect(value == .bool(true)) + } + + @Test + func jsonFalseDecodesAsBool() throws { + let data = Data("false".utf8) + let value = try JSONDecoder().decode(PayloadValue.self, from: data) + #expect(value == .bool(false)) + } + + // MARK: - Round-trip precision + + @Test + func doubleRoundTripsExactly() throws { + let original = PayloadValue.double(3.14) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PayloadValue.self, from: encoded) + #expect(decoded == original) + } + + // MARK: - Bool/number discrimination + + @Test + func jsonTrueIsNotDecodedAsDouble() throws { + let data = Data("true".utf8) + let value = try JSONDecoder().decode(PayloadValue.self, from: data) + #expect(value != .double(1.0)) + } + + @Test + func jsonFalseIsNotDecodedAsDouble() throws { + let data = Data("false".utf8) + let value = try JSONDecoder().decode(PayloadValue.self, from: data) + #expect(value != .double(0.0)) + } + + // MARK: - Literal conformances + + @Test + func stringLiteralConformance() { + let v: PayloadValue = "hello" + #expect(v == .string("hello")) + } + + @Test + func integerLiteralConformance() { + let v: PayloadValue = 42 + #expect(v == .int(42)) + } + + @Test + func floatLiteralConformance() { + let v: PayloadValue = 3.14 + #expect(v == .double(3.14)) + } + + @Test + func booleanLiteralConformance() { + let v: PayloadValue = true + #expect(v == .bool(true)) + } + + // MARK: - Mixed-type Event encoding + + @Test + func mixedPayloadEncodesToNativeJSONTypes() throws { + let event = Event( + appID: "test-app", + type: "Test.mixed", + clientUser: "user-hash", + sessionID: nil, + receivedAt: Date(timeIntervalSince1970: 0), + payload: [ + "label": .string("hello"), + "count": .int(7), + "ratio": .double(0.5), + "active": .bool(true), + ], + floatValue: nil, + isTestMode: false + ) + + let data = try JSONEncoder.telemetryEncoder.encode(event) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let payload = json["payload"] as! [String: Any] + + #expect(payload["label"] as? String == "hello") + #expect(payload["count"] as? Int == 7) + #expect((payload["ratio"] as? Double).map { abs($0 - 0.5) < 0.0001 } == true) + #expect(payload["active"] as? Bool == true) + } + + // MARK: - Backward compatibility: old string-only payload + + @Test + func oldStringOnlyPayloadDecodesIntoEvent() throws { + let json = """ + { + "appID": "test-app", + "type": "Legacy.event", + "clientUser": "hash", + "receivedAt": "2021-01-01T00:00:00+0000", + "payload": {"key": "value", "count": "42"}, + "isTestMode": "false" + } + """.data(using: .utf8)! + + let event = try JSONDecoder.telemetryDecoder.decode(Event.self, from: json) + #expect(event.payload["key"] == .string("value")) + #expect(event.payload["count"] == .string("42")) + } +} diff --git a/Tests/TelemetryDeckTests/PipelineTests.swift b/Tests/TelemetryDeckTests/PipelineTests.swift new file mode 100644 index 0000000..2ebf0b3 --- /dev/null +++ b/Tests/TelemetryDeckTests/PipelineTests.swift @@ -0,0 +1,134 @@ +import Testing + +@testable import TelemetryDeck + +struct PipelineTests { + let testConfig = TelemetryDeck.Config(appID: "test-app-id", namespace: "test-ns") + + @Test + func emptyProcessorsPipeline() async throws { + let pipeline = ProcessorPipeline( + processors: [], + finalizer: EventFinalizer(configuration: testConfig) + ) + let input = EventInput("Test.signal") + let context = EventContext() + let signal = try await pipeline.process(input, context: context) + #expect(signal.type == "Test.signal") + #expect(signal.appID == "test-app-id") + } + + @Test + func processorMutatesContext() async throws { + let processor = ParameterAddingProcessor(key: "testKey", value: "testValue") + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: testConfig) + ) + let input = EventInput("Test.signal") + let context = EventContext() + let signal = try await pipeline.process(input, context: context) + #expect(signal.payload["testKey"] == "testValue") + } + + @Test + func processorsCalledInOrder() async throws { + let processor1 = ParameterAddingProcessor(key: "param1", value: "value1") + let processor2 = ParameterAddingProcessor(key: "param2", value: "value2") + let pipeline = ProcessorPipeline( + processors: [processor1, processor2], + finalizer: EventFinalizer(configuration: testConfig) + ) + let input = EventInput("Test.signal") + let context = EventContext() + let signal = try await pipeline.process(input, context: context) + #expect(signal.payload["param1"] == "value1") + #expect(signal.payload["param2"] == "value2") + } + + @Test + func eventFilteredStopsChain() async throws { + let processor = FilteringProcessor() + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: testConfig) + ) + let input = EventInput("Test.signal") + let context = EventContext() + + do { + _ = try await pipeline.process(input, context: context) + Issue.record("Expected eventFiltered error to be thrown") + } catch let error as ProcessorError { + switch error { + case .eventFiltered: + break + default: + Issue.record("Expected eventFiltered, got \(error)") + } + } + } + + @Test + func finalizerHashesUserIdentifier() async throws { + let pipeline = ProcessorPipeline( + processors: [], + finalizer: EventFinalizer(configuration: testConfig) + ) + let input = EventInput("Test.signal") + var context = EventContext() + context.userIdentifier = "test@example.com" + let signal = try await pipeline.process(input, context: context) + let expectedHash = CryptoHashing.sha256(string: "test@example.com", salt: "") + #expect(signal.clientUser == expectedHash) + } + + @Test + func finalizerMergesParameters() async throws { + let pipeline = ProcessorPipeline( + processors: [], + finalizer: EventFinalizer(configuration: testConfig) + ) + var context = EventContext() + context.addParameter("paramA", value: "contextValue") + context.addParameter("paramB", value: "onlyInContext") + + let input = EventInput( + "Test.signal", + parameters: [ + "paramA": "inputValue", + "paramC": "onlyInInput", + ] + ) + + let signal = try await pipeline.process(input, context: context) + #expect(signal.payload["paramA"] == "inputValue") + #expect(signal.payload["paramB"] == "onlyInContext") + #expect(signal.payload["paramC"] == "onlyInInput") + } +} + +private struct ParameterAddingProcessor: EventProcessor { + let key: String + let value: String + + func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + var context = context + context.addParameter(key, value: value) + return try await next(input, context) + } +} + +private struct FilteringProcessor: EventProcessor { + func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + throw ProcessorError.eventFiltered + } +} diff --git a/Tests/TelemetryDeckTests/PresetIntegrationTests.swift b/Tests/TelemetryDeckTests/PresetIntegrationTests.swift new file mode 100644 index 0000000..6751d86 --- /dev/null +++ b/Tests/TelemetryDeckTests/PresetIntegrationTests.swift @@ -0,0 +1,518 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +@Suite(.serialized) +struct PresetIntegrationTests { + + // MARK: - Navigation + + @Test + func navigationPathChangedUsesEmptySourceOnFirstCall() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "navigation-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.navigationPathChanged(to: "profile") + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == "TelemetryDeck.Navigation.pathChanged") + #expect(events[0].payload["TelemetryDeck.Navigation.sourcePath"] == "") + #expect(events[0].payload["TelemetryDeck.Navigation.destinationPath"] == "profile") + #expect(events[0].payload["TelemetryDeck.Navigation.identifier"] == " -> profile") + + await TelemetryDeck.terminate() + } + + @Test + func navigationPathChangedWithSourceAndDestination() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "navigation-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.navigationPathChanged(from: "home", to: "settings") + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == "TelemetryDeck.Navigation.pathChanged") + #expect(events[0].payload["TelemetryDeck.Navigation.sourcePath"] == "home") + #expect(events[0].payload["TelemetryDeck.Navigation.destinationPath"] == "settings") + #expect(events[0].payload["TelemetryDeck.Navigation.identifier"] == "home -> settings") + #expect(events[0].payload["TelemetryDeck.Navigation.schemaVersion"] == "1") + + await TelemetryDeck.terminate() + } + + @Test + func navigationPathChangedChainsPreviousDestination() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "navigation-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.navigationPathChanged(to: "first") + + let firstSignals = await cache.pop() + #expect(firstSignals.count == 1) + #expect(firstSignals[0].payload["TelemetryDeck.Navigation.destinationPath"] == "first") + + await TelemetryDeck.navigationPathChanged(to: "second") + + let secondSignals = await cache.pop() + #expect(secondSignals.count == 1) + #expect(secondSignals[0].payload["TelemetryDeck.Navigation.sourcePath"] == "first") + #expect(secondSignals[0].payload["TelemetryDeck.Navigation.destinationPath"] == "second") + #expect(secondSignals[0].payload["TelemetryDeck.Navigation.identifier"] == "first -> second") + + await TelemetryDeck.terminate() + } + + @Test + func navigationSignalNameIsCorrect() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "navigation-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.navigationPathChanged(to: "anyPath") + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == "TelemetryDeck.Navigation.pathChanged") + + await TelemetryDeck.terminate() + } + + // MARK: - Pirate Metrics + + @Test + func acquiredUserSendsCorrectSignalNameAndChannel() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "pirate-metrics-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.acquiredUser(channel: "organic-search") + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == "TelemetryDeck.Acquisition.userAcquired") + #expect(events[0].payload["TelemetryDeck.Acquisition.channel"] == "organic-search") + + await TelemetryDeck.terminate() + } + + @Test + func leadStartedIncludesLeadID() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "pirate-metrics-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.leadStarted(leadID: "lead-12345") + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == "TelemetryDeck.Acquisition.leadStarted") + #expect(events[0].payload["TelemetryDeck.Acquisition.leadID"] == "lead-12345") + + await TelemetryDeck.terminate() + } + + @Test + func coreFeatureUsedIncludesFeatureName() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "pirate-metrics-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.coreFeatureUsed(featureName: "photo-editor") + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == "TelemetryDeck.Activation.coreFeatureUsed") + #expect(events[0].payload["TelemetryDeck.Activation.featureName"] == "photo-editor") + + await TelemetryDeck.terminate() + } + + @Test + func userRatingSubmittedRejectsOutOfRangeRating() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "pirate-metrics-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.userRatingSubmitted(rating: 11) + + let count = await cache.count() + #expect(count == 0) + + await TelemetryDeck.terminate() + } + + @Test + func userRatingSubmittedAcceptsValidRating() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "pirate-metrics-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.userRatingSubmitted(rating: 8, comment: "Great app!") + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == "TelemetryDeck.Referral.userRatingSubmitted") + #expect(events[0].payload["TelemetryDeck.Referral.ratingValue"] == 8) + #expect(events[0].payload["TelemetryDeck.Referral.ratingComment"] == "Great app!") + #expect(events[0].floatValue == nil) + + await TelemetryDeck.terminate() + } + + // MARK: - Error Reporting + + @Test + func errorOccurredSendsSignalWithIDAndCategory() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "error-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.errorOccurred(id: "error-001", category: .userInput) + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == "TelemetryDeck.Error.occurred") + #expect(events[0].payload["TelemetryDeck.Error.id"] == "error-001") + #expect(events[0].payload["TelemetryDeck.Error.category"] == "user-input") + + await TelemetryDeck.terminate() + } + + @Test + func errorOccurredWithIdentifiableErrorUsesErrorID() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "error-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + struct TestError: LocalizedError, IdentifiableError { + let id = "identifiable-error-123" + var errorDescription: String? { "Test error description" } + } + + await TelemetryDeck.errorOccurred(identifiableError: TestError(), category: .thrownException) + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == "TelemetryDeck.Error.occurred") + #expect(events[0].payload["TelemetryDeck.Error.id"] == "identifiable-error-123") + #expect(events[0].payload["TelemetryDeck.Error.category"] == "thrown-exception") + #expect(events[0].payload["TelemetryDeck.Error.message"] == "Test error description") + + await TelemetryDeck.terminate() + } + + @Test + func errorOccurredWithMessageIncludesMessage() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "error-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.errorOccurred(id: "error-with-message", category: .appState, message: "Custom error message") + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == "TelemetryDeck.Error.occurred") + #expect(events[0].payload["TelemetryDeck.Error.id"] == "error-with-message") + #expect(events[0].payload["TelemetryDeck.Error.category"] == "app-state") + #expect(events[0].payload["TelemetryDeck.Error.message"] == "Custom error message") + + await TelemetryDeck.terminate() + } + + @Test + func errorOccurredWithoutCategoryOmitsIt() async throws { + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "error-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.errorOccurred(id: "error-no-category") + + let events = await cache.pop() + #expect(events.count == 1) + #expect(events[0].type == "TelemetryDeck.Error.occurred") + #expect(events[0].payload["TelemetryDeck.Error.id"] == "error-no-category") + #expect(events[0].payload["TelemetryDeck.Error.category"] == nil) + + await TelemetryDeck.terminate() + } + + @Test + func doubleInitializationIsIgnored() async throws { + await TelemetryDeck.terminate() + + let firstCache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "double-init-test", namespace: "test") + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: firstCache, + transmitter: SpyEventTransmitter() + ) + + let secondCache = InMemoryEventCache() + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: secondCache, + transmitter: SpyEventTransmitter() + ) + + await TelemetryDeck.event("Test.doubleInit") + + let firstCount = await firstCache.count() + let secondCount = await secondCache.count() + #expect(firstCount == 1) + #expect(secondCount == 0) + + await TelemetryDeck.terminate() + } + + // MARK: - Initialization + + @Test + func emptyAppIDPreventsInitialization() async { + await #expect(throws: TelemetryDeckError.self) { + try await TelemetryDeck.initialize( + configuration: TelemetryDeck.Config(appID: "", namespace: "test") + ) + } + await TelemetryDeck.terminate() + } + + @Test + func emptyNamespacePreventsInitialization() async { + await #expect(throws: TelemetryDeckError.self) { + try await TelemetryDeck.initialize( + configuration: TelemetryDeck.Config(appID: "test", namespace: "") + ) + } + await TelemetryDeck.terminate() + } + + @Test + func eventsAreDroppedAfterFailedInitialization() async { + await TelemetryDeck.terminate() + + try? await TelemetryDeck.initialize( + configuration: TelemetryDeck.Config(appID: "", namespace: "test") + ) + + await TelemetryDeck.event("Should.not.send") + + let client = await TelemetryDeck.client() + #expect(client == nil) + + await TelemetryDeck.terminate() + } + + // MARK: - Pre-Initialization Buffering + + @Test + func eventsBeforeInitAreDeliveredAfterInit() async throws { + await TelemetryDeck.terminate() + + await TelemetryDeck.event("Buffer.first", parameters: ["key": "value1"]) + await TelemetryDeck.event("Buffer.second", parameters: ["key": "value2"]) + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "test-buffering", namespace: "test") + + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter(), + logger: NoOpLogger() + ) + + let events = await cache.pop() + #expect(events.count == 2) + #expect(events.contains { $0.type == "Buffer.first" }) + #expect(events.contains { $0.type == "Buffer.second" }) + #expect(events.first { $0.type == "Buffer.first" }?.payload["key"] == "value1") + #expect(events.first { $0.type == "Buffer.second" }?.payload["key"] == "value2") + + await TelemetryDeck.terminate() + } + + @Test + func bufferedTimestampsReflectCreationTime() async throws { + await TelemetryDeck.terminate() + + let before = Date() + await TelemetryDeck.event("Timestamp.test") + try await Task.sleep(nanoseconds: 100_000_000) + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "test-timestamps", namespace: "test") + + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter(), + logger: NoOpLogger() + ) + + let events = await cache.pop() + #expect(events.count == 1) + + let eventTime = events[0].receivedAt + #expect(eventTime >= before) + #expect(eventTime < Date()) + + await TelemetryDeck.terminate() + } + + @Test + func terminateClearsBuffer() async throws { + await TelemetryDeck.terminate() + + await TelemetryDeck.event("Discarded.event") + await TelemetryDeck.terminate() + + let cache = InMemoryEventCache() + let config = TelemetryDeck.Config(appID: "test-clear-buffer", namespace: "test") + + try await TelemetryDeck.initialize( + configuration: config, + processors: [], + cache: cache, + transmitter: SpyEventTransmitter(), + logger: NoOpLogger() + ) + + let count = await cache.count() + #expect(count == 0) + + await TelemetryDeck.terminate() + } + + @Test + func concurrentSignalsDuringInitAllArrive() async throws { + await TelemetryDeck.terminate() + + let eventCount = 20 + + await withTaskGroup(of: Void.self) { group in + for i in 0.. Event = { _, ctx in + #expect(ctx.sessionID != nil) + return Event( + appID: "test-app", + type: input.name, + clientUser: "test-user", + sessionID: ctx.sessionID?.uuidString, + receivedAt: Date(), + payload: [:], + floatValue: nil, + isTestMode: false + ) + } + + let signal = try await processor.process(input, context: context, next: mockNext) + + #expect(signal.sessionID != nil) + } + + @Test + func sessionIDChangesAfterStartNewSession() async throws { + let processor = SessionTrackingProcessor() + + let originalSessionID = await processor.currentSessionID() + let newSessionID = await processor.startNewSession() + + #expect(originalSessionID != newSessionID) + + let currentSessionID = await processor.currentSessionID() + #expect(currentSessionID == newSessionID) + } + + @Test + func foregroundAfterShortBackgroundKeepsSameSession() async throws { + let processor = SessionTrackingProcessor(sendSessionStartedEvent: false) + let storage = InMemoryProcessorStorage() + await processor.start(storage: storage, logger: DefaultLogger(), emitter: MockEventSender()) + + let sessionBeforeBackground = await processor.currentSessionID() + + #if canImport(AppKit) + NotificationCenter.default.post(name: NSApplication.didResignActiveNotification, object: nil) + try? await Task.sleep(nanoseconds: 100_000_000) + NotificationCenter.default.post(name: NSApplication.willBecomeActiveNotification, object: nil) + #elseif canImport(UIKit) && !os(watchOS) + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 100_000_000) + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + #endif + + try? await Task.sleep(nanoseconds: 100_000_000) + + let sessionAfterForeground = await processor.currentSessionID() + #expect(sessionBeforeBackground == sessionAfterForeground) + + await processor.stop() + } + + @Test + func stopCleansUpRegistration() async throws { + let processor = SessionTrackingProcessor(sendSessionStartedEvent: false) + let storage = InMemoryProcessorStorage() + + await processor.start(storage: storage, logger: DefaultLogger(), emitter: MockEventSender()) + await processor.stop() + } +} diff --git a/Tests/TelemetryDeckTests/SignalCacheConcurrencyTests.swift b/Tests/TelemetryDeckTests/SignalCacheConcurrencyTests.swift deleted file mode 100644 index cb6a5c1..0000000 --- a/Tests/TelemetryDeckTests/SignalCacheConcurrencyTests.swift +++ /dev/null @@ -1,145 +0,0 @@ -import Foundation -import Testing - -@testable import TelemetryDeck - -struct SignalCacheConcurrencyTests { - - /// Repro for https://github.com/TelemetryDeck/SwiftSDK/issues/265: - /// - /// count() with barrier blocks because it waits for ALL pending GCD operations. - /// - /// The bug: When count() uses `.barrier`, it must wait for all prior async blocks - /// to complete before executing. If those blocks do work before calling push(), - /// count() is blocked for their entire duration. - /// - /// This test queues async blocks with artificial delays to create pending work, - /// then immediately calls count() to measure blocking. - @Test - func count_barrierCausesMainThreadBlock() { - if #available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) { - let cache = SignalCache(logHandler: nil) - let stressQueue = DispatchQueue(label: "com.telemetrydeck.stressdaqueue", attributes: .concurrent) - - // Queue 50 operations that each take 2ms BEFORE reaching push() - // With barrier bug: count() waits for ALL of these (~100ms total) - // With fix: count() returns immediately (~0ms) - for i in 0..<50 { - stressQueue.async { - Thread.sleep(forTimeInterval: 0.002) - cache.push(Self.makeSignal(id: "\(i)")) - } - } - - // Immediately call count() - this is what the timer callback does - let start = CFAbsoluteTimeGetCurrent() - _ = cache.count() - let elapsed = CFAbsoluteTimeGetCurrent() - start - - // With barrier bug: ~100ms (50 * 2ms serialized wait) - // With fix (no barrier): < 10ms (just reads array.count) - #expect(elapsed < 0.010, "count() blocked for \(elapsed)s - barrier flag causing contention") - } else { - print("skipping test on incompatible OS") - } - } - - /// Validates thread safety of concurrent push and pop operations. - /// After fix, pop() uses barrier flag to ensure exclusive access during mutation. - @Test - func concurrentPushAndPop_maintainsDataIntegrity() async { - if #available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) { - let cache = SignalCache(logHandler: nil) - let pushCount = 500 - - await withTaskGroup(of: Void.self) { group in - // Concurrent pushes - for i in 0..(logHandler: nil) - let signalCount = 200 - - for i in 0.. SignalPostBody { - SignalPostBody( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: id, - sessionID: id, - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ) - } -} diff --git a/Tests/TelemetryDeckTests/SignalPayloadTests.swift b/Tests/TelemetryDeckTests/SignalPayloadTests.swift deleted file mode 100644 index 16ea46e..0000000 --- a/Tests/TelemetryDeckTests/SignalPayloadTests.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Testing - -@testable import TelemetryDeck - -struct DefaultSignalPayloadTests { - @Test - func isSimulatorOrTestFlight() { - print("isSimulatorOrTestFlight", DefaultSignalPayload.isSimulatorOrTestFlight) - } - - @Test - func isSimulator() { - print("isSimulator", DefaultSignalPayload.isSimulator) - } - - @Test - func isDebug() { - #expect(DefaultSignalPayload.isDebug == true) - print("isDebug", DefaultSignalPayload.isDebug) - } - - @Test - func isTestFlight() { - #expect(DefaultSignalPayload.isTestFlight == false) - print("isTestFlight", DefaultSignalPayload.isTestFlight) - } - - @Test - func isAppStore() { - #expect(DefaultSignalPayload.isAppStore == false) - print("isAppStore", DefaultSignalPayload.isAppStore) - } - - @Test - func systemVersion() { - print("systemVersion", DefaultSignalPayload.systemVersion) - } - - @Test - func majorSystemVersion() { - print("majorSystemVersion", DefaultSignalPayload.majorSystemVersion) - } - - @Test - func majorMinorSystemVersion() { - print("majorMinorSystemVersion", DefaultSignalPayload.majorMinorSystemVersion) - } - - @Test - func appVersion() { - print("appVersion", DefaultSignalPayload.appVersion) - } - - @Test - func buildNumber() { - print("buildNumber", DefaultSignalPayload.buildNumber) - } - - @Test - func modelName() { - print("modelName", DefaultSignalPayload.modelName) - } - - @Test - func architecture() { - print("architecture", DefaultSignalPayload.architecture) - } - - @Test - func operatingSystem() { - let expectedResult: String - - #if os(macOS) - expectedResult = "macOS" - #elseif os(iOS) - expectedResult = "iOS" - #elseif os(watchOS) - expectedResult = "watchOS" - #elseif os(tvOS) - expectedResult = "tvOS" - #elseif os(Linux) - expectedResult = "Linux" - #elseif os(visionOS) - expectedResult = "visionOS" - #else - return "Unknown Operating System" - #endif - - #expect(expectedResult == DefaultSignalPayload.operatingSystem) - - print("operatingSystem", DefaultSignalPayload.operatingSystem) - } - - @Test - func platform() { - print("platform", DefaultSignalPayload.platform) - } - - @Test - func targetEnvironment() { - print("targetEnvironment", DefaultSignalPayload.targetEnvironment) - } - - @Test - func locale() { - print("locale", DefaultSignalPayload.locale) - } -} diff --git a/Tests/TelemetryDeckTests/TelemetryClientTests.swift b/Tests/TelemetryDeckTests/TelemetryClientTests.swift deleted file mode 100644 index cb7437e..0000000 --- a/Tests/TelemetryDeckTests/TelemetryClientTests.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// TelemetryClientTests.swift -// TelemetryDeck -// -// Created by Konstantin on 17/11/2025. -// - -import Foundation -import Testing - -@testable import TelemetryDeck - -struct TelemetryClientTests { - - @Test - func `SignalManager creates correct service url`() { - - let config = TelemetryManagerConfiguration(appID: "44e0f59a-60a2-4d4a-bf27-1f96ccb4aaa3") - let result = SignalManager.getServiceUrl(baseURL: config.apiBaseURL) - - #expect(result != nil) - #expect(result?.absoluteString == "https://nom.telemetrydeck.com/v2/") - } - - @Test - func `SignalManager creates correct service url with namespace`() { - - let config = TelemetryManagerConfiguration(appID: "44e0f59a-60a2-4d4a-bf27-1f96ccb4aaa3") - let result = SignalManager.getServiceUrl(baseURL: config.apiBaseURL, namespace: "deltaquadrant") - - #expect(result != nil) - #expect(result?.absoluteString == "https://nom.telemetrydeck.com/v2/namespace/deltaquadrant/") - } - - @Test - func `SignalManager preserves base URL path components`() { - let baseURL = URL(string: "https://example.com/array/sensors")! - let result = SignalManager.getServiceUrl(baseURL: baseURL) - - #expect(result != nil) - #expect(result?.absoluteString == "https://example.com/array/sensors/v2/") - } - - @Test - func `SignalManager preserves base URL path components with trailing slash`() { - let baseURL = URL(string: "https://example.com/array/sensors/")! - let result = SignalManager.getServiceUrl(baseURL: baseURL) - - #expect(result != nil) - #expect(result?.absoluteString == "https://example.com/array/sensors/v2/") - } - - @Test - func `SignalManager preserves base URL path components with namespace`() { - let baseURL = URL(string: "https://example.com/array/sensors")! - let result = SignalManager.getServiceUrl(baseURL: baseURL, namespace: "deltaquadrant") - - #expect(result != nil) - #expect(result?.absoluteString == "https://example.com/array/sensors/v2/namespace/deltaquadrant/") - } - - @Test - func `SignalManager preserves base URL path components with trailing slash and namespace`() { - let baseURL = URL(string: "https://example.com/array/sensors/")! - let result = SignalManager.getServiceUrl(baseURL: baseURL, namespace: "deltaquadrant") - - #expect(result != nil) - #expect(result?.absoluteString == "https://example.com/array/sensors/v2/namespace/deltaquadrant/") - } -} diff --git a/Tests/TelemetryDeckTests/TelemetryDeckErrorTests.swift b/Tests/TelemetryDeckTests/TelemetryDeckErrorTests.swift new file mode 100644 index 0000000..2fb14e0 --- /dev/null +++ b/Tests/TelemetryDeckTests/TelemetryDeckErrorTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct TelemetryDeckErrorTests { + @Test + func invalidConfigurationCodeRawValue() { + #expect(TelemetryDeckError.Code.invalidConfiguration.rawValue == 1001) + } + + @Test + func errorDomainIsTelemetryDeck() { + let error = TelemetryDeckError(code: .invalidConfiguration, localizedDescription: "test") + #expect(TelemetryDeckError.errorDomain == "TelemetryDeck") + #expect((error as NSError).domain == "TelemetryDeck") + } + + @Test + func nsErrorCodeMatchesRawValue() { + let error = TelemetryDeckError(code: .invalidConfiguration, localizedDescription: "test") + let nsError = error as NSError + #expect(nsError.code == 1001) + } + + @Test + func nsErrorUserInfoContainsLocalizedDescription() { + let message = "appID must not be empty" + let error = TelemetryDeckError(code: .invalidConfiguration, localizedDescription: message) + let nsError = error as NSError + #expect(nsError.localizedDescription == message) + } + + @Test + func localizedErrorDescriptionMatchesMessage() { + let message = "Something went wrong" + let error = TelemetryDeckError(code: .invalidConfiguration, localizedDescription: message) + #expect(error.errorDescription == message) + } + + @Test + func debugDescriptionIncludesCodeAndMessage() { + let error = TelemetryDeckError(code: .invalidConfiguration, localizedDescription: "bad config") + #expect(error.debugDescription == "TelemetryDeckError.invalidConfiguration (1001): bad config") + } + + @Test + func patternMatchingOnCodeSucceeds() { + let error: any Error = TelemetryDeckError(code: .invalidConfiguration, localizedDescription: "test") + #expect(TelemetryDeckError.Code.invalidConfiguration ~= error) + } + + @Test + func patternMatchingOnUnrelatedErrorFails() { + let error: any Error = URLError(.badURL) + #expect(!(TelemetryDeckError.Code.invalidConfiguration ~= error)) + } + + @Test + func typedCatchAllowsDirectCodeAccess() { + let config = TelemetryDeck.Config(appID: "", namespace: "test") + do { + try config.validate() + Issue.record("Expected TelemetryDeckError to be thrown") + } catch { + #expect(error.code == .invalidConfiguration) + } + } +} diff --git a/Tests/TelemetryDeckTests/TelemetryDeckTests.swift b/Tests/TelemetryDeckTests/TelemetryDeckTests.swift deleted file mode 100644 index 46794b7..0000000 --- a/Tests/TelemetryDeckTests/TelemetryDeckTests.swift +++ /dev/null @@ -1,353 +0,0 @@ -import Foundation -import Testing - -@testable import TelemetryDeck - -struct TelemetryDeckTests { - @Test - func sending() { - let YOUR_APP_ID = "44e0f59a-60a2-4d4a-bf27-1f96ccb4aaa3" - - let config = TelemetryManagerConfiguration(appID: YOUR_APP_ID) - TelemetryDeck.initialize(config: config) - TelemetryDeck.signal("appOpenedRegularly") - TelemetryDeck.signal("userLoggedIn", customUserID: "email") - TelemetryDeck.signal("databaseUpdated", parameters: ["numberOfDatabaseEntries": "3831"]) - } - - @Test - func pushAndPop() { - let signalCache = SignalCache(logHandler: nil) - - let signals: [SignalPostBody] = [ - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "01", - sessionID: "01", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "02", - sessionID: "02", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "03", - sessionID: "03", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "04", - sessionID: "04", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "05", - sessionID: "05", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "06", - sessionID: "06", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "07", - sessionID: "07", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "08", - sessionID: "08", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "09", - sessionID: "09", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "10", - sessionID: "10", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "11", - sessionID: "11", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "12", - sessionID: "12", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "13", - sessionID: "13", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "14", - sessionID: "14", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - .init( - receivedAt: Date(), - appID: UUID().uuidString, - clientUser: "15", - sessionID: "15", - type: "test", - floatValue: nil, - payload: [:], - isTestMode: "true" - ), - ] - - for signal in signals { - signalCache.push(signal) - } - - var allPoppedSignals: [SignalPostBody] = [] - var poppedSignalsBatch: [SignalPostBody] = signalCache.pop() - while !poppedSignalsBatch.isEmpty { - allPoppedSignals.append(contentsOf: poppedSignalsBatch) - poppedSignalsBatch = signalCache.pop() - } - - #expect(signals.count == allPoppedSignals.count) - - allPoppedSignals.sort { lhs, rhs in - lhs.type < rhs.type - } - - #expect(signals == allPoppedSignals) - } - - @Test(.disabled("this test is flaky"), .bug("https://github.com/TelemetryDeck/SwiftSDK/issues/200")) - func signalEnrichers() throws { - struct BasicEnricher: SignalEnricher { - func enrich(signalType: String, for clientUser: String?, floatValue: Double?) -> [String: String] { - ["isTestEnricher": "true"] - } - } - - let configuration = TelemetryManagerConfiguration(appID: UUID().uuidString) - configuration.metadataEnrichers.append(BasicEnricher()) - - let signalManager = FakeSignalManager() - TelemetryManager.initialize(with: configuration, signalManager: signalManager) - TelemetryDeck.signal("testSignal") - - let bodyItems = signalManager.processedSignals - #expect(bodyItems.count == 1) - let bodyItem = try #require(bodyItems.first) - #expect(bodyItem.payload["isTestEnricher"] == "true") - } - - @Test(.disabled("this test is flaky"), .bug("https://github.com/TelemetryDeck/SwiftSDK/issues/200")) - func signalEnrichers_precedence() throws { - struct BasicEnricher: SignalEnricher { - func enrich(signalType: String, for clientUser: String?, floatValue: Double?) -> [String: String] { - ["item": "A", "isDebug": "banana"] - } - } - - let configuration = TelemetryManagerConfiguration(appID: UUID().uuidString) - configuration.metadataEnrichers.append(BasicEnricher()) - - let signalManager = FakeSignalManager() - TelemetryManager.initialize(with: configuration, signalManager: signalManager) - TelemetryDeck.signal("testSignal", parameters: ["item": "B"]) - - let bodyItems = signalManager.processedSignals - #expect(bodyItems.count == 1) - let bodyItem = try #require(bodyItems.first) - #expect(bodyItem.payload["item"] == "B") // .send takes priority over enricher - #expect(bodyItem.payload["isDebug"] == "banana") // enricher takes priority over default payload - } - - @Test(.disabled("this test is flaky"), .bug("https://github.com/TelemetryDeck/SwiftSDK/issues/200")) - func sendsSignals_withAnalyticsImplicitlyEnabled() { - let YOUR_APP_ID = "44e0f59a-60a2-4d4a-bf27-1f96ccb4aaa3" - - let configuration = TelemetryManagerConfiguration(appID: YOUR_APP_ID) - - let signalManager = FakeSignalManager() - TelemetryManager.initialize(with: configuration, signalManager: signalManager) - - TelemetryDeck.signal("appOpenedRegularly") - - #expect(signalManager.processedSignalTypes.count == 1) - } - - @Test(.disabled("this test is flaky"), .bug("https://github.com/TelemetryDeck/SwiftSDK/issues/200")) - func sendsSignals_withAnalyticsExplicitlyEnabled() { - let YOUR_APP_ID = "44e0f59a-60a2-4d4a-bf27-1f96ccb4aaa3" - - let configuration = TelemetryManagerConfiguration(appID: YOUR_APP_ID) - configuration.analyticsDisabled = false - - let signalManager = FakeSignalManager() - TelemetryManager.initialize(with: configuration, signalManager: signalManager) - - TelemetryDeck.signal("appOpenedRegularly") - - #expect(signalManager.processedSignalTypes.count == 1) - } - - @Test - func doesNotSendSignals_withAnalyticsExplicitlyDisabled() { - let YOUR_APP_ID = "44e0f59a-60a2-4d4a-bf27-1f96ccb4aaa3" - - let configuration = TelemetryManagerConfiguration(appID: YOUR_APP_ID) - configuration.analyticsDisabled = true - - let signalManager = FakeSignalManager() - TelemetryManager.initialize(with: configuration, signalManager: signalManager) - - TelemetryDeck.signal("appOpenedRegularly") - - #expect(signalManager.processedSignalTypes.isEmpty == true) - } - - @Test - func doesNotSendSignals_withAnalyticsExplicitlyEnabled_inPreviewMode() { - setenv("XCODE_RUNNING_FOR_PREVIEWS", "1", 1) - - let YOUR_APP_ID = "44e0f59a-60a2-4d4a-bf27-1f96ccb4aaa3" - - let configuration = TelemetryManagerConfiguration(appID: YOUR_APP_ID) - configuration.analyticsDisabled = false - - let signalManager = FakeSignalManager() - TelemetryManager.initialize(with: configuration, signalManager: signalManager) - - TelemetryDeck.signal("appOpenedRegularly") - - #expect(signalManager.processedSignalTypes.isEmpty == true) - - setenv("XCODE_RUNNING_FOR_PREVIEWS", "0", 1) - } - - @Test(.disabled("this test is flaky"), .bug("https://github.com/TelemetryDeck/SwiftSDK/issues/200")) - func sendsSignals_withNumercalValue() { - let YOUR_APP_ID = "44e0f59a-60a2-4d4a-bf27-1f96ccb4aaa3" - - let configuration = TelemetryManagerConfiguration(appID: YOUR_APP_ID) - - let signalManager = FakeSignalManager() - TelemetryManager.initialize(with: configuration, signalManager: signalManager) - - TelemetryDeck.signal("appOpenedRegularly", floatValue: 42) - - #expect(signalManager.processedSignals.first?.floatValue == 42) - } -} - -private class FakeSignalManager: @preconcurrency SignalManageable { - var processedSignalTypes: [String] = [] - var processedSignals: [SignalPostBody] = [] - - @MainActor - func processSignal( - _ signalType: String, - parameters: [String: String], - floatValue: Double?, - customUserID: String?, - configuration: TelemetryManagerConfiguration - ) { - processedSignalTypes.append(signalType) - let enrichedMetadata: [String: String] = configuration.metadataEnrichers - .map { $0.enrich(signalType: signalType, for: customUserID, floatValue: floatValue) } - .reduce([String: String]()) { $0.applying($1) } - - let payload = DefaultSignalPayload.parameters - .applying(enrichedMetadata) - .applying(parameters) - - let signalPostBody = SignalPostBody( - receivedAt: Date(), - appID: configuration.telemetryAppID, - clientUser: customUserID ?? "no user", - sessionID: configuration.sessionID.uuidString, - type: "\(signalType)", - floatValue: floatValue, - payload: payload, - isTestMode: configuration.testMode ? "true" : "false" - ) - processedSignals.append(signalPostBody) - } - - func attemptToSendNextBatchOfCachedSignals() {} - - var defaultUserIdentifier: String { UUID().uuidString } -} diff --git a/Tests/TelemetryDeckTests/TelemetryEngineTests.swift b/Tests/TelemetryDeckTests/TelemetryEngineTests.swift new file mode 100644 index 0000000..b13ce5a --- /dev/null +++ b/Tests/TelemetryDeckTests/TelemetryEngineTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +private actor NoOpEventTransmitter: EventTransmitting { + private let cache: (any EventCaching)? + + init(cache: (any EventCaching)? = nil) { + self.cache = cache + } + + func transmit(_ events: [Event]) async -> [Event] { + [] + } + + func flush() async { + guard let cache else { return } + let events = await cache.pop() + _ = await transmit(events) + } + + func start() async {} + func stop() async {} +} + +@Suite +struct TelemetryEngineTests { + @Test + func processorOfTypeReturnsCorrectProcessor() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let cache = InMemoryEventCache() + let transmitter = NoOpEventTransmitter(cache: cache) + + let client = await TelemetryEngine.create( + configuration: config, + processors: [TestModeProcessor(), SessionTrackingProcessor()], + cache: cache, + transmitter: transmitter, + storage: InMemoryProcessorStorage() + ) + + let testModeProcessor = await client.processor(ofType: TestModeProcessor.self) + #expect(testModeProcessor != nil) + + await client.shutdown() + } + + @Test + func processorConformingToReturnsCorrectProcessor() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let cache = InMemoryEventCache() + let transmitter = NoOpEventTransmitter(cache: cache) + + let client = await TelemetryEngine.create( + configuration: config, + processors: [SessionTrackingProcessor(), TestModeProcessor()], + cache: cache, + transmitter: transmitter, + storage: InMemoryProcessorStorage() + ) + + let sessionManager = await client.processor(conformingTo: SessionManaging.self) + #expect(sessionManager != nil) + + let testModeProvider = await client.processor(conformingTo: TestModeProviding.self) + #expect(testModeProvider != nil) + + await client.shutdown() + } + + @Test + func processorOfTypeReturnsNilForMissing() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let cache = InMemoryEventCache() + let transmitter = NoOpEventTransmitter(cache: cache) + + let client = await TelemetryEngine.create( + configuration: config, + processors: [SessionTrackingProcessor()], + cache: cache, + transmitter: transmitter, + storage: InMemoryProcessorStorage() + ) + + let testModeProcessor = await client.processor(ofType: TestModeProcessor.self) + #expect(testModeProcessor == nil) + + await client.shutdown() + } + + @Test + func shutdownDoesNotPreventSubsequentSends() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let cache = InMemoryEventCache() + let transmitter = NoOpEventTransmitter(cache: cache) + + let client = await TelemetryEngine.create( + configuration: config, + processors: [], + cache: cache, + transmitter: transmitter, + storage: InMemoryProcessorStorage() + ) + + await client.shutdown() + await client.send(EventInput("After.shutdown")) + + let count = await cache.count() + #expect(count == 1) + } + + @Test + func flushEmptiesCacheAfterSend() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let cache = InMemoryEventCache() + let transmitter = NoOpEventTransmitter(cache: cache) + + let client = await TelemetryEngine.create( + configuration: config, + processors: [], + cache: cache, + transmitter: transmitter, + storage: InMemoryProcessorStorage() + ) + + await client.send(EventInput("Test.signal")) + + let countBeforeFlush = await cache.count() + #expect(countBeforeFlush == 1) + + await client.flush() + + let countAfterFlush = await cache.count() + #expect(countAfterFlush == 0) + + await client.shutdown() + } +} diff --git a/Tests/TelemetryDeckTests/TestModeProcessorTests.swift b/Tests/TelemetryDeckTests/TestModeProcessorTests.swift new file mode 100644 index 0000000..7217975 --- /dev/null +++ b/Tests/TelemetryDeckTests/TestModeProcessorTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct TestModeProcessorTests { + @Test + func overrideTrueForceTestMode() async throws { + let processor = TestModeProcessor(override: true) + let isTest = await processor.isTestMode() + #expect(isTest == true) + } + + @Test + func overrideFalseDisablesTestMode() async throws { + let processor = TestModeProcessor(override: false) + let isTest = await processor.isTestMode() + #expect(isTest == false) + } + + @Test + func overrideNilUsesDebugFlag() async throws { + let processor = TestModeProcessor(override: nil) + let isTest = await processor.isTestMode() + #if DEBUG + #expect(isTest == true) + #else + #expect(isTest == false) + #endif + } + + @Test + func testModePropagatedToSignalContext() async throws { + let processor = TestModeProcessor(override: true) + let configuration = TelemetryDeck.Config(appID: "test-app-id", namespace: "test") + + let capturer = ContextCapturingProcessor() + let pipeline = ProcessorPipeline( + processors: [processor, capturer], + finalizer: EventFinalizer(configuration: configuration) + ) + + let input = EventInput("test.event") + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + let capturedTestMode = await capturer.capturedTestMode + #expect(capturedTestMode == true) + } +} + +private actor ContextCapturingProcessor: EventProcessor { + private(set) var capturedTestMode: Bool? + + func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + capturedTestMode = context.isTestMode + return try await next(input, context) + } +} diff --git a/Tests/TelemetryDeckTests/TestSupport.swift b/Tests/TelemetryDeckTests/TestSupport.swift new file mode 100644 index 0000000..4cfb2b5 --- /dev/null +++ b/Tests/TelemetryDeckTests/TestSupport.swift @@ -0,0 +1,62 @@ +import Foundation + +@testable import TelemetryDeck + +actor InMemoryProcessorStorage: ProcessorStorage { + private var storage: [String: Any] = [:] + + func data(forKey key: String) async -> Data? { + storage[key] as? Data + } + + func set(_ value: Data?, forKey key: String) async { + storage[key] = value + } + + func string(forKey key: String) async -> String? { + storage[key] as? String + } + + func set(_ value: String?, forKey key: String) async { + storage[key] = value + } + + func integer(forKey key: String) async -> Int { + storage[key] as? Int ?? 0 + } + + func set(_ value: Int, forKey key: String) async { + storage[key] = value + } + + func bool(forKey key: String) async -> Bool { + storage[key] as? Bool ?? false + } + + func set(_ value: Bool, forKey key: String) async { + storage[key] = value + } + + func stringArray(forKey key: String) async -> [String]? { + storage[key] as? [String] + } + + func setStringArray(_ value: [String], forKey key: String) async { + storage[key] = value + } +} + +struct NoOpLogger: Logging { + func log(_ level: LogLevel, _ message: @autoclosure () -> String) {} +} + +actor MockEventSender: EventSending { + func send(_ input: EventInput) async {} +} + +actor CapturingEventSender: EventSending { + var sentEvents: [EventInput] = [] + func send(_ input: EventInput) async { + sentEvents.append(input) + } +} diff --git a/Tests/TelemetryDeckTests/TimezoneFormattingTests.swift b/Tests/TelemetryDeckTests/TimezoneFormattingTests.swift new file mode 100644 index 0000000..cf50dfe --- /dev/null +++ b/Tests/TelemetryDeckTests/TimezoneFormattingTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct TimezoneFormattingTests { + @Test + func utcPlusZero() { + let timeZone = TimeZone(secondsFromGMT: 0)! + let result = TimezoneFormatting.utcOffsetString(from: timeZone) + #expect(result == "UTC+0") + } + + @Test + func utcPlusFiveThirty() { + let timeZone = TimeZone(secondsFromGMT: 19800)! + let result = TimezoneFormatting.utcOffsetString(from: timeZone) + #expect(result == "UTC+5:30") + } + + @Test + func utcMinusFive() { + let timeZone = TimeZone(secondsFromGMT: -18000)! + let result = TimezoneFormatting.utcOffsetString(from: timeZone) + #expect(result == "UTC-5") + } + + @Test + func utcMinusNineThirty() { + let timeZone = TimeZone(secondsFromGMT: -34200)! + let result = TimezoneFormatting.utcOffsetString(from: timeZone) + #expect(result == "UTC-9:30") + } + + @Test + func utcPlusTwelve() { + let timeZone = TimeZone(secondsFromGMT: 43200)! + let result = TimezoneFormatting.utcOffsetString(from: timeZone) + #expect(result == "UTC+12") + } + + @Test + func utcPlusFiveFortyFive() { + let timeZone = TimeZone(secondsFromGMT: 20700)! + let result = TimezoneFormatting.utcOffsetString(from: timeZone) + #expect(result == "UTC+5:45") + } + + @Test + func utcMinusThreeThirty() { + let timeZone = TimeZone(secondsFromGMT: -12600)! + let result = TimezoneFormatting.utcOffsetString(from: timeZone) + #expect(result == "UTC-3:30") + } +} diff --git a/Tests/TelemetryDeckTests/UserIdentifierProcessorTests.swift b/Tests/TelemetryDeckTests/UserIdentifierProcessorTests.swift new file mode 100644 index 0000000..c9d1baf --- /dev/null +++ b/Tests/TelemetryDeckTests/UserIdentifierProcessorTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct UserIdentifierProcessorTests { + @Test + func customUserIDFromInputTakesPrecedence() async throws { + let processor = UserIdentifierProcessor() + let storage = InMemoryProcessorStorage() + let configuration = TelemetryDeck.Config(appID: "test-app-id", namespace: "test") + + await processor.start(storage: storage, logger: NoOpLogger(), emitter: MockEventSender()) + await processor.setUserIdentifier("explicit") + + let capturer = ContextCapturingProcessor() + let pipeline = ProcessorPipeline( + processors: [processor, capturer], + finalizer: EventFinalizer(configuration: configuration) + ) + + let input = EventInput("test.event", customUserID: "custom") + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + let capturedIdentifier = await capturer.capturedUserIdentifier + #expect(capturedIdentifier == "custom") + } + + @Test + func explicitUserIDUsedWhenNoCustomID() async throws { + let processor = UserIdentifierProcessor() + let storage = InMemoryProcessorStorage() + let configuration = TelemetryDeck.Config(appID: "test-app-id", namespace: "test") + + await processor.start(storage: storage, logger: NoOpLogger(), emitter: MockEventSender()) + await processor.setUserIdentifier("explicit") + + let capturer = ContextCapturingProcessor() + let pipeline = ProcessorPipeline( + processors: [processor, capturer], + finalizer: EventFinalizer(configuration: configuration) + ) + + let input = EventInput("test.event") + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + let capturedIdentifier = await capturer.capturedUserIdentifier + #expect(capturedIdentifier == "explicit") + } + + @Test + func defaultUserIDUsedWhenNoExplicitOrCustom() async throws { + let processor = UserIdentifierProcessor() + let storage = InMemoryProcessorStorage() + let configuration = TelemetryDeck.Config(appID: "test-app-id", namespace: "test") + + await processor.start(storage: storage, logger: NoOpLogger(), emitter: MockEventSender()) + + let capturer = ContextCapturingProcessor() + let pipeline = ProcessorPipeline( + processors: [processor, capturer], + finalizer: EventFinalizer(configuration: configuration) + ) + + let input = EventInput("test.event") + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + let capturedIdentifier = await capturer.capturedUserIdentifier + #expect(capturedIdentifier != nil) + #expect(capturedIdentifier != "") + } + + @Test + func setUserIdentifierToNilReverts() async throws { + let processor = UserIdentifierProcessor() + let storage = InMemoryProcessorStorage() + let configuration = TelemetryDeck.Config(appID: "test-app-id", namespace: "test") + + await processor.start(storage: storage, logger: NoOpLogger(), emitter: MockEventSender()) + await processor.setUserIdentifier("explicit") + await processor.setUserIdentifier(nil) + + let capturer = ContextCapturingProcessor() + let pipeline = ProcessorPipeline( + processors: [processor, capturer], + finalizer: EventFinalizer(configuration: configuration) + ) + + let input = EventInput("test.event") + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + let capturedIdentifier = await capturer.capturedUserIdentifier + #expect(capturedIdentifier != "explicit") + #expect(capturedIdentifier != nil) + } + + @Test + func currentUserIdentifierReflectsState() async throws { + let processor = UserIdentifierProcessor() + let storage = InMemoryProcessorStorage() + let configuration = TelemetryDeck.Config(appID: "test-app-id", namespace: "test") + + await processor.start(storage: storage, logger: NoOpLogger(), emitter: MockEventSender()) + + let defaultID = await processor.currentUserIdentifier() + #expect(defaultID != nil) + + await processor.setUserIdentifier("explicit") + let explicitID = await processor.currentUserIdentifier() + #expect(explicitID == "explicit") + + await processor.setUserIdentifier(nil) + let revertedID = await processor.currentUserIdentifier() + #expect(revertedID == defaultID) + } +} + +private actor ContextCapturingProcessor: EventProcessor { + private(set) var capturedUserIdentifier: String? + + func process( + _ input: EventInput, + context: EventContext, + next: @Sendable (EventInput, EventContext) async throws -> Event + ) async throws -> Event { + capturedUserIdentifier = context.userIdentifier + return try await next(input, context) + } +} diff --git a/Tests/TelemetryDeckTests/V2DataMigrationTests.swift b/Tests/TelemetryDeckTests/V2DataMigrationTests.swift new file mode 100644 index 0000000..a9016e8 --- /dev/null +++ b/Tests/TelemetryDeckTests/V2DataMigrationTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +@Suite +struct V2DataMigrationTests { + private let testConfig = TelemetryDeck.Config(appID: "test-app", namespace: "test-ns") + + @Test + func recentSessionsWithEpochTimestampsAreMigratedToReferenceDate() async throws { + let storage = InMemoryProcessorStorage() + + let epochTimestamp = Date().addingTimeInterval(-200).timeIntervalSince1970 + let v2JSON = "[{\"st\":\(epochTimestamp),\"dn\":42}]".data(using: .utf8)! + await storage.set(v2JSON, forKey: "recentSessions") + + await V2DataMigrator.migrateIfNeeded(storage: storage) + + let migratedData = await storage.data(forKey: "recentSessions") + #expect(migratedData != nil) + + let migratedDate = decodedSessionDate(from: migratedData!) + #expect(migratedDate != nil) + let expectedDate = Date(timeIntervalSince1970: epochTimestamp) + #expect(abs(migratedDate!.timeIntervalSince(expectedDate)) < 1) + } + + @Test + func distinctDaysUsedPlistArrayIsMigratedToJSONSet() async throws { + let storage = InMemoryProcessorStorage() + + await storage.setStringArray(["2025-01-01", "2025-01-02", "2025-01-03"], forKey: "distinctDaysUsed") + await storage.set("[{\"st\":\(Date().timeIntervalSince1970),\"dn\":0}]".data(using: .utf8)!, forKey: "recentSessions") + + await V2DataMigrator.migrateIfNeeded(storage: storage) + + let migratedData = await storage.data(forKey: "distinctDaysUsed") + #expect(migratedData != nil) + + let migratedDays = try JSONDecoder().decode(Set.self, from: migratedData!) + #expect(migratedDays == ["2025-01-01", "2025-01-02", "2025-01-03"]) + } + + @Test + func installIDIsSetAfterMigration() async throws { + let storage = InMemoryProcessorStorage() + + let epochTimestamp = Date().addingTimeInterval(-100).timeIntervalSince1970 + await storage.set("[{\"st\":\(epochTimestamp),\"dn\":10}]".data(using: .utf8)!, forKey: "recentSessions") + + #expect(await storage.string(forKey: "installID") == nil) + + await V2DataMigrator.migrateIfNeeded(storage: storage) + + let installID = await storage.string(forKey: "installID") + #expect(installID != nil) + #expect(UUID(uuidString: installID!) != nil) + } + + @Test + func migrationIsIdempotent() async throws { + let storage = InMemoryProcessorStorage() + + let epochTimestamp = Date().addingTimeInterval(-100).timeIntervalSince1970 + await storage.set("[{\"st\":\(epochTimestamp),\"dn\":10}]".data(using: .utf8)!, forKey: "recentSessions") + + await V2DataMigrator.migrateIfNeeded(storage: storage) + let installIDAfterFirst = await storage.string(forKey: "installID") + let dataAfterFirst = await storage.data(forKey: "recentSessions") + + await V2DataMigrator.migrateIfNeeded(storage: storage) + let installIDAfterSecond = await storage.string(forKey: "installID") + let dataAfterSecond = await storage.data(forKey: "recentSessions") + + #expect(installIDAfterFirst == installIDAfterSecond) + #expect(dataAfterFirst == dataAfterSecond) + } + + @Test + func freshInstallWithNoV2DataIsUnaffected() async throws { + let storage = InMemoryProcessorStorage() + + await V2DataMigrator.migrateIfNeeded(storage: storage) + + #expect(await storage.string(forKey: "installID") == nil) + #expect(await storage.data(forKey: "recentSessions") == nil) + } + + @Test + func sessionTrackingProcessorWithV2DataDoesNotFireNewInstallDetected() async throws { + let storage = InMemoryProcessorStorage() + + let epochTimestamp = Date().addingTimeInterval(-100).timeIntervalSince1970 + await storage.set("[{\"st\":\(epochTimestamp),\"dn\":30}]".data(using: .utf8)!, forKey: "recentSessions") + await storage.setStringArray(["2025-03-01"], forKey: "distinctDaysUsed") + await storage.set("2025-03-01", forKey: "firstSessionDate") + + let processor = SessionTrackingProcessor() + let emitter = CapturingEventSender() + await processor.start(storage: storage, logger: NoOpLogger(), emitter: emitter) + + let sentEvents = await emitter.sentEvents + let eventNames = sentEvents.map(\.name) + #expect(!eventNames.contains(DefaultEvents.Acquisition.newInstallDetected.rawValue)) + + await processor.stop() + } + + private func decodedSessionDate(from data: Data) -> Date? { + struct Session: Decodable { + let startedAt: Date + private enum CodingKeys: String, CodingKey { case startedAt = "st" } + } + return (try? JSONDecoder().decode([Session].self, from: data))?.first?.startedAt + } +} diff --git a/Tests/TelemetryDeckTests/ValidationProcessorTests.swift b/Tests/TelemetryDeckTests/ValidationProcessorTests.swift new file mode 100644 index 0000000..65505bf --- /dev/null +++ b/Tests/TelemetryDeckTests/ValidationProcessorTests.swift @@ -0,0 +1,236 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +private final class Locked: @unchecked Sendable { + private var value: T + private let lock = NSLock() + + init(_ value: T) { + self.value = value + } + + func withLock(_ body: (inout T) -> R) -> R { + lock.lock() + defer { lock.unlock() } + return body(&value) + } +} + +final class SpyLogger: Logging, @unchecked Sendable { + private let logCalls = Locked<[(level: LogLevel, message: String)]>([]) + + func log(_ level: LogLevel, _ message: @autoclosure () -> String) { + logCalls.withLock { $0.append((level, message())) } + } + + func errorMessages() -> [String] { + logCalls.withLock { calls in + calls.filter { $0.level == .error }.map { $0.message } + } + } + + func hasErrorContaining(_ substring: String) -> Bool { + errorMessages().contains { $0.contains(substring) } + } + + func clear() { + logCalls.withLock { $0.removeAll() } + } +} + +struct ValidationProcessorTests { + @Test + func signalWithTelemetryDeckPrefixLogsError() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let logger = SpyLogger() + let processor = ValidationProcessor() + await processor.start(storage: InMemoryProcessorStorage(), logger: logger, emitter: MockEventSender()) + + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("TelemetryDeck.Custom.thing") + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + let hasError = logger.hasErrorContaining("TelemetryDeck.Custom.thing") + #expect(hasError) + + let hasReservedPrefix = logger.hasErrorContaining("reserved prefix") + #expect(hasReservedPrefix) + } + + @Test + func signalWithReservedNameLogsError() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let logger = SpyLogger() + let processor = ValidationProcessor() + await processor.start(storage: InMemoryProcessorStorage(), logger: logger, emitter: MockEventSender()) + + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("platform") + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + let hasError = logger.hasErrorContaining("platform") + #expect(hasError) + + let hasReservedName = logger.hasErrorContaining("reserved name") + #expect(hasReservedName) + } + + @Test + func parameterWithTelemetryDeckPrefixLogsError() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let logger = SpyLogger() + let processor = ValidationProcessor() + await processor.start(storage: InMemoryProcessorStorage(), logger: logger, emitter: MockEventSender()) + + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("MyApp.event", parameters: ["TelemetryDeck.foo": "bar"]) + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + let hasError = logger.hasErrorContaining("TelemetryDeck.foo") + #expect(hasError) + + let hasReservedPrefix = logger.hasErrorContaining("reserved prefix") + #expect(hasReservedPrefix) + } + + @Test + func parameterWithReservedNameLogsError() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let logger = SpyLogger() + let processor = ValidationProcessor() + await processor.start(storage: InMemoryProcessorStorage(), logger: logger, emitter: MockEventSender()) + + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("MyApp.event", parameters: ["appVersion": "1.0.0"]) + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + let hasError = logger.hasErrorContaining("appVersion") + #expect(hasError) + + let hasReservedName = logger.hasErrorContaining("reserved name") + #expect(hasReservedName) + } + + @Test + func validSignalAndParametersPassWithoutLogging() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let logger = SpyLogger() + let processor = ValidationProcessor() + await processor.start(storage: InMemoryProcessorStorage(), logger: logger, emitter: MockEventSender()) + + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("MyApp.event", parameters: ["myKey": "myValue"]) + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + let errorMessages = logger.errorMessages() + #expect(errorMessages.isEmpty) + } + + @Test + func skipsReservedPrefixValidationForEventName() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let logger = SpyLogger() + let processor = ValidationProcessor() + await processor.start(storage: InMemoryProcessorStorage(), logger: logger, emitter: MockEventSender()) + + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("TelemetryDeck.Session.started", skipsReservedPrefixValidation: true) + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + #expect(logger.errorMessages().isEmpty) + } + + @Test + func skipsReservedPrefixValidationForParameterKey() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let logger = SpyLogger() + let processor = ValidationProcessor() + await processor.start(storage: InMemoryProcessorStorage(), logger: logger, emitter: MockEventSender()) + + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("MyApp.event", parameters: ["TelemetryDeck.Activation.featureName": "photos"], skipsReservedPrefixValidation: true) + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + #expect(logger.errorMessages().isEmpty) + } + + @Test + func reservedKeyValidationStillActiveWhenSkippingPrefix() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let logger = SpyLogger() + let processor = ValidationProcessor() + await processor.start(storage: InMemoryProcessorStorage(), logger: logger, emitter: MockEventSender()) + + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("platform", parameters: ["appVersion": "1.0"], skipsReservedPrefixValidation: true) + let context = EventContext() + _ = try await pipeline.process(input, context: context) + + #expect(logger.errorMessages().count == 2) + } + + @Test + func validationDoesNotFilterSignal() async throws { + let config = TelemetryDeck.Config(appID: "test-app", namespace: "test") + let logger = SpyLogger() + let processor = ValidationProcessor() + await processor.start(storage: InMemoryProcessorStorage(), logger: logger, emitter: MockEventSender()) + + let pipeline = ProcessorPipeline( + processors: [processor], + finalizer: EventFinalizer(configuration: config) + ) + + let input = EventInput("TelemetryDeck.Invalid", parameters: ["TelemetryDeck.bad": "value", "platform": "iOS"]) + let context = EventContext() + let signal = try await pipeline.process(input, context: context) + + #expect(signal.type == "TelemetryDeck.Invalid") + #expect(signal.payload["TelemetryDeck.bad"] == "value") + #expect(signal.payload["platform"] == "iOS") + + let errorMessages = logger.errorMessages() + #expect(errorMessages.count == 3) + } +} diff --git a/tag-release.sh b/tag-release.sh index 2e16e6b..70b8298 100755 --- a/tag-release.sh +++ b/tag-release.sh @@ -11,11 +11,11 @@ fi version=$1 -# Replace version String in TelemetryClient.swift with specified version -sed -i '' "s/let sdkVersion = \".*\"/let sdkVersion = \"$version\"/" Sources/TelemetryDeck/TelemetryClient.swift +# Replace version String in AppInfoProcessor.swift with specified version +sed -i '' "s/let sdkVersion = \".*\"/let sdkVersion = \"$version\"/" Sources/TelemetryDeck/Processors/AppInfoProcessor.swift # Make a commit & tag it -git add Sources/TelemetryDeck/TelemetryClient.swift +git add Sources/TelemetryDeck/Processors/AppInfoProcessor.swift git commit -m "Bump Version to $version" git tag $version $(git rev-parse HEAD)