Add performance logging and background diagnostics

Performance Logging (Developer settings) logs a resource sample every 5s
in the foreground and background: CPU by area and thread, wakeups, GPU,
memory, disk, activity counters as rates (mpv property updates, player
time publishes, renders, saves, network, images, CloudKit events, log
entries, main run-loop wakeups), SwiftUI body evaluations of the player
views, and app/player/battery context. Each foreground/background
transition logs a summary of the period. MetricKit metrics and
diagnostics (CPU exceptions, hangs) are logged whenever logging is on.

Background diagnostics (iOS) log lifecycle, audio session and power
events with an app/player/render snapshot and a heartbeat that detects
suspension gaps and main-thread stalls. Logs are persisted per session
in Caches/Logs and the previous session is included in exports, so logs
survive the app being swiped away.
This commit is contained in:
Arkadiusz Fal
2026-10-24 19:36:51 +02:00
parent 21a6238a16
commit 9412ca916c
28 changed files with 1354 additions and 1 deletions

View File

@@ -14,6 +14,7 @@ struct ContentView: View {
#endif
var body: some View {
let _ = PerfCounter.body("ContentView")
Group {
if let appEnvironment {
#if os(iOS)

View File

@@ -134,6 +134,7 @@ final class DataManager {
/// Forces a save of any pending changes.
func save() {
PerfCounter.count("data.save")
do {
try modelContext.save()
} catch {

View File

@@ -11196,6 +11196,17 @@
}
}
},
"settings.advanced.debug.performanceLogging" : {
"comment" : "Toggle label for logging CPU, energy and activity every few seconds",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Performance Logging"
}
}
}
},
"settings.advanced.debug.playerAreaDebug" : {
"comment" : "Toggle label",
"localizations" : {

View File

@@ -1930,6 +1930,9 @@ final class CloudKitSyncEngine: @unchecked Sendable {
extension CloudKitSyncEngine: CKSyncEngineDelegate {
nonisolated func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
if PerfCounter.enabled {
PerfCounter.count("cloudkit.event." + Self.performanceName(of: event))
}
// Marks the whole callback tree, so anything that re-enters the engine
// from inside it says so in a debug build instead of trapping on
// someone's device. Inherited by every plain `Task { }` started below
@@ -1940,6 +1943,25 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
}
}
/// Event name for performance counters (the event's description includes its payload).
private nonisolated static func performanceName(of event: CKSyncEngine.Event) -> String {
switch event {
case .stateUpdate: "stateUpdate"
case .accountChange: "accountChange"
case .fetchedDatabaseChanges: "fetchedDatabaseChanges"
case .fetchedRecordZoneChanges: "fetchedRecordZoneChanges"
case .sentDatabaseChanges: "sentDatabaseChanges"
case .sentRecordZoneChanges: "sentRecordZoneChanges"
case .willFetchChanges: "willFetchChanges"
case .willFetchRecordZoneChanges: "willFetchRecordZoneChanges"
case .didFetchRecordZoneChanges: "didFetchRecordZoneChanges"
case .didFetchChanges: "didFetchChanges"
case .willSendChanges: "willSendChanges"
case .didSendChanges: "didSendChanges"
@unknown default: "other"
}
}
private nonisolated func handleDelegateEvent(_ event: CKSyncEngine.Event) async {
// Events that require async processing must be awaited directly (not in a fire-and-forget Task)
// so that CKSyncEngine waits for processing to complete before advancing the sync state token.

View File

@@ -127,4 +127,9 @@ private final class TokenStrippingPipelineDelegate: ImagePipelineDelegate {
guard let url = request.url else { return nil }
return ImageLoadingService.cacheKey(for: url)
}
func imageTask(_ task: ImageTask, didCompleteWithResult result: Result<ImageResponse, ImagePipeline.Error>, pipeline _: ImagePipeline) {
guard PerfCounter.enabled, case let .success(response) = result else { return }
PerfCounter.count(response.cacheType == nil ? "image.loadFromNetwork" : "image.loadFromCache")
}
}

View File

@@ -0,0 +1,390 @@
//
// BackgroundDiagnostics.swift
// Yattee
//
// Heartbeat and event logging to diagnose battery drain and heat while the
// app is in the background.
//
import Foundation
/// Render activity counters, reset each time they are read.
final class RenderDiagnosticsCounters: @unchecked Sendable {
struct Counts {
var linkFires = 0
var renders = 0
var presents = 0
var backgroundRenders = 0
var skippedInBackground = 0
}
private let lock = NSLock()
private var counts = Counts()
func increment(_ key: WritableKeyPath<Counts, Int>) {
lock.withLock { counts[keyPath: key] += 1 }
}
func take() -> Counts {
lock.withLock {
defer { counts = Counts() }
return counts
}
}
}
#if os(iOS)
import AVFoundation
import UIKit
/// Logs a process/app/player snapshot every 10 seconds while the app is not
/// active (every 30 seconds while active), plus lifecycle, audio and power
/// events, under the Diagnostics category.
///
/// Only does work while "Enable Logging" is on. Logs are persisted to disk by
/// `LogFileStore`, so they survive the app being swiped away.
///
/// The heartbeat timer runs on its own queue, independent of the main thread:
/// a "heartbeat gap" means the whole process was suspended, while a heartbeat
/// without its app snapshot means the main thread was blocked.
@MainActor
final class BackgroundDiagnostics {
static let shared = BackgroundDiagnostics()
private weak var playerService: PlayerService?
private var observers: [NSObjectProtocol] = []
private var runLoopObserver: CFRunLoopObserver?
private var runLoopWakeups = 0
private var started = false
private let sampler = ProcessSampler()
private init() {}
func start(playerService: PlayerService) {
guard !started else { return }
started = true
self.playerService = playerService
UIDevice.current.isBatteryMonitoringEnabled = true
sampler.setMainThreadPort(pthread_mach_thread_np(pthread_self()))
installRunLoopObserver()
observeEvents()
sampler.start()
log(.info, "Diagnostics started", details: appSnapshot())
}
// MARK: - Snapshot
/// App, device, player, audio and render state. Must run on the main thread.
func appSnapshot() -> String {
let app = UIApplication.shared
var lines: [String] = []
var appLine = "app: state=\(describe(app.applicationState))"
if app.applicationState == .background {
let remaining = app.backgroundTimeRemaining
appLine += " bgTimeRemaining=" + (remaining > 1e6 ? "unlimited" : "\(Int(remaining))s")
}
appLine += " protectedData=\(app.isProtectedDataAvailable ? "available" : "locked")"
appLine += " scenes=[\(app.connectedScenes.map { describe($0.activationState) }.joined(separator: ","))]"
appLine += " idleTimerDisabled=\(app.isIdleTimerDisabled)"
lines.append(appLine)
let device = UIDevice.current
let battery = device.batteryLevel < 0 ? "?" : "\(Int(device.batteryLevel * 100))%"
lines.append("device: thermal=\(describe(ProcessInfo.processInfo.thermalState)) battery=\(battery)"
+ " batteryState=\(describe(device.batteryState)) lowPower=\(ProcessInfo.processInfo.isLowPowerModeEnabled)")
lines.append("mainRunLoop: wakeups=\(runLoopWakeups) since last sample")
runLoopWakeups = 0
if let playerService {
let state = playerService.state
lines.append("player: state=\(describe(state.playbackState)) pip=\(state.pipState)"
+ " backend=\(playerService.currentBackendType) video=\(state.currentVideo?.id.videoID ?? "none")"
+ " time=\(Int(state.currentTime))/\(Int(state.duration))"
+ " publishesTime=\(state.publishesTimeUpdates)")
}
let session = AVAudioSession.sharedInstance()
let outputs = session.currentRoute.outputs.map { "\($0.portType.rawValue)(\($0.portName))" }
lines.append("audio: category=\(session.category.rawValue) mode=\(session.mode.rawValue)"
+ " otherAudioPlaying=\(session.isOtherAudioPlaying)"
+ " silenceSecondaryHint=\(session.secondaryAudioShouldBeSilencedHint)"
+ " route=[\(outputs.joined(separator: ","))]")
let renderViews = MPVRenderView.diagnosticsInstances
if renderViews.isEmpty {
lines.append("render: no MPV render views")
} else {
lines.append(contentsOf: renderViews.map { "render: " + $0.diagnosticsSummary() })
}
return lines.joined(separator: "\n")
}
/// Called by the sampler with the process-level part of a heartbeat.
nonisolated fileprivate func deliverHeartbeat(process: String, highCPU: Bool, enqueuedAt: DispatchTime) {
let latencyMs = (DispatchTime.now().uptimeNanoseconds - enqueuedAt.uptimeNanoseconds) / 1_000_000
MainActor.assumeIsolated {
let state = describe(UIApplication.shared.applicationState)
// Background playback legitimately costs CPU; flag only when nothing is playing.
let isPlaying = playerService?.state.playbackState == .playing
log(highCPU && !isPlaying ? .warning : .info, "Heartbeat [\(state)]",
details: process + "\nmainThread: latency=\(latencyMs)ms\n" + appSnapshot())
}
}
// MARK: - Events
private func installRunLoopObserver() {
let observer = CFRunLoopObserverCreateWithHandler(nil, CFRunLoopActivity.afterWaiting.rawValue, true, 0) { _, _ in
MainActor.assumeIsolated {
BackgroundDiagnostics.shared.runLoopWakeups += 1
}
}
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes)
runLoopObserver = observer
}
private func observeEvents() {
let center = NotificationCenter.default
let lifecycle: [(Notification.Name, String)] = [
(UIApplication.willResignActiveNotification, "App will resign active"),
(UIApplication.didEnterBackgroundNotification, "App did enter background"),
(UIApplication.willEnterForegroundNotification, "App will enter foreground"),
(UIApplication.didBecomeActiveNotification, "App did become active"),
(UIApplication.willTerminateNotification, "App will terminate"),
(UIApplication.didReceiveMemoryWarningNotification, "Memory warning"),
(UIApplication.protectedDataWillBecomeUnavailableNotification, "Device locking (protected data unavailable)"),
(UIApplication.protectedDataDidBecomeAvailableNotification, "Device unlocked (protected data available)"),
(ProcessInfo.thermalStateDidChangeNotification, "Thermal state changed"),
(Notification.Name.NSProcessInfoPowerStateDidChange, "Low Power Mode changed"),
(UIDevice.batteryStateDidChangeNotification, "Battery state changed"),
(UIDevice.batteryLevelDidChangeNotification, "Battery level changed"),
(AVAudioSession.mediaServicesWereResetNotification, "Audio media services were reset"),
(AVAudioSession.mediaServicesWereLostNotification, "Audio media services were lost"),
]
for (name, message) in lifecycle {
observers.append(center.addObserver(forName: name, object: nil, queue: .main) { _ in
MainActor.assumeIsolated {
BackgroundDiagnostics.shared.logEvent(message)
}
})
}
observers.append(center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { notification in
let info = notification.userInfo ?? [:]
let type = (info[AVAudioSessionInterruptionTypeKey] as? UInt).flatMap(AVAudioSession.InterruptionType.init)
let reason = (info[AVAudioSessionInterruptionReasonKey] as? UInt).flatMap(AVAudioSession.InterruptionReason.init)
let options = (info[AVAudioSessionInterruptionOptionKey] as? UInt).map(AVAudioSession.InterruptionOptions.init)
let details = "type=\(type == .began ? "began" : type == .ended ? "ended" : "?")"
+ " reason=\(reason.map { "\($0.rawValue)" } ?? "none")"
+ " shouldResume=\(options?.contains(.shouldResume) ?? false)"
MainActor.assumeIsolated {
BackgroundDiagnostics.shared.logEvent("Audio interruption", extra: details)
}
})
observers.append(center.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { notification in
let info = notification.userInfo ?? [:]
let reason = (info[AVAudioSessionRouteChangeReasonKey] as? UInt).flatMap(AVAudioSession.RouteChangeReason.init)
let previous = (info[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription)?
.outputs.map { $0.portType.rawValue }.joined(separator: ",") ?? "?"
let details = "reason=\(reason.map { "\($0.rawValue)" } ?? "?") previousRoute=[\(previous)]"
MainActor.assumeIsolated {
BackgroundDiagnostics.shared.logEvent("Audio route changed", extra: details)
}
})
observers.append(center.addObserver(forName: AVAudioSession.silenceSecondaryAudioHintNotification, object: nil, queue: .main) { notification in
let type = (notification.userInfo?[AVAudioSessionSilenceSecondaryAudioHintTypeKey] as? UInt)
.flatMap(AVAudioSession.SilenceSecondaryAudioHintType.init)
let details = "type=\(type == .begin ? "begin" : type == .end ? "end" : "?")"
MainActor.assumeIsolated {
BackgroundDiagnostics.shared.logEvent("Silence secondary audio hint", extra: details)
}
})
}
private func logEvent(_ message: String, extra: String? = nil) {
guard LoggingService.shared.isEnabled else { return }
var details = appSnapshot()
if let extra {
details = extra + "\n" + details
}
log(.info, message, details: details)
// A fresh process sample right at the transition, and the cadence follows the new state.
sampler.setAppActive(UIApplication.shared.applicationState == .active)
sampler.sampleSoon()
}
private func log(_ level: LogLevel, _ message: String, details: String) {
LoggingService.shared.log(level: level, category: .diagnostics, message: message, details: details)
}
// MARK: - Descriptions
private func describe(_ state: UIApplication.State) -> String {
switch state {
case .active: "active"
case .inactive: "inactive"
case .background: "background"
@unknown default: "unknown(\(state.rawValue))"
}
}
private func describe(_ state: UIScene.ActivationState) -> String {
switch state {
case .foregroundActive: "foregroundActive"
case .foregroundInactive: "foregroundInactive"
case .background: "background"
case .unattached: "unattached"
@unknown default: "unknown"
}
}
private func describe(_ state: ProcessInfo.ThermalState) -> String {
switch state {
case .nominal: "nominal"
case .fair: "fair"
case .serious: "serious"
case .critical: "critical"
@unknown default: "unknown"
}
}
private func describe(_ state: UIDevice.BatteryState) -> String {
switch state {
case .unknown: "unknown"
case .unplugged: "unplugged"
case .charging: "charging"
case .full: "full"
@unknown default: "unknown"
}
}
private func describe(_ state: PlaybackState) -> String {
if case .failed = state { return "failed" }
return "\(state)"
}
}
/// Samples process CPU, per-thread CPU, wakeups, GPU time and memory on its own
/// queue. All mutable state is confined to `queue`.
private final class ProcessSampler: @unchecked Sendable {
private static let interval: TimeInterval = 10
/// While the app is active, log only every Nth sample.
private static let activeLogEvery = 3
/// Process CPU above this while not active is logged as a warning.
private static let highCPUPercent = 15.0
private let queue = DispatchQueue(label: "stream.yattee.diagnostics", qos: .utility)
private var timer: DispatchSourceTimer?
private var isAppActive = true
private var tick = 0
private var lastTick: Date?
private var resourceSampler: ResourceSampler?
private var pendingSoon = false
func setMainThreadPort(_ port: mach_port_t) {
queue.async { self.resourceSampler = ResourceSampler(mainThreadPort: port) }
}
func setAppActive(_ active: Bool) {
queue.async { self.isAppActive = active }
}
func start() {
queue.async {
let timer = DispatchSource.makeTimerSource(queue: self.queue)
timer.schedule(deadline: .now() + Self.interval, repeating: Self.interval, leeway: .seconds(1))
timer.setEventHandler { self.fire(forced: false) }
timer.resume()
self.timer = timer
_ = self.sample() // baseline
}
}
/// Take an extra sample shortly after a lifecycle event.
func sampleSoon() {
queue.async {
guard !self.pendingSoon else { return }
self.pendingSoon = true
self.queue.asyncAfter(deadline: .now() + 1) {
self.pendingSoon = false
self.fire(forced: true)
}
}
}
private func fire(forced: Bool) {
guard UserDefaults.standard.bool(forKey: "loggingEnabled") else {
lastTick = nil
return
}
let now = Date()
if !forced, let lastTick {
let gap = now.timeIntervalSince(lastTick)
if gap > Self.interval * 2.5 {
LoggingService.shared.log(level: .warning, category: .diagnostics,
message: "Heartbeat gap: \(Int(gap))s since previous heartbeat",
details: "The process was suspended (or not scheduled) during the gap.")
}
}
if !forced {
lastTick = now
tick += 1
}
let process = sample()
guard forced || !isAppActive || tick % Self.activeLogEvery == 0 else { return }
let highCPU = !isAppActive && process.cpuPercent >= Self.highCPUPercent
let enqueuedAt = DispatchTime.now()
let responded = ResponseFlag()
DispatchQueue.main.async {
responded.set()
BackgroundDiagnostics.shared.deliverHeartbeat(process: process.text, highCPU: highCPU, enqueuedAt: enqueuedAt)
}
queue.asyncAfter(deadline: .now() + 3) {
guard !responded.isSet else { return }
LoggingService.shared.log(level: .warning, category: .diagnostics,
message: "Heartbeat: main thread did not respond within 3s", details: process.text)
}
}
// MARK: - Sampling
private struct ProcessSample {
let text: String
let cpuPercent: Double
}
private func sample() -> ProcessSample {
guard let resourceSampler else { return ProcessSample(text: "cpu: unavailable", cpuPercent: 0) }
let sample = resourceSampler.sample()
let footprint = sample.footprintMB.map { "\($0)MB" } ?? "?"
let text = String(format: "cpu: process=%.1f%% over %.0fs", sample.cpuPercent, sample.wall)
+ " threads=\(sample.threadCount) footprint=\(footprint)\n"
+ "hotThreads: \(sample.hotThreadsText)\n"
+ sample.powerText
return ProcessSample(text: text, cpuPercent: sample.cpuPercent)
}
}
/// Set from the main thread, read on the sampler queue.
private final class ResponseFlag: @unchecked Sendable {
private let lock = NSLock()
private var value = false
var isSet: Bool { lock.withLock { value } }
func set() {
lock.withLock { value = true }
}
}
#endif

View File

@@ -0,0 +1,94 @@
//
// LogFileStore.swift
// Yattee
//
// Persists log entries to disk so a session's logs survive the app being
// killed and can be exported on the next launch.
//
import Foundation
/// Appends log entries to a per-session file in Caches/Logs.
///
/// The first use in a process rotates the last session's file to
/// `previous-session.log`, so the export after a relaunch still contains the
/// session that was killed. All file access happens on a private serial queue.
final class LogFileStore: @unchecked Sendable {
static let shared = LogFileStore()
/// Stop writing a session once it reaches this size.
private static let maxSessionBytes = 25 * 1024 * 1024
private let queue = DispatchQueue(label: "stream.yattee.logfile", qos: .utility)
private let currentURL: URL
private let previousURL: URL
private var handle: FileHandle?
private var bytesWritten = 0
private var reachedLimit = false
private let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
return formatter
}()
private init() {
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Logs", isDirectory: true)
currentURL = directory.appendingPathComponent("current-session.log")
previousURL = directory.appendingPathComponent("previous-session.log")
queue.async { [self] in
let fileManager = FileManager.default
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
if fileManager.fileExists(atPath: currentURL.path) {
try? fileManager.removeItem(at: previousURL)
try? fileManager.moveItem(at: currentURL, to: previousURL)
}
fileManager.createFile(atPath: currentURL.path, contents: nil)
handle = try? FileHandle(forWritingTo: currentURL)
write("Session started: pid \(ProcessInfo.processInfo.processIdentifier), "
+ "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") "
+ "(\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))\n")
}
}
func append(_ entry: LogEntry) {
queue.async { [self] in
let details = entry.details.map { "\n Details: \($0)" } ?? ""
write("[\(formatter.string(from: entry.timestamp))] [\(entry.level.rawValue.uppercased())] "
+ "[\(entry.category.rawValue)] \(entry.message)\(details)\n")
}
}
/// The previous session's log, or nil when there is none.
func previousSessionText() -> String? {
queue.sync {
guard let data = try? Data(contentsOf: previousURL) else { return nil }
return String(decoding: data, as: UTF8.self)
}
}
func clear() {
queue.async { [self] in
try? FileManager.default.removeItem(at: previousURL)
try? handle?.truncate(atOffset: 0)
bytesWritten = 0
reachedLimit = false
}
}
/// Must be called on `queue`.
private func write(_ line: String) {
guard let handle, !reachedLimit else { return }
let data = Data(line.utf8)
if bytesWritten + data.count > Self.maxSessionBytes {
reachedLimit = true
try? handle.write(contentsOf: Data("--- log size limit reached, further entries not written ---\n".utf8))
return
}
try? handle.write(contentsOf: data)
bytesWritten += data.count
}
}

View File

@@ -39,6 +39,8 @@ enum LogCategory: String, Codable, CaseIterable, Sendable {
case imageLoading = "ImageLoading"
case mediaSources = "MediaSources"
case subscriptions = "Subscriptions"
case diagnostics = "Diagnostics"
case performance = "Performance"
case general = "General"
var icon: String {
@@ -55,6 +57,8 @@ enum LogCategory: String, Codable, CaseIterable, Sendable {
case .imageLoading: return "photo"
case .mediaSources: return "externaldrive.connected.to.line.below"
case .subscriptions: return "person.2"
case .diagnostics: return "stethoscope"
case .performance: return "gauge.with.dots.needle.67percent"
case .general: return "doc.text"
}
}
@@ -191,6 +195,7 @@ final class LoggingService: Sendable {
/// This method is nonisolated to allow safe logging from any thread.
/// OSLog output is synchronous; in-app storage is dispatched to MainActor.
nonisolated func log(level: LogLevel, category: LogCategory, message: String, details: String? = nil) {
PerfCounter.count("log.entry")
// Log to OSLog in DEBUG builds for Xcode console visibility
// OSLog/Logger is thread-safe, so this can be called from any thread.
// The master "Enable Logging" toggle gates this too - it takes
@@ -214,6 +219,10 @@ final class LoggingService: Sendable {
}
#endif
// Timestamp at the call site: the MainActor hop below can be late when
// the main thread is busy, which is exactly when timing matters.
let timestamp = Date()
// Dispatch in-app storage to MainActor asynchronously
// This ensures thread-safety for the entries array
Task { @MainActor [weak self] in
@@ -221,6 +230,7 @@ final class LoggingService: Sendable {
guard self.isEnabled else { return }
let entry = LogEntry(
timestamp: timestamp,
level: level,
category: category,
message: message,
@@ -228,6 +238,7 @@ final class LoggingService: Sendable {
)
self.entries.insert(entry, at: 0)
LogFileStore.shared.append(entry)
// Trim old entries
if self.entries.count > self.maxEntries {
@@ -241,9 +252,13 @@ final class LoggingService: Sendable {
/// Clear all log entries.
func clearLogs() {
entries.removeAll()
LogFileStore.shared.clear()
}
/// Export logs as text.
///
/// Includes the previous app session read back from disk, so logs survive
/// the app being killed (e.g. swiped away while the phone was hot).
func exportLogs() -> String {
let header = "Yattee Logs - Exported \(Date().formatted())\n"
let separator = String(repeating: "=", count: 60) + "\n"
@@ -253,7 +268,12 @@ final class LoggingService: Sendable {
return "[\(entry.formattedTimestamp)] [\(entry.level.rawValue.uppercased())] [\(entry.category.rawValue)] \(entry.message)\(details)"
}.joined(separator: "\n")
return header + separator + logLines
guard let previous = LogFileStore.shared.previousSessionText(), !previous.isEmpty else {
return header + separator + logLines
}
return header + separator
+ "PREVIOUS SESSION (read from disk)\n" + separator + previous + "\n"
+ separator + "CURRENT SESSION\n" + separator + logLines
}
/// Reset filters.

View File

@@ -0,0 +1,51 @@
//
// PerformanceCounters.swift
// Yattee
//
// Activity counters for performance logging. Each counter is reported as a
// rate by PerformanceMonitor, so a log shows what the app was busy doing.
//
import Foundation
import Synchronization
/// Named activity counters, safe to bump from any thread.
///
/// Counting is a no-op unless Performance Logging is on, so call sites can
/// stay in hot paths:
///
/// PerfCounter.count("mpv.prop.time-pos")
///
/// In a SwiftUI body, count evaluations with `let _ = PerfCounter.body("Name")`.
enum PerfCounter {
private static let isEnabled = Atomic<Bool>(false)
private static let counts = Mutex<[String: Int]>([:])
@inline(__always)
static var enabled: Bool { isEnabled.load(ordering: .relaxed) }
static func setEnabled(_ enabled: Bool) {
isEnabled.store(enabled, ordering: .relaxed)
}
static func count(_ key: String, by amount: Int = 1) {
guard enabled else { return }
counts.withLock { $0[key, default: 0] += amount }
}
/// Counts one body evaluation of the named view.
static func body(_ name: StaticString) {
guard enabled else { return }
let key = "view." + name.description
counts.withLock { $0[key, default: 0] += 1 }
}
/// Returns the counts accumulated since the last call and resets them.
static func take() -> [String: Int] {
counts.withLock {
defer { $0 = [:] }
return $0
}
}
}

View File

@@ -0,0 +1,415 @@
//
// PerformanceMonitor.swift
// Yattee
//
// Performance Logging: periodic resource usage with attribution, in the
// foreground and the background, under the Performance log category.
//
import Foundation
#if os(iOS) || os(tvOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
#if os(iOS) || os(macOS)
import MetricKit
#endif
/// Logs a resource sample every 5 seconds while Performance Logging is on:
/// CPU split by area and thread, wakeups, GPU, memory, disk, the
/// activity counters (`PerfCounter`) as rates, and app/player context.
/// Each foreground/background transition also logs a summary of the period
/// that just ended.
///
/// MetricKit reports (daily metrics, CPU exceptions, hangs) are logged
/// whenever logging is enabled, independent of the Performance toggle.
final class PerformanceMonitor: @unchecked Sendable {
static let shared = PerformanceMonitor()
static let enabledKey = "performanceLoggingEnabled"
private static let interval: TimeInterval = 5
/// Process CPU above these (percent of one core) logs the sample as a warning.
private static let highCPUForeground = 60.0
private static let highCPUBackground = 15.0
// Confined to `queue`.
private let queue = DispatchQueue(label: "stream.yattee.performance", qos: .utility)
private var sampler: ResourceSampler?
private var timer: DispatchSourceTimer?
private var period: Period?
private var context = ""
private var isBackground = false
// Main thread only.
@MainActor private weak var appEnvironment: AppEnvironment?
@MainActor private var runLoopObserver: CFRunLoopObserver?
@MainActor private var started = false
@MainActor private var batteryReference: (date: Date, level: Float)?
#if os(iOS) || os(macOS)
@MainActor private var metricSubscriber: MetricSubscriber?
#endif
private init() {}
var isEnabled: Bool {
UserDefaults.standard.bool(forKey: Self.enabledKey)
}
@MainActor
func start(appEnvironment: AppEnvironment) {
guard !started else { return }
started = true
self.appEnvironment = appEnvironment
observeLifecycle()
#if os(iOS) || os(macOS)
let subscriber = MetricSubscriber()
MXMetricManager.shared.add(subscriber)
metricSubscriber = subscriber
#endif
if isEnabled {
activate()
}
}
@MainActor
func setEnabled(_ enabled: Bool) {
guard enabled != isEnabled else { return }
UserDefaults.standard.set(enabled, forKey: Self.enabledKey)
if enabled {
activate()
} else {
deactivate()
}
}
// MARK: - Activation
@MainActor
private func activate() {
PerfCounter.setEnabled(true)
installRunLoopObserver()
#if os(iOS)
UIDevice.current.isBatteryMonitoringEnabled = true
#endif
let mainThreadPort = pthread_mach_thread_np(pthread_self())
let background = currentlyInBackground()
let context = makeContext()
queue.async {
self.isBackground = background
self.context = context
let sampler = ResourceSampler(mainThreadPort: mainThreadPort)
sampler.reset()
self.sampler = sampler
_ = PerfCounter.take()
self.period = Period(background: background)
let timer = DispatchSource.makeTimerSource(queue: self.queue)
timer.schedule(deadline: .now() + Self.interval, repeating: Self.interval, leeway: .milliseconds(500))
timer.setEventHandler { self.tick() }
timer.resume()
self.timer = timer
self.log(.info, "Performance logging started", details: "context: " + context)
}
}
@MainActor
private func deactivate() {
PerfCounter.setEnabled(false)
if let runLoopObserver {
CFRunLoopRemoveObserver(CFRunLoopGetMain(), runLoopObserver, .commonModes)
self.runLoopObserver = nil
}
queue.async {
self.timer?.cancel()
self.timer = nil
self.logPeriodSummary()
self.period = nil
self.sampler = nil
self.log(.info, "Performance logging stopped", details: nil)
}
}
@MainActor
private func installRunLoopObserver() {
guard runLoopObserver == nil else { return }
let observer = CFRunLoopObserverCreateWithHandler(nil, CFRunLoopActivity.afterWaiting.rawValue, true, 0) { _, _ in
PerfCounter.count("main.runLoopWakeup")
}
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes)
runLoopObserver = observer
}
// MARK: - Lifecycle
@MainActor
private func observeLifecycle() {
let center = NotificationCenter.default
#if os(iOS) || os(tvOS)
let transitions: [(Notification.Name, Bool)] = [
(UIApplication.didEnterBackgroundNotification, true),
(UIApplication.willEnterForegroundNotification, false),
]
#elseif os(macOS)
let transitions: [(Notification.Name, Bool)] = [
(NSApplication.didResignActiveNotification, true),
(NSApplication.didBecomeActiveNotification, false),
]
#endif
for (name, background) in transitions {
center.addObserver(forName: name, object: nil, queue: .main) { _ in
MainActor.assumeIsolated {
PerformanceMonitor.shared.stateChanged(background: background)
}
}
}
}
@MainActor
private func stateChanged(background: Bool) {
guard PerfCounter.enabled else { return }
let context = makeContext()
queue.async {
guard self.sampler != nil, background != self.isBackground else { return }
// Close the period at the transition with a final sample.
self.takeSample()
self.logPeriodSummary()
self.context = context
self.isBackground = background
self.period = Period(background: background)
}
}
// MARK: - Sampling
private func tick() {
takeSample()
// Refresh the context for the next sample; the main thread may be
// busy, so the sample itself never waits for it.
DispatchQueue.main.async {
let context = MainActor.assumeIsolated { self.makeContext() }
self.queue.async { self.context = context }
}
}
/// Must run on `queue`.
private func takeSample() {
guard let sampler else { return }
let sample = sampler.sample()
let counts = PerfCounter.take()
guard sample.wall > 0 else { return }
period?.add(sample, counts: counts)
// A sample right after the previous one (at a transition) only feeds the period totals.
guard sample.wall >= 1, UserDefaults.standard.bool(forKey: "loggingEnabled") else { return }
let state = isBackground ? "bg" : "fg"
let threshold = isBackground ? Self.highCPUBackground : Self.highCPUForeground
let footprint = sample.footprintMB.map { "\($0)MB" } ?? "?"
let wakeups = (sample.idleWakeupsPerSecond ?? 0) + (sample.interruptWakeupsPerSecond ?? 0)
let message = String(format: "Perf [%@] cpu %.1f%% | %@ | wakeups %.0f/s | mem %@",
state, sample.cpuPercent, sample.areasText, wakeups, footprint)
var lines = [
"threads: \(sample.hotThreadsText)",
sample.powerText,
String(format: "cpu: user %.1f%% system %.1f%% | %d threads | disk ops read %d write %d | window %.1fs",
sample.userPercent, sample.systemPercent, sample.threadCount,
sample.diskReadOps, sample.diskWriteOps, sample.wall),
]
lines.append(contentsOf: Self.describeCounts(counts, over: sample.wall))
lines.append("context: \(context)")
log(sample.cpuPercent >= threshold ? .warning : .info, message, details: lines.joined(separator: "\n"))
}
/// Must run on `queue`.
private func logPeriodSummary() {
guard let period, period.wall > 0,
UserDefaults.standard.bool(forKey: "loggingEnabled") else { return }
let wall = period.wall
let minutes = Int(wall) / 60
let seconds = Int(wall) % 60
let areas = period.areaSeconds.sorted { $0.value > $1.value }
.map { String(format: "%@ %.1f", $0.key, $0.value / wall * 100) }
.joined(separator: " ")
var lines = [
String(format: "cpu: avg %.1f%% max %.1f%% | %@", period.cpuSeconds / wall * 100, period.maxCPU, areas.isEmpty ? "idle" : areas),
String(format: "wakeups: avg %.0f/s", period.wakeups / wall),
]
lines.append(contentsOf: Self.describeCounts(period.counts, over: wall))
lines.append("context: \(context)")
log(.info, "Perf summary [\(period.background ? "bg" : "fg")] \(minutes)m \(seconds)s",
details: lines.joined(separator: "\n"))
}
/// Counters as rates: views in their own line, everything else as "activity".
private static func describeCounts(_ counts: [String: Int], over wall: TimeInterval) -> [String] {
func format(_ entries: [(key: String, value: Int)], stripping prefix: String = "") -> String {
entries.prefix(15).map { entry in
let name = String(entry.key.dropFirst(prefix.count))
if entry.key.hasSuffix(".bytes") {
return String(format: "%@ %.1fKB/s", name, Double(entry.value) / 1024 / wall)
}
return String(format: "%@ %.1f/s", name, Double(entry.value) / wall)
}.joined(separator: ", ")
}
let sorted = counts.sorted { $0.value > $1.value }
let views = sorted.filter { $0.key.hasPrefix("view.") }
let activity = sorted.filter { !$0.key.hasPrefix("view.") }
return [
"activity: " + (activity.isEmpty ? "none" : format(activity)),
"views: " + (views.isEmpty ? "none" : format(views, stripping: "view.")),
]
}
private func log(_ level: LogLevel, _ message: String, details: String?) {
LoggingService.shared.log(level: level, category: .performance, message: message, details: details)
}
// MARK: - Context
@MainActor
private func currentlyInBackground() -> Bool {
#if os(iOS) || os(tvOS)
UIApplication.shared.applicationState == .background
#elseif os(macOS)
!NSApplication.shared.isActive
#endif
}
@MainActor
private func makeContext() -> String {
var parts: [String] = []
#if os(iOS) || os(tvOS)
let appState: String = switch UIApplication.shared.applicationState {
case .active: "active"
case .inactive: "inactive"
case .background: "background"
@unknown default: "unknown"
}
parts.append("app \(appState)")
#elseif os(macOS)
parts.append("app \(NSApplication.shared.isActive ? "active" : "inactive")")
#endif
if let appEnvironment {
let state = appEnvironment.playerService.state
var player = "player \(state.playbackState)"
if state.currentVideo != nil {
player += state.currentStream?.isAudioOnly == true ? " audio-only" : " video"
player += " pip=\(state.pipState)"
player += " expanded=\(appEnvironment.navigationCoordinator.isPlayerExpanded)"
player += " publishesTime=\(state.publishesTimeUpdates)"
}
parts.append(player)
}
let thermal: String = switch ProcessInfo.processInfo.thermalState {
case .nominal: "nominal"
case .fair: "fair"
case .serious: "serious"
case .critical: "critical"
@unknown default: "unknown"
}
parts.append("thermal \(thermal)")
if ProcessInfo.processInfo.isLowPowerModeEnabled {
parts.append("low power mode")
}
#if os(iOS)
parts.append(batteryDescription())
#endif
return parts.joined(separator: " | ")
}
#if os(iOS)
/// Battery level, and the drain rate while unplugged once it is measurable.
@MainActor
private func batteryDescription() -> String {
let device = UIDevice.current
guard device.batteryLevel >= 0 else { return "battery ?" }
let level = device.batteryLevel
var text = "battery \(Int(level * 100))%"
guard device.batteryState == .unplugged else {
batteryReference = nil
return text + " (\(device.batteryState == .full ? "full" : "charging"))"
}
guard let reference = batteryReference, level <= reference.level else {
batteryReference = (Date(), level)
return text
}
let hours = Date().timeIntervalSince(reference.date) / 3600
if hours >= 5.0 / 60, reference.level > level {
text += String(format: " (%.0f%%/h over %.0fm)", Double(reference.level - level) * 100 / hours, hours * 60)
}
return text
}
#endif
}
// MARK: - Period
/// Totals for one foreground or background period.
private struct Period {
let background: Bool
var wall: TimeInterval = 0
var cpuSeconds: Double = 0
var maxCPU: Double = 0
var areaSeconds: [String: Double] = [:]
var wakeups: Double = 0
var counts: [String: Int] = [:]
init(background: Bool) {
self.background = background
}
mutating func add(_ sample: ResourceSample, counts: [String: Int]) {
wall += sample.wall
cpuSeconds += sample.cpuPercent / 100 * sample.wall
if sample.wall >= 1 {
maxCPU = max(maxCPU, sample.cpuPercent)
}
for area in sample.areas {
areaSeconds[area.area, default: 0] += area.percent / 100 * sample.wall
}
wakeups += ((sample.idleWakeupsPerSecond ?? 0) + (sample.interruptWakeupsPerSecond ?? 0)) * sample.wall
self.counts.merge(counts, uniquingKeysWith: +)
}
}
// MARK: - MetricKit
#if os(iOS) || os(macOS)
/// Logs MetricKit payloads. The system delivers them at most daily (metrics)
/// or shortly after the event (diagnostics), usually on a later launch.
private final class MetricSubscriber: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
LoggingService.shared.log(level: .info, category: .performance,
message: "MetricKit metrics \(payload.timeStampBegin.formatted()) – \(payload.timeStampEnd.formatted())",
details: String(decoding: payload.jsonRepresentation(), as: UTF8.self))
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
var kinds: [String] = []
if let cpu = payload.cpuExceptionDiagnostics, !cpu.isEmpty { kinds.append("\(cpu.count) CPU exception(s)") }
if let hangs = payload.hangDiagnostics, !hangs.isEmpty { kinds.append("\(hangs.count) hang(s)") }
if let disk = payload.diskWriteExceptionDiagnostics, !disk.isEmpty { kinds.append("\(disk.count) disk write exception(s)") }
if let crashes = payload.crashDiagnostics, !crashes.isEmpty { kinds.append("\(crashes.count) crash(es)") }
LoggingService.shared.log(level: kinds.isEmpty ? .info : .warning, category: .performance,
message: "MetricKit diagnostics: \(kinds.isEmpty ? "none" : kinds.joined(separator: ", "))",
details: String(decoding: payload.jsonRepresentation(), as: UTF8.self))
}
}
}
#endif

View File

@@ -0,0 +1,260 @@
//
// ResourceSampler.swift
// Yattee
//
// Measures the process's CPU, per-thread CPU, wakeups, GPU, memory
// and disk activity between two samples.
//
import Foundation
/// One measurement window, from the previous `sample()` call to this one.
struct ResourceSample {
struct Thread {
let name: String
let area: String
let percent: Double
}
let wall: TimeInterval
/// Percent of one core, all threads.
let cpuPercent: Double
let userPercent: Double
let systemPercent: Double
let threadCount: Int
/// Threads that used CPU in the window, busiest first.
let threads: [Thread]
let idleWakeupsPerSecond: Double?
let interruptWakeupsPerSecond: Double?
let gpuPercent: Double?
let footprintMB: UInt64?
let diskReadOps: Int
let diskWriteOps: Int
/// CPU percent grouped by area (ui, playback, audio, network, …), busiest first.
/// CPU of threads that exited during the window is reported as "exited".
var areas: [(area: String, percent: Double)] {
var totals: [String: Double] = [:]
for thread in threads {
totals[thread.area, default: 0] += thread.percent
}
let unattributed = cpuPercent - threads.reduce(0) { $0 + $1.percent }
if unattributed >= 0.5 {
totals["exited", default: 0] += unattributed
}
return totals.sorted { $0.value > $1.value }.map { ($0.key, $0.value) }
}
var hotThreadsText: String {
let hot = threads.filter { $0.percent >= 0.5 }.prefix(8)
guard !hot.isEmpty else { return "none" }
return hot.map { String(format: "%@ %.1f%%", $0.name, $0.percent) }.joined(separator: ", ")
}
var areasText: String {
let areas = areas.filter { $0.percent >= 0.1 }
guard !areas.isEmpty else { return "idle" }
return areas.map { String(format: "%@ %.1f", $0.area, $0.percent) }.joined(separator: " ")
}
var powerText: String {
var parts: [String] = []
if let idle = idleWakeupsPerSecond, let interrupts = interruptWakeupsPerSecond {
parts.append(String(format: "wakeups %.0f/s (idle %.0f, interrupt %.0f)", idle + interrupts, idle, interrupts))
}
if let gpuPercent {
parts.append(String(format: "gpu %.1f%%", gpuPercent))
}
return parts.isEmpty ? "power: unavailable" : parts.joined(separator: " | ")
}
}
/// Keeps the previous sample's counters; not thread-safe, confine each
/// instance to one queue.
final class ResourceSampler {
private var mainThreadPort: mach_port_t = 0
private var lastSampleTime: DispatchTime?
private var lastUserCPU: Double = 0
private var lastSystemCPU: Double = 0
private var lastThreadCPU: [UInt64: UInt64] = [:]
private var lastIdleWakeups: UInt64 = 0
private var lastInterruptWakeups: UInt64 = 0
private var lastGPUNanoseconds: UInt64 = 0
private var lastInBlocks: Int = 0
private var lastOutBlocks: Int = 0
/// Call from the main thread so the main thread can be named in samples.
init(mainThreadPort: mach_port_t) {
self.mainThreadPort = mainThreadPort
}
/// Resets the baseline without producing a meaningful sample.
func reset() {
_ = sample()
}
func sample() -> ResourceSample {
let now = DispatchTime.now()
let wall = lastSampleTime.map { Double(now.uptimeNanoseconds - $0.uptimeNanoseconds) / 1e9 } ?? 0
lastSampleTime = now
var usage = rusage()
getrusage(RUSAGE_SELF, &usage)
let user = seconds(usage.ru_utime)
let system = seconds(usage.ru_stime)
let userPercent = wall > 0 ? (user - lastUserCPU) / wall * 100 : 0
let systemPercent = wall > 0 ? (system - lastSystemCPU) / wall * 100 : 0
lastUserCPU = user
lastSystemCPU = system
let diskReadOps = usage.ru_inblock - lastInBlocks
let diskWriteOps = usage.ru_oublock - lastOutBlocks
lastInBlocks = usage.ru_inblock
lastOutBlocks = usage.ru_oublock
let (threadCount, threads) = sampleThreads(wall: wall)
var idleWakeups: Double?
var interruptWakeups: Double?
var gpuPercent: Double?
var power = task_power_info_v2()
var powerCount = mach_msg_type_number_t(MemoryLayout<task_power_info_v2>.size / MemoryLayout<natural_t>.size)
let powerResult = withUnsafeMutablePointer(to: &power) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(powerCount)) {
task_info(mach_task_self_, task_flavor_t(TASK_POWER_INFO_V2), $0, &powerCount)
}
}
if powerResult == KERN_SUCCESS {
let idle = power.cpu_energy.task_platform_idle_wakeups
let interrupts = power.cpu_energy.task_interrupt_wakeups
let gpu = power.gpu_energy.task_gpu_utilisation
if wall > 0 {
idleWakeups = Double(idle &- lastIdleWakeups) / wall
interruptWakeups = Double(interrupts &- lastInterruptWakeups) / wall
gpuPercent = Double(gpu &- lastGPUNanoseconds) / 1e9 / wall * 100
}
lastIdleWakeups = idle
lastInterruptWakeups = interrupts
lastGPUNanoseconds = gpu
}
var vmInfo = task_vm_info_data_t()
var vmCount = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<natural_t>.size)
let vmResult = withUnsafeMutablePointer(to: &vmInfo) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(vmCount)) {
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &vmCount)
}
}
return ResourceSample(
wall: wall,
cpuPercent: userPercent + systemPercent,
userPercent: userPercent,
systemPercent: systemPercent,
threadCount: threadCount,
threads: threads,
idleWakeupsPerSecond: idleWakeups,
interruptWakeupsPerSecond: interruptWakeups,
gpuPercent: gpuPercent,
footprintMB: vmResult == KERN_SUCCESS ? vmInfo.phys_footprint / 1_048_576 : nil,
diskReadOps: diskReadOps,
diskWriteOps: diskWriteOps
)
}
/// Per-thread CPU since the last sample.
private func sampleThreads(wall: TimeInterval) -> (count: Int, threads: [ResourceSample.Thread]) {
var threadList: thread_act_array_t?
var threadCount: mach_msg_type_number_t = 0
guard task_threads(mach_task_self_, &threadList, &threadCount) == KERN_SUCCESS, let threadList else {
return (0, [])
}
defer {
for index in 0..<Int(threadCount) {
mach_port_deallocate(mach_task_self_, threadList[index])
}
vm_deallocate(mach_task_self_, vm_address_t(UInt(bitPattern: threadList)),
vm_size_t(Int(threadCount) * MemoryLayout<thread_t>.stride))
}
var current: [UInt64: UInt64] = [:]
var threads: [ResourceSample.Thread] = []
for index in 0..<Int(threadCount) {
let thread = threadList[index]
var identifier = thread_identifier_info()
var identifierCount = mach_msg_type_number_t(MemoryLayout<thread_identifier_info>.size / MemoryLayout<natural_t>.size)
let identifierResult = withUnsafeMutablePointer(to: &identifier) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(identifierCount)) {
thread_info(thread, thread_flavor_t(THREAD_IDENTIFIER_INFO), $0, &identifierCount)
}
}
var info = thread_extended_info()
var infoCount = mach_msg_type_number_t(MemoryLayout<thread_extended_info>.size / MemoryLayout<natural_t>.size)
let infoResult = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(infoCount)) {
thread_info(thread, thread_flavor_t(THREAD_EXTENDED_INFO), $0, &infoCount)
}
}
guard identifierResult == KERN_SUCCESS, infoResult == KERN_SUCCESS else { continue }
let id = identifier.thread_id
let total = info.pth_user_time + info.pth_system_time // nanoseconds
current[id] = total
guard wall > 0 else { continue }
let percent = Double(total &- (lastThreadCPU[id] ?? total)) / 1e9 / wall * 100
guard percent > 0 else { continue }
var name = withUnsafeBytes(of: info.pth_name) { raw in
String(decoding: raw.prefix { $0 != 0 }, as: UTF8.self)
}
if thread == mainThreadPort {
name = "main"
} else if name.isEmpty {
name = "unnamed"
}
threads.append(.init(name: name, area: Self.area(forThread: name), percent: percent))
}
lastThreadCPU = current
// Merge same-named threads (e.g. several "unnamed" GCD workers).
var merged: [String: ResourceSample.Thread] = [:]
for thread in threads {
let percent = (merged[thread.name]?.percent ?? 0) + thread.percent
merged[thread.name] = .init(name: thread.name, area: thread.area, percent: percent)
}
return (Int(threadCount), merged.values.sorted { $0.percent > $1.percent })
}
/// What a thread is doing, from its name.
static func area(forThread name: String) -> String {
let lower = name.lowercased()
if name == "main" { return "ui" }
// mpv names its threads after their role ("core", "demux", "vo", …).
if ["core", "demux", "vo", "ao", "cache", "dec"].contains(lower)
|| ["mpv", "stream.yattee.mpv", "lavf", "ffmpeg"].contains(where: lower.hasPrefix) {
return "playback"
}
if lower.contains("auremoteio") || lower.contains("audio") || lower.contains("aqclient") {
return "audio"
}
if lower.contains("nsurlconnection") || lower.contains("com.apple.network") || lower.contains("cfnetwork") {
return "network"
}
if lower.contains("coremedia") || lower.contains("videotoolbox") || lower.contains("vtdecode") {
return "media"
}
if lower.contains("cloudkit") {
return "cloudkit"
}
if lower.hasPrefix("stream.yattee.diagnostics") || lower.hasPrefix("stream.yattee.logfile")
|| lower.hasPrefix("stream.yattee.performance") {
return "logging"
}
return "other"
}
private func seconds(_ time: timeval) -> Double {
Double(time.tv_sec) + Double(time.tv_usec) / 1_000_000
}
}

