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

Size
7.7 KB
Lines
192
Language
swift
SHA-256
200264d5699b…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1import CKeyCodec
2import CKeyScanKit
3import CKeyTelemetry
4import Foundation
5#if canImport(UIKit) && !os(watchOS)
6import UIKit
7#elseif os(macOS)
8import AppKit
9#endif
10
11/// Monotonic milliseconds (includes time asleep), the one clock every
12/// telemetry call uses.
13func 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.
30public 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))
64public 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.
73final 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}