| 1 | import Foundation |
| 2 | import os |
| 3 | |
| 4 | // π€ Perf β the shared performance-instrumentation core for both native apps. |
| 5 | // Three layers, all local to the device: |
| 6 | // |
| 7 | // 1. os_signpost intervals around the seams that hurt (API calls, page |
| 8 | // decrypt, thread-open, socket connects). Always on: signposts cost |
| 9 | // nanoseconds, never leave the device, and make any build inspectable |
| 10 | // in Instruments (os_signpost track, subsystem = bundle id). |
| 11 | // 2. A slow-event ledger: a span that overruns its budget, a main-thread |
| 12 | // hang (HangWatchdog), or a noteworthy event (socket reconnect) appends |
| 13 | // one JSON line to Library/Logs/perf-events.jsonl β so a device |
| 14 | // accumulates its own "little cuts" record in the field, exportable |
| 15 | // from Settings as a single report. |
| 16 | // 3. MetricKit daily payloads + hang diagnostics (MetricKitCollector), |
| 17 | // filed beside the ledger. |
| 18 | // |
| 19 | // Recording (layers 2β3) is gated: Debug builds record by default; Release |
| 20 | // builds only when the Settings consent toggle is on. Nothing recorded ever |
| 21 | // includes message content, names, or keys β a span carries a fixed name, a |
| 22 | // duration, and at most a URL path or a row count. The apps call install() |
| 23 | // at launch; the extensions (NSE/share) never do, so for them this whole |
| 24 | // file is inert signpost emission. |
| 25 | public enum Perf { |
| 26 | /// The consent/preference key ("perfDiagnostics"): unset means the build |
| 27 | /// default below; the Settings toggle writes it on both platforms. |
| 28 | public static let consentKey = "perfDiagnostics" |
| 29 | |
| 30 | /// Debug daily-drivers record without being asked; Release waits for the |
| 31 | /// consent toggle β the deal Paul set for shipping APM at all. |
| 32 | public static var defaultEnabled: Bool { |
| 33 | #if DEBUG |
| 34 | return true |
| 35 | #else |
| 36 | return false |
| 37 | #endif |
| 38 | } |
| 39 | |
| 40 | public static var enabledPreference: Bool { |
| 41 | (UserDefaults.standard.object(forKey: consentKey) as? Bool) ?? defaultEnabled |
| 42 | } |
| 43 | |
| 44 | // MARK: - Lifecycle (apps only) |
| 45 | |
| 46 | private static let lock = NSLock() |
| 47 | private static var installed = false |
| 48 | private static var recording = false |
| 49 | private static var installedAt = DispatchTime.now() |
| 50 | private static var watchdog: HangWatchdog? |
| 51 | private static var hitches: HitchRecorder? |
| 52 | private static var metrics: MetricKitCollector? |
| 53 | |
| 54 | static var isRecording: Bool { |
| 55 | lock.lock(); defer { lock.unlock() } |
| 56 | return recording |
| 57 | } |
| 58 | |
| 59 | /// Call once from each app's launch hook (never from an extension). |
| 60 | public static func install() { |
| 61 | lock.lock() |
| 62 | let already = installed |
| 63 | installed = true |
| 64 | installedAt = .now() |
| 65 | lock.unlock() |
| 66 | guard !already else { return } |
| 67 | setRecording(enabledPreference) |
| 68 | } |
| 69 | |
| 70 | /// The Settings toggle's runtime half (the toggle itself persists the |
| 71 | /// preference). Starts/stops the watchdog + MetricKit subscription and |
| 72 | /// gates ledger writes; signposts stay on either way. Main thread only |
| 73 | /// (install() and the Settings toggles) β watchdog/metrics are unlocked. |
| 74 | public static func setRecording(_ on: Bool) { |
| 75 | lock.lock() |
| 76 | recording = on |
| 77 | lock.unlock() |
| 78 | if on { |
| 79 | if watchdog == nil { watchdog = HangWatchdog() } |
| 80 | watchdog?.start() |
| 81 | if hitches == nil { hitches = HitchRecorder() } |
| 82 | hitches?.start() |
| 83 | if metrics == nil { metrics = MetricKitCollector() } |
| 84 | metrics?.start() |
| 85 | } else { |
| 86 | watchdog?.stop() |
| 87 | hitches?.stop() |
| 88 | metrics?.stop() |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // MARK: - Signpost spans |
| 93 | |
| 94 | private static let subsystem = Bundle.main.bundleIdentifier ?? "com.elevenmessenger.messenger" |
| 95 | private static let signposter = OSSignposter(subsystem: subsystem, category: "perf") |
| 96 | // Points-of-interest events (hangs, first frame) show up in Time |
| 97 | // Profiler's track without opening the os_signpost instrument. |
| 98 | private static let poi = OSSignposter(logHandle: OSLog(subsystem: subsystem, category: .pointsOfInterest)) |
| 99 | |
| 100 | /// An open interval. end() closes the signpost and, when the elapsed time |
| 101 | /// beats the span's budget, files a "slow" ledger event. |
| 102 | public struct Span { |
| 103 | fileprivate let name: StaticString |
| 104 | fileprivate let slowMs: Double |
| 105 | fileprivate let start: DispatchTime |
| 106 | fileprivate let state: OSSignpostIntervalState |
| 107 | |
| 108 | /// `record: false` closes the signpost without a ledger event β for |
| 109 | /// spans that ended abnormally (a failed socket connect isn't a slow |
| 110 | /// one) where a duration would mislead. |
| 111 | public func end(_ detail: String? = nil, record: Bool = true) { |
| 112 | Perf.endSpan(self, detail: detail, record: record) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | public static func begin(_ name: StaticString, slowMs: Double = 500) -> Span { |
| 117 | let id = signposter.makeSignpostID() |
| 118 | let state = signposter.beginInterval(name, id: id) |
| 119 | return Span(name: name, slowMs: slowMs, start: .now(), state: state) |
| 120 | } |
| 121 | |
| 122 | private static func endSpan(_ span: Span, detail: String?, record: Bool) { |
| 123 | if let detail { |
| 124 | // Public, or trace exports show "<private>" (learned from the |
| 125 | // 2026-07-20 session). Details are paths/counts, never content, |
| 126 | // and signposts never leave the device. |
| 127 | signposter.endInterval(span.name, span.state, "\(detail, privacy: .public)") |
| 128 | } else { |
| 129 | signposter.endInterval(span.name, span.state) |
| 130 | } |
| 131 | guard record else { return } |
| 132 | let ms = Double(DispatchTime.now().uptimeNanoseconds &- span.start.uptimeNanoseconds) / 1e6 |
| 133 | if ms >= span.slowMs { |
| 134 | self.record(kind: "slow", name: "\(span.name)", ms: ms, detail: detail) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // MARK: - Ledger events |
| 139 | |
| 140 | // A same-name floor so a repeating condition (socket retrying every 3s on |
| 141 | // a dead network) can't flood the ledger: at most one event per name per |
| 142 | // 30s window. Slow spans skip the floor β each one is a distinct cut. |
| 143 | private static var eventFloor: [String: DispatchTime] = [:] |
| 144 | |
| 145 | /// A noteworthy moment with no duration (e.g. "socket.reconnect"). |
| 146 | public static func event(_ name: StaticString, detail: String? = nil) { |
| 147 | guard isRecording else { return } |
| 148 | let key = "\(name)" |
| 149 | lock.lock() |
| 150 | if let last = eventFloor[key], |
| 151 | DispatchTime.now().uptimeNanoseconds &- last.uptimeNanoseconds < 30_000_000_000 { |
| 152 | lock.unlock() |
| 153 | return |
| 154 | } |
| 155 | eventFloor[key] = .now() |
| 156 | lock.unlock() |
| 157 | poi.emitEvent(name) |
| 158 | record(kind: "note", name: key, ms: nil, detail: detail) |
| 159 | } |
| 160 | |
| 161 | /// The watchdog's report: main was wedged for ~ms. |
| 162 | static func recordHang(ms: Double) { |
| 163 | poi.emitEvent("hang") |
| 164 | record(kind: "hang", name: "main.hang", ms: ms, detail: nil) |
| 165 | } |
| 166 | |
| 167 | /// The hitch recorder's report: a rendered-frame gap of ~ms (iOS). |
| 168 | static func recordHitch(ms: Double) { |
| 169 | poi.emitEvent("hitch") |
| 170 | record(kind: "hitch", name: "frame.hitch", ms: ms, detail: nil) |
| 171 | } |
| 172 | |
| 173 | private static var firstFrameNoted = false |
| 174 | |
| 175 | /// Called by the app when its first real screen is up β the honest |
| 176 | /// "launch felt like this" number (since install(), not process start; |
| 177 | /// MetricKit's launch histograms cover the pre-main part). |
| 178 | public static func noteFirstFrame() { |
| 179 | lock.lock() |
| 180 | let already = firstFrameNoted |
| 181 | firstFrameNoted = true |
| 182 | let since = Double(DispatchTime.now().uptimeNanoseconds &- installedAt.uptimeNanoseconds) / 1e6 |
| 183 | lock.unlock() |
| 184 | guard !already else { return } |
| 185 | poi.emitEvent("firstFrame") |
| 186 | record(kind: "boot", name: "boot.firstFrame", ms: since, detail: nil) |
| 187 | } |
| 188 | |
| 189 | static func record(kind: String, name: String, ms: Double?, detail: String?) { |
| 190 | guard isRecording else { return } |
| 191 | let event = PerfEvent(t: Int64(Date().timeIntervalSince1970 * 1000), |
| 192 | kind: kind, name: name, |
| 193 | ms: ms.map { ($0 * 10).rounded() / 10 }, detail: detail) |
| 194 | PerfLedger.shared.append(event) |
| 195 | } |
| 196 | |
| 197 | // MARK: - Files & export |
| 198 | |
| 199 | static var logsDir: URL { |
| 200 | let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] |
| 201 | .appendingPathComponent("Logs", isDirectory: true) |
| 202 | try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| 203 | return dir |
| 204 | } |
| 205 | |
| 206 | static var eventsURL: URL { logsDir.appendingPathComponent("perf-events.jsonl") } |
| 207 | static var diagnosticsDir: URL { |
| 208 | let dir = logsDir.appendingPathComponent("perf-diagnostics", isDirectory: true) |
| 209 | try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| 210 | return dir |
| 211 | } |
| 212 | |
| 213 | /// Everything recorded so far, oldest first (current + rotated file). |
| 214 | public static func recordedEvents() -> [PerfEvent] { |
| 215 | let prev = (try? String(contentsOf: PerfLedger.rotatedURL, encoding: .utf8)) ?? "" |
| 216 | let cur = (try? String(contentsOf: eventsURL, encoding: .utf8)) ?? "" |
| 217 | return PerfEvent.parse(jsonl: prev + cur) |
| 218 | } |
| 219 | |
| 220 | /// Assemble the shareable report (device/OS/app + the ledger + the most |
| 221 | /// recent MetricKit payloads, size-capped) and return its file URL. |
| 222 | /// Blocking file IO β call off the main actor. |
| 223 | public static func assembleExport() -> URL? { |
| 224 | var diags: [String] = [] |
| 225 | if let names = try? FileManager.default.contentsOfDirectory(atPath: diagnosticsDir.path) { |
| 226 | // Filenames start with an ISO timestamp, so lexicographic = chronological. |
| 227 | for name in names.sorted().reversed() { |
| 228 | guard let data = try? Data(contentsOf: diagnosticsDir.appendingPathComponent(name)), |
| 229 | let json = String(data: data, encoding: .utf8) else { continue } |
| 230 | diags.append(json) |
| 231 | } |
| 232 | } |
| 233 | let info = Bundle.main.infoDictionary |
| 234 | let export = PerfExport( |
| 235 | app: Bundle.main.bundleIdentifier ?? "?", |
| 236 | version: "\(info?["CFBundleShortVersionString"] ?? "?") (\(info?["CFBundleVersion"] ?? "?"))", |
| 237 | os: ProcessInfo.processInfo.operatingSystemVersionString, |
| 238 | device: hardwareModel(), |
| 239 | generated: Int64(Date().timeIntervalSince1970 * 1000), |
| 240 | events: recordedEvents(), |
| 241 | metrickit: PerfExport.capDiagnostics(diags, maxBytes: 2_000_000)) |
| 242 | guard let data = try? JSONEncoder().encode(export) else { return nil } |
| 243 | let url = logsDir.appendingPathComponent("performance-report.json") |
| 244 | guard (try? data.write(to: url, options: .atomic)) != nil else { return nil } |
| 245 | return url |
| 246 | } |
| 247 | |
| 248 | /// Wipe the ledger + collected payloads (the Settings "clear" affordance). |
| 249 | public static func clearRecorded() { |
| 250 | PerfLedger.shared.clear() |
| 251 | let fm = FileManager.default |
| 252 | if let names = try? fm.contentsOfDirectory(atPath: diagnosticsDir.path) { |
| 253 | for name in names { try? fm.removeItem(at: diagnosticsDir.appendingPathComponent(name)) } |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | /// "hw.machine" (iPhone17,1 / Mac15,6) β a model id, not an identifier of |
| 258 | /// the person; it's what makes a hang report actionable across devices. |
| 259 | private static func hardwareModel() -> String { |
| 260 | var size = 0 |
| 261 | sysctlbyname("hw.machine", nil, &size, nil, 0) |
| 262 | guard size > 0 else { return "?" } |
| 263 | var chars = [CChar](repeating: 0, count: size) |
| 264 | sysctlbyname("hw.machine", &chars, &size, nil, 0) |
| 265 | return String(cString: chars) |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // MARK: - The on-disk ledger |
| 270 | |
| 271 | /// Serialized appends to perf-events.jsonl with ClickLab-style rotation: the |
| 272 | /// file caps at ~512KB and rotates once to .prev β the interesting window is |
| 273 | /// recent, and unbounded append only grows. |
| 274 | final class PerfLedger { |
| 275 | static let shared = PerfLedger() |
| 276 | static var rotatedURL: URL { Perf.eventsURL.deletingPathExtension().appendingPathExtension("prev.jsonl") } |
| 277 | |
| 278 | private let queue = DispatchQueue(label: "lchat.perf.ledger", qos: .utility) |
| 279 | private var handle: FileHandle? |
| 280 | private var bytes: Int = -1 // unknown until first append |
| 281 | |
| 282 | func append(_ event: PerfEvent) { |
| 283 | queue.async { [self] in |
| 284 | guard var data = try? JSONEncoder().encode(event) else { return } |
| 285 | data.append(0x0A) |
| 286 | let url = Perf.eventsURL |
| 287 | if bytes < 0 { |
| 288 | let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) |
| 289 | bytes = (attrs?[.size] as? Int) ?? 0 |
| 290 | } |
| 291 | if bytes > 512 * 1024 { |
| 292 | try? handle?.close() |
| 293 | handle = nil |
| 294 | try? FileManager.default.removeItem(at: Self.rotatedURL) |
| 295 | try? FileManager.default.moveItem(at: url, to: Self.rotatedURL) |
| 296 | bytes = 0 |
| 297 | } |
| 298 | if handle == nil { |
| 299 | if !FileManager.default.fileExists(atPath: url.path) { |
| 300 | FileManager.default.createFile(atPath: url.path, contents: nil) |
| 301 | } |
| 302 | handle = try? FileHandle(forWritingTo: url) |
| 303 | _ = try? handle?.seekToEnd() |
| 304 | } |
| 305 | try? handle?.write(contentsOf: data) |
| 306 | bytes += data.count |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | func clear() { |
| 311 | queue.async { [self] in |
| 312 | try? handle?.close() |
| 313 | handle = nil |
| 314 | bytes = 0 |
| 315 | try? FileManager.default.removeItem(at: Perf.eventsURL) |
| 316 | try? FileManager.default.removeItem(at: Self.rotatedURL) |
| 317 | } |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | // MARK: - Event & export shapes (pure; pinned by PerfTests) |
| 322 | |
| 323 | /// One ledger line. Kinds: "slow" (a span beat its budget), "hang" (main |
| 324 | /// thread wedged), "boot" (first frame), "note" (an event), "metric" / |
| 325 | /// "diag" (MetricKit arrivals). |
| 326 | public struct PerfEvent: Codable, Equatable, Sendable { |
| 327 | public var t: Int64 // epoch ms |
| 328 | public var kind: String |
| 329 | public var name: String |
| 330 | public var ms: Double? |
| 331 | public var detail: String? |
| 332 | |
| 333 | public init(t: Int64, kind: String, name: String, ms: Double? = nil, detail: String? = nil) { |
| 334 | self.t = t; self.kind = kind; self.name = name; self.ms = ms; self.detail = detail |
| 335 | } |
| 336 | |
| 337 | /// Lenient JSONL parse: a torn or garbage line (a crash mid-append) skips, |
| 338 | /// never sinks the export. |
| 339 | public static func parse(jsonl: String) -> [PerfEvent] { |
| 340 | jsonl.split(separator: "\n").compactMap { |
| 341 | try? JSONDecoder().decode(PerfEvent.self, from: Data($0.utf8)) |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | /// The Settings footer's one-liner: counts, pluralised, worst slow span |
| 346 | /// last. Nil when there's nothing to say. |
| 347 | public static func summary(_ events: [PerfEvent]) -> String? { |
| 348 | let hangs = events.filter { $0.kind == "hang" }.count |
| 349 | let hitches = events.filter { $0.kind == "hitch" }.count |
| 350 | let slows = events.filter { $0.kind == "slow" } |
| 351 | guard hangs > 0 || hitches > 0 || !slows.isEmpty else { return nil } |
| 352 | var parts: [String] = [] |
| 353 | if hangs > 0 { |
| 354 | parts.append("\(hangs.formatted()) \(hangs == 1 ? "hang" : "hangs")") |
| 355 | } |
| 356 | if hitches > 0 { |
| 357 | parts.append("\(hitches.formatted()) \(hitches == 1 ? "hitch" : "hitches")") |
| 358 | } |
| 359 | if !slows.isEmpty { |
| 360 | parts.append("\(slows.count.formatted()) slow \(slows.count == 1 ? "moment" : "moments")") |
| 361 | } |
| 362 | if let worst = slows.max(by: { ($0.ms ?? 0) < ($1.ms ?? 0) }), let ms = worst.ms { |
| 363 | parts.append("worst \(worst.name) \((ms / 1000).formatted(.number.precision(.fractionLength(1))))s") |
| 364 | } |
| 365 | return parts.joined(separator: " Β· ") |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | /// The shareable report β also the unit a future opt-in stream would POST to |
| 370 | /// a webhook-ingest feed, so its shape is deliberately self-contained. |
| 371 | public struct PerfExport: Codable { |
| 372 | public var app: String |
| 373 | public var version: String |
| 374 | public var os: String |
| 375 | public var device: String |
| 376 | public var generated: Int64 |
| 377 | public var events: [PerfEvent] |
| 378 | public var metrickit: [String] // raw MetricKit payload JSONs, newest first |
| 379 | |
| 380 | /// Keep the newest payloads under a byte budget β hang diagnostics carry |
| 381 | /// full call trees and a report must stay shareable, not gigantic. |
| 382 | public static func capDiagnostics(_ newestFirst: [String], maxBytes: Int) -> [String] { |
| 383 | var kept: [String] = [] |
| 384 | var total = 0 |
| 385 | for json in newestFirst { |
| 386 | let size = json.utf8.count |
| 387 | if total + size > maxBytes { break } |
| 388 | kept.append(json) |
| 389 | total += size |
| 390 | } |
| 391 | return kept |
| 392 | } |
| 393 | } |
| 394 | |