139 lines
4.5 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|