View File

@@ -179,6 +179,8 @@ actor HTTPClient {
do {
(data, response) = try await session.data(for: finalRequest)
PerfCounter.count("net.api.request")
PerfCounter.count("net.api.bytes", by: data.count)
} catch let error as URLError {
let apiError = mapURLError(error)
// Add more context for cancelled requests

View File

@@ -239,6 +239,36 @@ final class MPVRenderView: UIView {
/// Tracks whether app is in background (to prevent GPU work)
private var isInBackground = false
// MARK: - Diagnostics
/// Live render views, sampled by `BackgroundDiagnostics`.
private static let liveInstances = NSHashTable<MPVRenderView>.weakObjects()
static var diagnosticsInstances: [MPVRenderView] {
liveInstances.allObjects
}
/// Render activity since the last diagnostics sample (written from main and the render queue).
private let diagnosticsCounters = RenderDiagnosticsCounters()
/// One-line render state for the diagnostics heartbeat. Resets the activity counters.
func diagnosticsSummary() -> String {
let counts = diagnosticsCounters.take()
let link: String
if let displayLink {
link = displayLink.isPaused ? "paused" : "running@\(Int(videoFPS))"
} else {
link = "none"
}
return "view=\(String(UInt(bitPattern: ObjectIdentifier(self).hashValue), radix: 16).suffix(6))"
+ " window=\(window != nil) superview=\(superview != nil) hidden=\(isHidden) setup=\(isSetup)"
+ " displayLink=\(link) inBackground=\(isInBackground) pip=\(isPiPActive)"
+ " capturePiP=\(captureFramesForPiP) bgTimer=\(backgroundRenderTimer != nil)"
+ " fb=\(renderWidth)x\(renderHeight)"
+ " | linkFires=\(counts.linkFires) renders=\(counts.renders) presents=\(counts.presents)"
+ " backgroundRenders=\(counts.backgroundRenders) skippedInBackground=\(counts.skippedInBackground)"
}
// MARK: - Video Dimensions for PiP
/// Current video width (actual video content, not view size) - set by backend
@@ -270,6 +300,7 @@ final class MPVRenderView: UIView {
}
private func commonInit() {
Self.liveInstances.add(self)
backgroundColor = .black
contentScaleFactor = traitCollection.displayScale
registerForTraitChanges([UITraitDisplayScale.self]) { (self: MPVRenderView, _) in
@@ -903,6 +934,9 @@ final class MPVRenderView: UIView {
// MARK: - Rendering
@objc private func displayLinkFired() {
diagnosticsCounters.increment(\.linkFires)
PerfCounter.count("render.displayLink")
// Skip if not setup, already rendering, or app is in background (GPU work not permitted)
guard isSetup, !isRendering, !isInBackground else { return }
@@ -934,9 +968,17 @@ final class MPVRenderView: UIView {
// but renders queued by appDidBecomeActive/resumeRendering can run
// after the app went back to the background. PiP keeps rendering.
guard !isInBackground || isPiPActive else {
diagnosticsCounters.increment(\.skippedInBackground)
PerfCounter.count("render.skippedInBackground")
MPVLogging.log("performRender: skipped (app in background)")
return
}
diagnosticsCounters.increment(\.renders)
PerfCounter.count("render.frame")
if isInBackground {
diagnosticsCounters.increment(\.backgroundRenders)
PerfCounter.count("render.frameInBackground")
}
let frameStart = Date()
let tFrameStart = CACurrentMediaTime()
@@ -1012,6 +1054,10 @@ final class MPVRenderView: UIView {
} else if !isPiPActive {
glBindRenderbuffer(GLenum(GL_RENDERBUFFER), colorRenderbuffer)
let presented = eaglContext.presentRenderbuffer(Int(GL_RENDERBUFFER))
if presented {
diagnosticsCounters.increment(\.presents)
PerfCounter.count("render.present")
}
if !presented {
MPVLogging.warn("performRender: presentRenderbuffer FAILED",
details: "fb:\(framebuffer) rb:\(colorRenderbuffer) layer:\(layer.bounds) superview:\(superview != nil)")

View File

@@ -1493,6 +1493,9 @@ final class MPVBackend: PlayerBackend {
extension MPVBackend: MPVClientDelegate {
nonisolated func mpvClient(_ client: MPVClient, didUpdateProperty property: String, value: Any?) {
if PerfCounter.enabled {
PerfCounter.count("mpv.property." + property)
}
Task { @MainActor [weak self] in
self?.handlePropertyChange(property: property, value: value)
}

View File

@@ -141,6 +141,7 @@ final class NowPlayingService {
/// Updates playback time without changing other metadata.
func updatePlaybackTime(currentTime: TimeInterval, duration: TimeInterval, isPlaying: Bool) {
PerfCounter.count("nowPlaying.update")
guard var info = cachedNowPlayingInfo ?? infoCenter.nowPlayingInfo else { return }
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime

View File

@@ -2798,6 +2798,7 @@ final class PlayerService {
}
private func saveProgress() {
PerfCounter.count("player.progressSave")
guard settingsManager?.incognitoModeEnabled != true,
settingsManager?.saveWatchHistory != false else { return }
@@ -2884,6 +2885,7 @@ final class PlayerService {
}
private func checkSponsorBlockSegments(at time: Double) {
PerfCounter.count("player.sponsorBlockCheck")
// Read settings - use defaults if settings manager not available
let enabled = settingsManager?.sponsorBlockEnabled ?? true
let enabledCategories = settingsManager?.sponsorBlockCategories ?? SponsorBlockCategory.defaultEnabled

View File

@@ -228,8 +228,10 @@ final class PlayerState {
}
set {
if publishesTimeUpdates {
PerfCounter.count("player.time.published")
withMutation(keyPath: \.currentTime) { storedCurrentTime = newValue }
} else {
PerfCounter.count("player.time.suppressed")
storedCurrentTime = newValue
}
updateActiveChapter()

View File

@@ -37,6 +37,7 @@ struct MarqueeText: View {
// MARK: - Body
var body: some View {
let _ = PerfCounter.body("MarqueeText")
GeometryReader { geometry in
Group {
if needsScrolling {
@@ -73,6 +74,7 @@ struct MarqueeText: View {
// Paused outside the active scene: the timeline otherwise keeps
// re-rendering every frame while the app plays in the background.
TimelineView(.animation(paused: scenePhase != .active)) { context in
let _ = PerfCounter.body("MarqueeText.frame")
HStack(spacing: spacing) {
textView
textView

View File

@@ -50,6 +50,7 @@ struct ControlsSectionRenderer: View {
@State private var isBrightnessExpanded = false
var body: some View {
let _ = PerfCounter.body("ControlsSectionRenderer")
HStack {
ForEach(section.visibleButtons(isWideLayout: actions.isWideScreenLayout)) { config in
renderButton(config)

View File

@@ -183,6 +183,7 @@ struct ExpandedPlayerSheet: View {
// MARK: - Body
var body: some View {
let _ = PerfCounter.body("ExpandedPlayerSheet")
playerContentWithOverlays
.applyPlayerSheets(
showingQualitySheet: $showingQualitySheet,

View File

@@ -139,6 +139,7 @@ struct MiniPlayerView: View {
}
var body: some View {
let _ = PerfCounter.body("MiniPlayerView")
Group {
#if os(macOS)
capsuleLayout

View File

@@ -198,6 +198,7 @@ struct PlayerControlsView: View {
}
var body: some View {
let _ = PerfCounter.body("PlayerControlsView")
GeometryReader { geometry in
ZStack {
@@ -1018,6 +1019,7 @@ struct PlayerControlsView: View {
private var progressBar: some View {
GeometryReader { geometry in
let _ = PerfCounter.body("PlayerControlsView.progressBar")
ZStack(alignment: .bottomLeading) {
if playerState.isLive {
// For live streams, show simple red indicator (no scrubbing)

View File

@@ -49,6 +49,7 @@ struct SegmentedProgressBar: View {
}
var body: some View {
let _ = PerfCounter.body("SegmentedProgressBar")
GeometryReader { geometry in
if chapters.count >= 2 {
segmentedBar(geometry: geometry)
@@ -217,6 +218,7 @@ private struct ChapterSegmentView: View {
}
var body: some View {
let _ = PerfCounter.body("ChapterSegmentView")
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Background

View File

@@ -60,6 +60,7 @@ struct MacOSControlBar: View {
// MARK: - Body
var body: some View {
let _ = PerfCounter.body("MacOSControlBar")
VStack(spacing: 8) {
// Top row: preset-driven button row
MacOSControlsSectionRenderer(

View File

@@ -58,6 +58,7 @@ struct TVPlayerControlsView: View {
}
var body: some View {
let _ = PerfCounter.body("TVPlayerControlsView")
ZStack {
// Gradient overlay for readability
gradientOverlay

View File

@@ -11,6 +11,7 @@ struct DeveloperSettingsView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Bindable private var loggingService = LoggingService.shared
@State private var loggingEnabled = LoggingService.shared.isEnabled
@State private var performanceLoggingEnabled = PerformanceMonitor.shared.isEnabled
// Data management state
@State private var showingDeduplicateConfirmation = false
@@ -137,6 +138,13 @@ struct DeveloperSettingsView: View {
)) {
Label(String(localized: "settings.advanced.debug.verboseRemote"), systemImage: "antenna.radiowaves.left.and.right")
}
Toggle(isOn: $performanceLoggingEnabled) {
Label(String(localized: "settings.advanced.debug.performanceLogging"), systemImage: "gauge.with.dots.needle.67percent")
}
.onChange(of: performanceLoggingEnabled) { _, newValue in
PerformanceMonitor.shared.setEnabled(newValue)
}
}
}
}

View File

@@ -275,6 +275,7 @@ struct VideoInfoView: View {
}
var body: some View {
let _ = PerfCounter.body("VideoInfoView")
Group {
if isLoadingInitialVideo || (displayedVideoIsPlaceholder && isLoadingVideoDetails) {
loadingView

View File

@@ -58,6 +58,11 @@ struct YatteeApp: App {
// Register background tasks early — Apple requires BGTaskScheduler.register()
// to be called during the app launch sequence, before the run loop starts.
appEnvironment.backgroundRefreshManager.registerBackgroundTasks()
#if os(iOS)
BackgroundDiagnostics.shared.start(playerService: AppEnvironment.shared.playerService)
#endif
PerformanceMonitor.shared.start(appEnvironment: AppEnvironment.shared)
}
var body: some Scene {
@@ -334,6 +339,7 @@ struct YatteeApp: App {
// assertion keeps the app alive until the async flush finishes.
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "PersistOnBackground") {
LoggingService.shared.warning("Background task PersistOnBackground expired", category: .diagnostics)
if backgroundTask != .invalid {
UIApplication.shared.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
@@ -349,6 +355,7 @@ struct YatteeApp: App {
// before the assertion is given up.
await appEnvironment.watchHistorySync.flushPending()
if backgroundTask != .invalid {
LoggingService.shared.info("Background task PersistOnBackground finished", category: .diagnostics)
UIApplication.shared.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
}