// ── Telemetry Client ───────────────────────────────────────── // Generic telemetry event queue + batch flush to platform-service. // Matches the @bytelyst/telemetry-client TypeScript package interface. // Product apps configure with BLPlatformConfig; no hardcoded product IDs. import Foundation /// Telemetry event matching the platform-service /api/telemetry/events schema. public struct BLTelemetryEvent: Codable, Sendable { public let id: String public let productId: String public let anonymousInstallId: String public let sessionId: String public let platform: String public let channel: String public let osFamily: String public let osVersion: String public let appVersion: String public let buildNumber: String public let releaseChannel: String public let eventType: String public let module: String public let eventName: String public var feature: String? public var message: String? public var tags: [String: String]? public var metrics: [String: Double]? public let occurredAt: String } /// Generic telemetry client. Queues events in memory and flushes periodically. /// Thread-safe via NSLock. Fire-and-forget — errors never surface to the user. public final class BLTelemetryClient { private let config: BLPlatformConfig private let client: BLPlatformClient private var queue: [[String: Any]] = [] private let queueLock = NSLock() private var flushTimer: Timer? private let maxQueue: Int private let batchSize: Int private let flushIntervalSec: TimeInterval private let installId: String private var sessionId: String private let appVersion: String private let buildNumber: String private let releaseChannel: String private let osVersion: String /// Optional extra fields added to every event (e.g. deviceModel, locale, timezone). public var extraFields: [String: String] = [:] public init( config: BLPlatformConfig, client: BLPlatformClient, maxQueue: Int = 200, batchSize: Int = 50, flushIntervalSec: TimeInterval = 30, releaseChannel: String = "beta" ) { self.config = config self.client = client self.maxQueue = maxQueue self.batchSize = batchSize self.flushIntervalSec = flushIntervalSec self.releaseChannel = releaseChannel let bundle = Bundle.main appVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" buildNumber = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "0" osVersion = ProcessInfo.processInfo.operatingSystemVersionString // Install ID — persisted in UserDefaults (or App Group if configured) let storageKey = "\(config.productId)-telemetry-install-id" let defaults: UserDefaults if let groupId = config.appGroupId, let groupDefaults = UserDefaults(suiteName: groupId) { defaults = groupDefaults } else { defaults = .standard } if let existing = defaults.string(forKey: storageKey), !existing.isEmpty { installId = existing } else { let newId = UUID().uuidString defaults.set(newId, forKey: storageKey) installId = newId } sessionId = UUID().uuidString } // MARK: - Lifecycle /// Start the periodic flush timer. Call on app launch / foreground. public func start() { sessionId = UUID().uuidString guard flushTimer == nil else { return } flushTimer = Timer.scheduledTimer(withTimeInterval: flushIntervalSec, repeats: true) { [weak self] _ in self?.flush() } } /// Stop the flush timer and flush remaining events. Call on app background. public func stop() { flush() flushTimer?.invalidate() flushTimer = nil } // MARK: - Track /// Track a telemetry event. Thread-safe. public func trackEvent( _ eventType: String, module: String, name: String, feature: String? = nil, message: String? = nil, tags: [String: String]? = nil, metrics: [String: Double]? = nil, userId: String? = nil ) { var event: [String: Any] = [ "id": UUID().uuidString, "productId": config.productId, "anonymousInstallId": installId, "sessionId": sessionId, "platform": config.platform, "channel": config.channel, "osFamily": "ios", "osVersion": osVersion, "appVersion": appVersion, "buildNumber": buildNumber, "releaseChannel": releaseChannel, "eventType": eventType, "module": module, "eventName": name, "occurredAt": ISO8601DateFormatter().string(from: Date()), ] if let feature { event["feature"] = feature } if let message { event["message"] = String(message.prefix(512)) } if let tags { event["tags"] = tags } if let metrics { event["metrics"] = metrics } if let userId { event["userId"] = userId } for (key, value) in extraFields { event[key] = value } enqueue(event) } /// Convenience: track a screen view. public func trackScreen(_ screen: String) { trackEvent("info", module: "navigation", name: "screen_view", tags: ["screen": screen]) } // MARK: - Flush /// Flush all queued events to the server. Thread-safe. public func flush() { queueLock.lock() let events = queue queue.removeAll() queueLock.unlock() guard !events.isEmpty else { return } // Batch into chunks let chunks = stride(from: 0, to: events.count, by: batchSize).map { Array(events[$0.. String { installId } public func getSessionId() -> String { sessionId } // MARK: - Private private func enqueue(_ event: [String: Any]) { queueLock.lock() queue.append(event) if queue.count > maxQueue { queue.removeFirst(queue.count - maxQueue) } let count = queue.count queueLock.unlock() if count >= batchSize { flush() } } private func sendBatch(_ events: [[String: Any]]) { let body: [String: Any] = [ "productId": config.productId, "events": events, ] guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return } client.fireAndForget(path: "/api/telemetry/events", body: jsonData) } }