215 lines
6.8 KiB
Swift
215 lines
6.8 KiB
Swift
// ── 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..<min($0 + batchSize, events.count)])
|
|
}
|
|
|
|
for chunk in chunks {
|
|
sendBatch(chunk)
|
|
}
|
|
}
|
|
|
|
// MARK: - Accessors
|
|
|
|
public func getInstallId() -> 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)
|
|
}
|
|
}
|