// ── Platform Telemetry Client ───────────────────────────────── // Sends events to platform-service /telemetry/events endpoint. // Privacy: no PII, only action names + timing metrics. // Fire-and-forget — errors never surface to the user. import Foundation @MainActor final class CMTelemetryService { static let shared = CMTelemetryService() private let productId = "chronomind" private let platform = "ios" private let channel = "native" private var queue: [TelemetryPayload] = [] private var flushTimer: Timer? private let maxQueue = 50 private let flushIntervalSec: TimeInterval = 30 private var installId: String { let key = "chronomind-telemetry-install-id" if let id = UserDefaults.standard.string(forKey: key) { return id } let id = UUID().uuidString UserDefaults.standard.set(id, forKey: key) return id } private let sessionId = UUID().uuidString private var baseURL: String { Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String ?? "https://api.chronomind.app" } private struct TelemetryPayload: Codable { let id: String let productId: String let anonymousInstallId: String let sessionId: String let platform: String let channel: String let osFamily: String let osVersion: String let appVersion: String let buildNumber: String let releaseChannel: String let eventType: String let module: String let eventName: String let feature: String? let message: String? let tags: [String: String]? let metrics: [String: Double]? let occurredAt: String } private init() {} // MARK: - Public API func start() { guard flushTimer == nil else { return } flushTimer = Timer.scheduledTimer(withTimeInterval: flushIntervalSec, repeats: true) { [weak self] _ in guard let self else { return } Task { @MainActor in self.flush() } } } func stop() { flush() flushTimer?.invalidate() flushTimer = nil } func trackEvent( _ eventType: String, module: String, name: String, feature: String? = nil, message: String? = nil, tags: [String: String]? = nil, metrics: [String: Double]? = nil ) { let event = TelemetryPayload( id: UUID().uuidString, productId: productId, anonymousInstallId: installId, sessionId: sessionId, platform: platform, channel: channel, osFamily: "ios", osVersion: ProcessInfo.processInfo.operatingSystemVersionString, appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0", buildNumber: Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1", releaseChannel: "beta", eventType: eventType, module: module, eventName: name, feature: feature, message: message, tags: tags, metrics: metrics, occurredAt: ISO8601DateFormatter().string(from: Date()) ) queue.append(event) if queue.count >= maxQueue { flush() } } func trackTimer(_ name: String, tags: [String: String]? = nil, metrics: [String: Double]? = nil) { trackEvent("info", module: "timers", name: name, tags: tags, metrics: metrics) } func trackScreen(_ screen: String) { trackEvent("info", module: "navigation", name: "screen_view", tags: ["screen": screen]) } func flush() { guard !queue.isEmpty else { return } let batch = queue queue.removeAll() Task.detached { [baseURL, productId] in guard let url = URL(string: "\(baseURL)/telemetry/events") else { return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(productId, forHTTPHeaderField: "X-Product-Id") request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") request.httpBody = try? JSONEncoder().encode(["events": batch]) request.timeoutInterval = 10 _ = try? await URLSession.shared.data(for: request) } } }