learning_ai_clock/ios/ChronoMind/Shared/Cloud/TelemetryService.swift
2026-02-28 19:26:52 -08:00

139 lines
4.5 KiB
Swift

// 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)
}
}
}