learning_ai_common_plat/packages/swift-platform-sdk/Sources/BLTelemetryClient.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)
}
}