Enable log export across multiple sessions for better diagnostic support

This commit is contained in:
Beingpax 2026-01-12 16:45:45 +05:45
parent 05cc14ab6c
commit fd1219580d

View File

@ -1,111 +1,140 @@
import Foundation import Foundation
import OSLog import OSLog
/// Utility class for exporting app logs since launch for diagnostic purposes
final class LogExporter { final class LogExporter {
static let shared = LogExporter() static let shared = LogExporter()
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "LogExporter") private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "LogExporter")
private let subsystem = "com.prakashjoshipax.voiceink" private let subsystem = "com.prakashjoshipax.voiceink"
private let maxSessionsToKeep = 3
private let sessionsKey = "logExporter.sessionStartDates.v1"
/// Timestamp when the app was launched private(set) var sessionStartDates: [Date] = []
let launchDate: Date
private init() { private init() {
self.launchDate = Date() var loadedDates: [Date] = []
logger.notice("🎙️ LogExporter initialized, launch timestamp recorded") if let data = UserDefaults.standard.data(forKey: sessionsKey),
let dates = try? JSONDecoder().decode([Date].self, from: data) {
loadedDates = dates
}
sessionStartDates = [Date()] + loadedDates
sessionStartDates = Array(sessionStartDates.prefix(maxSessionsToKeep))
saveSessions()
logger.notice("🎙️ LogExporter initialized, \(self.sessionStartDates.count) session(s) tracked")
}
private func saveSessions() {
if let data = try? JSONEncoder().encode(sessionStartDates) {
UserDefaults.standard.set(data, forKey: sessionsKey)
}
} }
/// Exports logs since app launch to a file and returns the file URL
func exportLogs() async throws -> URL { func exportLogs() async throws -> URL {
logger.notice("🎙️ Starting log export since \(self.launchDate)") logger.notice("🎙️ Starting log export")
let logs = try await fetchLogsSinceLaunch()
let logs = try await fetchLogs()
let fileURL = try saveLogsToFile(logs) let fileURL = try saveLogsToFile(logs)
logger.notice("🎙️ Log export completed: \(fileURL.path)") logger.notice("🎙️ Log export completed: \(fileURL.path)")
return fileURL return fileURL
} }
/// Fetches logs from OSLogStore since app launch private func fetchLogs() async throws -> [String] {
private func fetchLogsSinceLaunch() async throws -> [String] { let store = try OSLogStore(scope: .system)
let store = try OSLogStore(scope: .currentProcessIdentifier)
// Get logs since launch
let position = store.position(date: launchDate)
// Create predicate to filter by our subsystem
let predicate = NSPredicate(format: "subsystem == %@", subsystem) let predicate = NSPredicate(format: "subsystem == %@", subsystem)
let entries = try store.getEntries(at: position, matching: predicate)
var logLines: [String] = [] var logLines: [String] = []
// Add header
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
logLines.append("=== VoiceInk Diagnostic Logs ===") logLines.append("=== VoiceInk Diagnostic Logs ===")
logLines.append("Export Date: \(dateFormatter.string(from: Date()))") logLines.append("Export Date: \(dateFormatter.string(from: Date()))")
logLines.append("App Launch: \(dateFormatter.string(from: launchDate))")
logLines.append("Subsystem: \(subsystem)") logLines.append("Subsystem: \(subsystem)")
logLines.append("Total Sessions: \(sessionStartDates.count)")
logLines.append("================================") logLines.append("================================")
logLines.append("") logLines.append("")
for entry in entries { // Build session ranges with labels
guard let logEntry = entry as? OSLogEntryLog else { continue } let totalSessions = sessionStartDates.count
var sessionRanges: [(label: String, start: Date, end: Date?)] = []
let timestamp = dateFormatter.string(from: logEntry.date) for i in 0..<totalSessions {
let level = logLevelString(logEntry.level) let start = sessionStartDates[i]
let category = logEntry.category let end: Date? = (i == 0) ? nil : sessionStartDates[i - 1]
let message = logEntry.composedMessage let sessionNumber = totalSessions - i
logLines.append("[\(timestamp)] [\(level)] [\(category)] \(message)") let label: String
if totalSessions == 1 {
label = "Session 1 (Current)"
} else if i == 0 {
label = "Session \(sessionNumber) (Current)"
} else if i == totalSessions - 1 {
label = "Session 1 (Oldest)"
} else {
label = "Session \(sessionNumber)"
}
sessionRanges.append((label, start, end))
} }
if logLines.count <= 6 { // Only header lines // Fetch logs for each session (oldest first for chronological order)
logLines.append("No logs found since app launch.") for (label, startDate, endDate) in sessionRanges.reversed() {
logLines.append("--- \(label) ---")
logLines.append("")
let position = store.position(date: startDate)
let entries = try store.getEntries(at: position, matching: predicate)
var sessionLogCount = 0
for entry in entries {
guard let logEntry = entry as? OSLogEntryLog else { continue }
if let endDate, logEntry.date >= endDate { break }
let timestamp = dateFormatter.string(from: logEntry.date)
let level = logLevelString(logEntry.level)
let category = logEntry.category
let message = logEntry.composedMessage
logLines.append("[\(timestamp)] [\(level)] [\(category)] \(message)")
sessionLogCount += 1
}
if sessionLogCount == 0 {
logLines.append("No logs found for this session.")
}
logLines.append("")
} }
return logLines return logLines
} }
/// Converts OSLogEntryLog.Level to a readable string
private func logLevelString(_ level: OSLogEntryLog.Level) -> String { private func logLevelString(_ level: OSLogEntryLog.Level) -> String {
switch level { switch level {
case .undefined: case .undefined: return "UNDEFINED"
return "UNDEFINED" case .debug: return "DEBUG"
case .debug: case .info: return "INFO"
return "DEBUG" case .notice: return "NOTICE"
case .info: case .error: return "ERROR"
return "INFO" case .fault: return "FAULT"
case .notice: @unknown default: return "UNKNOWN"
return "NOTICE"
case .error:
return "ERROR"
case .fault:
return "FAULT"
@unknown default:
return "UNKNOWN"
} }
} }
/// Saves logs to a file in the Downloads folder
private func saveLogsToFile(_ logs: [String]) throws -> URL { private func saveLogsToFile(_ logs: [String]) throws -> URL {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
let timestamp = dateFormatter.string(from: Date()) let timestamp = dateFormatter.string(from: Date())
let fileName = "VoiceInk_Logs_\(timestamp).log" let fileName = "VoiceInk_Logs_\(timestamp).log"
// Get Downloads folder
guard let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first else { guard let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first else {
throw NSError(domain: "LogExporter", code: 1, userInfo: [NSLocalizedDescriptionKey: "Downloads directory unavailable"]) throw NSError(domain: "LogExporter", code: 1, userInfo: [NSLocalizedDescriptionKey: "Downloads directory unavailable"])
} }
let fileURL = downloadsURL.appendingPathComponent(fileName)
let fileURL = downloadsURL.appendingPathComponent(fileName)
let content = logs.joined(separator: "\n") let content = logs.joined(separator: "\n")
try content.write(to: fileURL, atomically: true, encoding: .utf8) try content.write(to: fileURL, atomically: true, encoding: .utf8)