// GlassTracker.swift — Dbrij Glass analytics for native iOS apps. // // One file, zero dependencies (Foundation only). Drop it into your project: // // let glass = GlassTracker(siteKey: "gk_your_site_key", appVersion: "1.4.2") // glass.screen("Checkout") // glass.track("signup", props: ["plan": "pro"]) // glass.identify("user_1234", traits: ["plan": "pro"]) // glass.tap(xPct: 48, yPct: 88, label: "buy-button") // // On iOS the tracker observes app lifecycle automatically (flushes on background, // refreshes its config on foreground). Elsewhere call onForeground()/onBackground(). // // What it sends and nothing more: the screens and events YOU name, plus a random // first-party visitor id persisted in UserDefaults. No IDFA, no fingerprinting. // Session replay is not part of native tracking; sessions from this tracker appear // in the Glass dashboard without a recording. // // The wire contract mirrors @dbrij/glass-native (source of truth: the Dbrij Glass // collector, apps/api/src/modules/glass/ingest-parse.ts). import Foundation #if canImport(UIKit) import UIKit #endif public final class GlassTracker: @unchecked Sendable { // MARK: configuration private let siteKey: String private let endpoint: String private let device: String private let appVersion: String? private let defaults: UserDefaults private static let sessionTimeoutDefault: TimeInterval = 30 * 60 private static let maxSessionSeconds: TimeInterval = 4 * 60 * 60 private static let flushAt = 20 private static let flushSeconds: TimeInterval = 5 private static let maxPersistedEvents = 500 private static let version = "0.1.0" // MARK: state (only ever touched on `queue`) private let queue = DispatchQueue(label: "com.dbrij.glass") private var events: [[String: Any]] = [] private var visitorId = "" private var sessionId = "" private var sessionStarted: TimeInterval = 0 private var sessionLast: TimeInterval = 0 private var sessionTimeout = GlassTracker.sessionTimeoutDefault private var currentPath = "/" private var flushTimer: DispatchSourceTimer? private var disabled: Bool private var killed = false private var lastConfigAt: TimeInterval = 0 public init( siteKey: String, endpoint: String = "https://api.dbrij.com/api/v1", device: String = "mobile", appVersion: String? = nil, disabled: Bool = false, defaults: UserDefaults = .standard ) { self.siteKey = siteKey self.endpoint = endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint self.device = device == "tablet" ? "tablet" : "mobile" self.appVersion = appVersion.map { String($0.prefix(20)) } self.disabled = disabled || !siteKey.hasPrefix("gk_") self.defaults = defaults queue.async { [weak self] in self?.load() self?.refreshConfig() } #if canImport(UIKit) NotificationCenter.default.addObserver( self, selector: #selector(handleBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(handleForeground), name: UIApplication.willEnterForegroundNotification, object: nil) #endif } // MARK: public API (all safe to call from any thread; failures are silent) /// A screen became visible. The name becomes a page path (/checkout-payment). public func screen(_ name: String) { let path = GlassTracker.screenPath(name) queue.async { [weak self] in guard let self else { return } self.currentPath = path self.enqueue(["t": "pageview", "p": path, "ts": GlassTracker.nowMs()]) } } /// A business moment your code names; props become dashboard breakdowns. public func track(_ name: String, props: [String: Any]? = nil) { guard !name.isEmpty else { return } queue.async { [weak self] in guard let self else { return } var event: [String: Any] = ["t": "custom", "n": String(name.prefix(120)), "p": self.currentPath, "ts": GlassTracker.nowMs()] if let props, JSONSerialization.isValidJSONObject(props) { event["props"] = props } self.enqueue(event) } } /// Tie this device's sessions to your own user id. Dropped while disabled. public func identify(_ distinctId: String, traits: [String: Any]? = nil) { guard !distinctId.isEmpty else { return } queue.async { [weak self] in guard let self, !self.disabled else { return } var event: [String: Any] = ["t": "identify", "n": String(distinctId.prefix(200)), "p": self.currentPath, "ts": GlassTracker.nowMs()] if let traits, JSONSerialization.isValidJSONObject(traits) { event["props"] = traits } self.enqueue(event) } } /// A tap, as percentages of the current screen (0-100), for heatmaps. public func tap(xPct: Double, yPct: Double, label: String? = nil) { guard xPct.isFinite, yPct.isFinite else { return } queue.async { [weak self] in guard let self else { return } self.enqueue([ "t": "click", "p": self.currentPath, "ts": GlassTracker.nowMs(), "props": [ "selector": String((label ?? "tap").prefix(250)), "x": Int(min(100, max(0, xPct.rounded()))), "y": Int(min(100, max(0, yPct.rounded()))), "vw": 100, "vh": 100, ], ]) } } public func onForeground() { queue.async { [weak self] in guard let self else { return } _ = self.ensureSession() self.refreshConfig() } } public func onBackground() { queue.async { [weak self] in guard let self else { return } self.persistQueue() self.flushLocked() } } /// Send everything queued now. Completion fires on an arbitrary queue. public func flush(_ completion: (@Sendable () -> Void)? = nil) { queue.async { [weak self] in self?.flushLocked(completion: completion) } } /// Runtime privacy switch. Disabling clears the queue and stops all sending. public func setDisabled(_ next: Bool) { queue.async { [weak self] in guard let self else { return } self.disabled = next || !self.siteKey.hasPrefix("gk_") if self.disabled { self.events = [] self.defaults.removeObject(forKey: self.key("q")) } } } // MARK: lifecycle plumbing #if canImport(UIKit) @objc private func handleBackground() { onBackground() } @objc private func handleForeground() { onForeground() } #endif // MARK: internals — everything below runs on `queue` private func key(_ suffix: String) -> String { "glass:\(siteKey.prefix(12)):\(suffix)" } private static func nowMs() -> Int64 { Int64(Date().timeIntervalSince1970 * 1000) } private static func randomId(_ prefix: String) -> String { prefix + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(24) } /// 'Checkout / Payment' -> /checkout-payment public static func screenPath(_ name: String) -> String { var slug = "" var lastDash = true for scalar in name.lowercased().unicodeScalars { if CharacterSet.alphanumerics.contains(scalar) && scalar.isASCII { slug.unicodeScalars.append(scalar) lastDash = false } else if !lastDash { slug.append("-") lastDash = true } } while slug.hasSuffix("-") { slug.removeLast() } if slug.count > 120 { slug = String(slug.prefix(120)) } return "/" + (slug.isEmpty ? "screen" : slug) } private func load() { if let vid = defaults.string(forKey: "glass:vid"), vid.count >= 10 { visitorId = vid } else { visitorId = GlassTracker.randomId("gv_") defaults.set(visitorId, forKey: "glass:vid") } if let raw = defaults.dictionary(forKey: key("s")), let id = raw["id"] as? String, let started = raw["started"] as? TimeInterval, let last = raw["last"] as? TimeInterval { sessionId = id sessionStarted = started sessionLast = last } if let persisted = defaults.array(forKey: key("q")) as? [[String: Any]], !persisted.isEmpty { events.append(contentsOf: persisted.suffix(GlassTracker.maxPersistedEvents)) defaults.removeObject(forKey: key("q")) } } private func ensureSession() -> String { let now = Date().timeIntervalSince1970 if sessionId.isEmpty || now - sessionLast > sessionTimeout || now - sessionStarted > GlassTracker.maxSessionSeconds { sessionId = String(GlassTracker.randomId("gs_").prefix(40)) sessionStarted = now } sessionLast = now defaults.set(["id": sessionId, "started": sessionStarted, "last": sessionLast], forKey: key("s")) return sessionId } private func enqueue(_ event: [String: Any]) { guard !disabled, !killed else { return } events.append(event) _ = ensureSession() if events.count >= GlassTracker.flushAt { flushLocked() return } if flushTimer == nil { let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + GlassTracker.flushSeconds) timer.setEventHandler { [weak self] in self?.flushTimer = nil self?.flushLocked() } timer.resume() flushTimer = timer } } private func envelope(_ slice: [[String: Any]]) -> Data? { var body: [String: Any] = [ "k": siteKey, "v": visitorId, "s": ensureSession(), "url": currentPath, "pf": "ios", "dv": device, "gv": GlassTracker.version, "events": slice, ] if let appVersion { body["av"] = appVersion } return try? JSONSerialization.data(withJSONObject: body) } private func flushLocked(completion: (@Sendable () -> Void)? = nil) { guard !disabled, !killed, !events.isEmpty else { completion?() return } let slice = Array(events.prefix(GlassTracker.flushAt)) guard let data = envelope(slice), let url = URL(string: "\(endpoint)/public/glass/ingest") else { completion?() return } var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = data request.setValue("text/plain", forHTTPHeaderField: "Content-Type") let task = URLSession.shared.dataTask(with: request) { [weak self] _, response, error in guard let self else { return } self.queue.async { let ok = error == nil && ((response as? HTTPURLResponse)?.statusCode ?? 500) < 300 if ok { self.events.removeFirst(min(slice.count, self.events.count)) if !self.events.isEmpty { self.flushLocked(completion: completion) return } self.defaults.removeObject(forKey: self.key("q")) } else { // offline or refused: keep a bounded queue for the next attempt self.events = Array(self.events.suffix(GlassTracker.maxPersistedEvents)) self.persistQueue() } completion?() } } task.resume() } private func persistQueue() { if events.isEmpty { defaults.removeObject(forKey: key("q")) } else { defaults.set(Array(events.suffix(GlassTracker.maxPersistedEvents)), forKey: key("q")) } } private func refreshConfig() { let now = Date().timeIntervalSince1970 guard !disabled, now - lastConfigAt > 5 * 60 else { return } lastConfigAt = now guard let encoded = siteKey.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: "\(endpoint)/public/glass/config?k=\(encoded)") else { return } let task = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in guard let self, let data, let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } // the API wraps responses in { success, data }; accept both shapes let cfg = (parsed["data"] as? [String: Any]) ?? parsed guard let ok = cfg["ok"] as? Bool else { return } self.queue.async { self.killed = !ok if let timeoutMs = cfg["sessionTimeoutMs"] as? Double, timeoutMs > 60_000 { self.sessionTimeout = timeoutMs / 1000 } } } task.resume() } }