Viewing is open to everyone. Downloading and starring need a GitHub sign-in.
Sign in with GitHub
CKeyTelemetryRuntime.swift
v0.1.0-build.85
Latest
Commit ede38f5
Sign in with GitHub to download. Each download is recorded with your GitHub username.
| 1 | import CKeyCodec |
| 2 | import CKeyScanKit |
| 3 | import CKeyTelemetry |
| 4 | import Foundation |
| 5 | #if canImport(UIKit) &&os(watchOS) |
| 6 | import UIKit |
| 7 | #elseif os(macOS) |
| 8 | import AppKit |
| 9 | #endif |
| 10 | |
| 11 | /// Monotonic milliseconds (includes time asleep), the one clock every |
| 12 | /// telemetry call uses. |
| 13 | func ckeyMonotonicMilliseconds() -> UInt64 { |
| 14 | clock_gettime_nsec_np(CLOCK_MONOTONIC) / 1_000_000 |
| 15 | } |
| 16 | |
| 17 | /// Records scan sessions in the telemetry core. The recording calls are a |
| 18 | /// few counter updates under the core's mutex: safe on the scan queue. |
| 19 | /// |
| 20 | /// `CKeyScanner` uses this automatically. A host with its own camera |
| 21 | /// pipeline (calling `CKeyDecoder` directly) can report its sessions here |
| 22 | /// so they are counted the same way: |
| 23 | /// |
| 24 | /// CKeyTelemetrySession.shared.scanSessionDidBegin() // camera view appeared |
| 25 | /// CKeyTelemetrySession.shared.scanFrameProcessed(milliseconds: ms) |
| 26 | /// CKeyTelemetrySession.shared.scanSessionDidDecode(result) |
| 27 | /// CKeyTelemetrySession.shared.scanSessionDidEnd() // camera view went away |
| 28 | /// |
| 29 | /// In mode 0 every call is a no-op in the core. |
| 30 | public final class CKeyTelemetrySession: CKeyScanObserver { |
| 31 | public static let shared = CKeyTelemetrySession() |
| 32 | |
| 33 | private init() {} |
| 34 | |
| 35 | public func scanSessionDidBegin() { |
| 36 | CKeyTelemetryRuntime.shared.start() |
| 37 | ckey_telemetry_session_begin(ckeyMonotonicMilliseconds()) |
| 38 | } |
| 39 | |
| 40 | public func scanFrameProcessed(milliseconds: Double) { |
| 41 | let ms = milliseconds.isFinite ? UInt32(min(max(0, milliseconds.rounded()), Double(UInt32.max))) : 0 |
| 42 | ckey_telemetry_frame(ckeyMonotonicMilliseconds(), ms) |
| 43 | } |
| 44 | |
| 45 | public func scanSessionDidDecode(_ result: CKeyResult) { |
| 46 | _ = ckey_telemetry_session_success(ckeyMonotonicMilliseconds(), result.telemetryProfileCode) |
| 47 | } |
| 48 | |
| 49 | public func scanSessionDidEnd() { |
| 50 | ckey_telemetry_session_end(ckeyMonotonicMilliseconds()) |
| 51 | CKeyTelemetryRuntime.shared.sendIfDue() |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | /// The continuous camera scanner of the `CKey` product: `CKeyCameraScanner` |
| 56 | /// with telemetry sessions attached (a session begins at `start()` and |
| 57 | /// `scanAgain()`, succeeds at the first valid decode, and ends at `stop()`). |
| 58 | /// It follows `CKey.telemetry`; in mode 0 nothing is counted or sent. |
| 59 | /// |
| 60 | /// let scanner = CKeyScanner() |
| 61 | /// scanner.onResult = { result in show(result) } |
| 62 | /// scanner.start() |
| 63 | #if canImport(AVFoundation) &&os(iOS) || os(macOS) |
| 64 | public final class CKeyScanner: CKeyCameraScanner { |
| 65 | public init() { |
| 66 | super.init(observer: CKeyTelemetrySession.shared) |
| 67 | } |
| 68 | } |
| 69 | #endif |
| 70 | |
| 71 | /// Sends aggregate batches. Never runs on the scan queue and never blocks: |
| 72 | /// all work happens on a private utility queue and in URLSession callbacks. |
| 73 | final class CKeyTelemetryRuntime { |
| 74 | static let shared = CKeyTelemetryRuntime() |
| 75 | |
| 76 | private let queue = DispatchQueue(label: "org.openckey.telemetry", qos: .utility) |
| 77 | private var started = false |
| 78 | private var timer: DispatchSourceTimer? |
| 79 | private var observers: [NSObjectProtocol] = [] |
| 80 | private lazy var session: URLSession = { |
| 81 | let configuration = URLSessionConfiguration.ephemeral |
| 82 | configuration.httpCookieAcceptPolicy = .never |
| 83 | configuration.httpShouldSetCookies = false |
| 84 | configuration.urlCache = nil |
| 85 | configuration.requestCachePolicy = .reloadIgnoringLocalCacheData |
| 86 | configuration.timeoutIntervalForRequest = 30 |
| 87 | configuration.timeoutIntervalForResource = 60 |
| 88 | configuration.waitsForConnectivity = false |
| 89 | return URLSession(configuration: configuration) |
| 90 | }() |
| 91 | private let dayFormatter: ISO8601DateFormatter = { |
| 92 | let formatter = ISO8601DateFormatter() |
| 93 | formatter.formatOptions = [.withFullDate] |
| 94 | formatter.timeZone = TimeZone(identifier: "UTC") |
| 95 | return formatter |
| 96 | }() |
| 97 | |
| 98 | private init() {} |
| 99 | |
| 100 | /// Idempotent. Sets the client identity, starts a low-frequency check |
| 101 | /// and listens for the app moving to the background. |
| 102 | func start() { |
| 103 | queue.async { [self] in |
| 104 | guard !started else { return } |
| 105 | started = true |
| 106 | #if os(iOS) |
| 107 | let platform = "ios" |
| 108 | #elseif os(macOS) |
| 109 | let platform = "macos" |
| 110 | #else |
| 111 | let platform = "other" |
| 112 | #endif |
| 113 | _ = ckey_telemetry_set_client("swift", CKeyCoreInfo.softwareVersion, platform) |
| 114 | |
| 115 | // should_send enforces the cadence (one batch per hour at most); |
| 116 | // this timer only gives it a chance to run. |
| 117 | let timer = DispatchSource.makeTimerSource(queue: queue) |
| 118 | timer.schedule(deadline: .now() + 120, repeating: 900, leeway: .seconds(120)) |
| 119 | timer.setEventHandler { [weak self] in self?.sendNow() } |
| 120 | timer.resume() |
| 121 | self.timer = timer |
| 122 | |
| 123 | #if canImport(UIKit) &&os(watchOS) |
| 124 | let background: Notification.Name? = UIApplication.didEnterBackgroundNotification |
| 125 | #elseif os(macOS) |
| 126 | let background: Notification.Name? = NSApplication.didResignActiveNotification |
| 127 | #else |
| 128 | let background: Notification.Name? = nil |
| 129 | #endif |
| 130 | if let background { |
| 131 | let token = NotificationCenter.default.addObserver(forName: background, object: nil, queue: nil) { [weak self] _ in |
| 132 | self?.sendInBackground() |
| 133 | } |
| 134 | observers.append(token) |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | /// Asynchronously tries a send if the core says one is due. |
| 140 | func sendIfDue() { |
| 141 | queue.async { [weak self] in self?.sendNow() } |
| 142 | } |
| 143 | |
| 144 | private func sendInBackground() { |
| 145 | #if os(iOS) |
| 146 | // A short expiring-activity assertion lets the request leave before |
| 147 | // suspension; it works in apps and app extensions alike. |
| 148 | ProcessInfo.processInfo.performExpiringActivity(withReason: "CKey aggregate telemetry") { [weak self] expired in |
| 149 | guard !expired, let self else { return } |
| 150 | let done = DispatchSemaphore(value: 0) |
| 151 | self.queue.async { self.sendNow { done.signal() } } |
| 152 | _ = done.wait(timeout: .now() + 25) |
| 153 | } |
| 154 | #else |
| 155 | sendIfDue() |
| 156 | #endif |
| 157 | } |
| 158 | |
| 159 | /// On `queue`. `completion` runs once the attempt (if any) has finished. |
| 160 | private func sendNow(completion: (() -> Void)? = nil) { |
| 161 | let now = ckeyMonotonicMilliseconds() |
| 162 | guard ckey_telemetry_should_send(now) != 0 else { completion?(); return } |
| 163 | let day = dayFormatter.string(from: Date()) |
| 164 | var json = [CChar](repeating: 0, count: Int(CKEY_TELEMETRY_MAX_BODY_BYTES) + 1) |
| 165 | var length = 0 |
| 166 | var batchID: UInt64 = 0 |
| 167 | let status = json.withUnsafeMutableBufferPointer { buffer in |
| 168 | ckey_telemetry_take_batch(day, buffer.baseAddress, buffer.count, &length, &batchID) |
| 169 | } |
| 170 | guard status == CKEY_OK, batchID != 0, length > 0, length < json.count, |
| 171 | let url = URL(string: CKEY_TELEMETRY_ENDPOINT) else { |
| 172 | if batchID != 0 { _ = ckey_telemetry_batch_result(batchID, 0, ckeyMonotonicMilliseconds()) } |
| 173 | completion?() |
| 174 | return |
| 175 | } |
| 176 | let body = Data(json[0..<length].map { UInt8(bitPattern: $0) }) |
| 177 | var request = URLRequest(url: url) |
| 178 | request.httpMethod = "POST" |
| 179 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 180 | request.httpBody = body |
| 181 | request.httpShouldHandleCookies = false |
| 182 | let task = session.dataTask(with: request) { [queue] _, response, _ in |
| 183 | // 0 = network error, timeout or no connectivity: the core keeps the batch. |
| 184 | let http = (response as? HTTPURLResponse)?.statusCode ?? 0 |
| 185 | queue.async { |
| 186 | _ = ckey_telemetry_batch_result(batchID, Int32(clamping: http), ckeyMonotonicMilliseconds()) |
| 187 | completion?() |
| 188 | } |
| 189 | } |
| 190 | task.resume() |
| 191 | } |
| 192 | } |