Date: 2026-01-01
Legacy event
+ + """ + try Data(legacyContent.utf8).write(to: tempLogFileURL) + + let writer = LogsWriter(logFileLocation: tempLogFileURL, maximumLogSize: 1024 * 1024) + writer.write(SystemLog(line: "Structured event")) + + let data = try Data(contentsOf: tempLogFileURL) + let contents = String(decoding: data, as: UTF8.self) + + XCTAssertTrue(contents.contains("Legacy event")) + XCTAssertTrue(contents.contains("Structured event")) + XCTAssertTrue(contents.contains(DiagnosticsLogRecord.linePrefix)) } func testTrimmingOccursWhenExceedingMaxSize() throws { diff --git a/DiagnosticsTests/Reporters/LogsReporterTests.swift b/DiagnosticsTests/Reporters/LogsReporterTests.swift index 1a5f9cc..aa29d6e 100644 --- a/DiagnosticsTests/Reporters/LogsReporterTests.swift +++ b/DiagnosticsTests/Reporters/LogsReporterTests.swift @@ -26,12 +26,16 @@ final class LogsReporterTests: XCTestCase { let identifier = UUID().uuidString let message = "\(identifier)" DiagnosticsLogger.log(message: message) - let diagnostics = LogsReporter().report().diagnostics as! String - XCTAssertTrue(diagnostics.contains(identifier), "Diagnostics is \(diagnostics)") - XCTAssertEqual(diagnostics.debugLogs.count, 1) - let debugLog = try XCTUnwrap(diagnostics.debugLogs.first) - XCTAssertTrue(debugLog.contains("LogsReporterTests.swift:L28"), "Prefix should be added") - XCTAssertTrue(debugLog.contains("<b>\(identifier)</b>"), "Log message should be added to \(debugLog)") + let diagnostics = LogsReporter().report().diagnostics as! DiagnosticsLogReport + let debugLogs = diagnostics.sessions.flatMap(\.events).filter { $0.level == "debug" } + let html = diagnostics.html() + XCTAssertTrue(html.contains(identifier), "Diagnostics is \(html)") + XCTAssertEqual(debugLogs.count, 1) + let debugLog = try XCTUnwrap(debugLogs.first) + XCTAssertEqual(debugLog.prefix, "LogsReporterTests.swift:L28", "Prefix should be added") + XCTAssertEqual(debugLog.message, message, "Raw message should be preserved for agents") + XCTAssertTrue(html.contains("LogsReporterTests.swift:L28"), "Prefix should be added") + XCTAssertTrue(html.contains("<b>\(identifier)</b>"), "Log message should be added to \(html)") } /// It should show errors. @@ -45,11 +49,14 @@ final class LogsReporterTests: XCTestCase { } DiagnosticsLogger.log(error: Error.testCase) - let diagnostics = LogsReporter().report().diagnostics as! String - XCTAssertTrue(diagnostics.contains("testCase")) - XCTAssertEqual(diagnostics.errorLogs.count, 1) - let errorLog = try XCTUnwrap(diagnostics.errorLogs.first) - XCTAssertTrue(errorLog.contains("ERROR: testCase | <b>example description</b>")) + let diagnostics = LogsReporter().report().diagnostics as! DiagnosticsLogReport + let errorLogs = diagnostics.sessions.flatMap(\.events).filter { $0.level == "error" } + let html = diagnostics.html() + XCTAssertTrue(html.contains("testCase")) + XCTAssertEqual(errorLogs.count, 1) + let errorLog = try XCTUnwrap(errorLogs.first) + XCTAssertTrue(errorLog.message.contains("ERROR: testCase | example description")) + XCTAssertTrue(html.contains("ERROR: testCase | <b>example description</b>")) } /// It should reverse the order of sessions to have the most recent session on top. @@ -57,9 +64,33 @@ final class LogsReporterTests: XCTestCase { DiagnosticsLogger.log(message: "first") DiagnosticsLogger.standard.startNewSession() DiagnosticsLogger.log(message: "second") - let diagnostics = LogsReporter().report().diagnostics as! String - let firstIndex = try XCTUnwrap(diagnostics.range(of: "first")?.lowerBound) - let secondIndex = try XCTUnwrap(diagnostics.range(of: "second")?.lowerBound) + let diagnostics = LogsReporter().report().diagnostics as! DiagnosticsLogReport + let html = diagnostics.html() + let firstIndex = try XCTUnwrap(html.range(of: "first")?.lowerBound) + let secondIndex = try XCTUnwrap(html.range(of: "second")?.lowerBound) XCTAssertTrue(firstIndex > secondIndex) } + + /// It should keep historic legacy sessions readable when new structured records are appended after an app update. + func testMixedLegacyAndStructuredSessions() throws { + let legacySession = """ + + --- + +Date: 2026-01-01 10:00:00
System: iOS 18.0
Locale: en
Version: 1.0 (1)
legacy historic event
+ """ + let structuredSession = String(decoding: NewSession().logData, as: UTF8.self) + let structuredEvent = String(decoding: LogItem(.debug(message: "structured update event"), file: #file, function: #function, line: #line).logData, as: UTF8.self) + + let report = DiagnosticsLogParser().parse(legacySession + structuredSession + structuredEvent) + let html = report.html() + + XCTAssertEqual(report.sessions.count, 2) + XCTAssertTrue(html.contains("legacy historic event")) + XCTAssertTrue(html.contains("structured update event")) + let legacyIndex = try XCTUnwrap(html.range(of: "legacy historic event")?.lowerBound) + let structuredIndex = try XCTUnwrap(html.range(of: "structured update event")?.lowerBound) + XCTAssertTrue(structuredIndex < legacyIndex) + } } diff --git a/DiagnosticsTests/Reporters/LogsTrimmerTests.swift b/DiagnosticsTests/Reporters/LogsTrimmerTests.swift index 19a79d2..1425c2b 100644 --- a/DiagnosticsTests/Reporters/LogsTrimmerTests.swift +++ b/DiagnosticsTests/Reporters/LogsTrimmerTests.swift @@ -58,5 +58,22 @@ final class LogsTrimmerTests: XCTestCase { let outputString = String(data: inputData, encoding: .utf8) XCTAssertEqual(outputString, expectedOutput) } + + /// It should trim structured log records while preserving session start records. + func testTrimmingStructuredRecords() { + let session = String(decoding: NewSession().logData, as: UTF8.self) + let oldLog = String(decoding: SystemLog(line: "Old structured log").logData, as: UTF8.self) + let newLog = String(decoding: SystemLog(line: "New structured log").logData, as: UTF8.self) + + var inputData = Data((session + oldLog + newLog).utf8) + let trimmer = LogsTrimmer(numberOfLinesToTrim: 1) + + trimmer.trim(data: &inputData) + + let outputString = String(decoding: inputData, as: UTF8.self) + XCTAssertTrue(outputString.contains("\"type\":\"sessionStart\"")) + XCTAssertFalse(outputString.contains("Old structured log")) + XCTAssertTrue(outputString.contains("New structured log")) + } } // swiftlint:enable line_length diff --git a/Sources/DiagnosticsReportDocument.swift b/Sources/DiagnosticsReportDocument.swift new file mode 100644 index 0000000..c47bded --- /dev/null +++ b/Sources/DiagnosticsReportDocument.swift @@ -0,0 +1,172 @@ +// +// DiagnosticsReportDocument.swift +// Diagnostics +// +// Created by Diagnostics. +// + +import Foundation + +struct DiagnosticsReportDocument: Codable, Sendable { + let schemaVersion: Int + let generatedAt: String + let title: String + let agentHints: [String] + let chapters: [DiagnosticsReportDocumentChapter] + + init(title: String, chapters: [DiagnosticsChapter]) { + self.schemaVersion = 1 + self.generatedAt = ISO8601DateFormatter().string(from: Date()) + self.title = title + self.agentHints = [ + "Agents should read this JSON payload instead of the rendered HTML.", + "Start with chapters where kind is logs, then inspect error and system events in the newest sessions first.", + "Use legacyHTML only as a fallback for custom diagnostics that do not expose structured data yet." + ] + self.chapters = chapters.map(DiagnosticsReportDocumentChapter.init) + } +} + +struct DiagnosticsReportDocumentChapter: Codable, Sendable { + let id: String + let title: String + let kind: String + let data: DiagnosticsReportDocumentValue + let legacyHTML: String? + + init(chapter: DiagnosticsChapter) { + self.id = chapter.title.anchor + self.title = chapter.title + let reportDocumentValue = chapter.diagnostics.reportDocumentValue + + switch reportDocumentValue { + case .logs: + self.kind = "logs" + case .table: + self.kind = "table" + case .preformatted: + self.kind = "preformatted" + case .text: + self.kind = "text" + case .legacyHTML: + self.kind = "legacyHTML" + } + + self.data = reportDocumentValue + + if case .legacyHTML(let html) = reportDocumentValue { + self.legacyHTML = html + } else { + self.legacyHTML = nil + } + } +} + +enum DiagnosticsReportDocumentValue: Codable, Sendable { + case table([DiagnosticsReportTableRow]) + case text(String) + case preformatted(String) + case logs(DiagnosticsLogReport) + case legacyHTML(String) + + private enum CodingKeys: String, CodingKey { + case type + case rows + case value + case sessions + } + + private enum ValueType: String, Codable { + case table + case text + case preformatted + case logs + case legacyHTML + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .table(let rows): + try container.encode(ValueType.table, forKey: .type) + try container.encode(rows, forKey: .rows) + case .text(let value): + try container.encode(ValueType.text, forKey: .type) + try container.encode(value, forKey: .value) + case .preformatted(let value): + try container.encode(ValueType.preformatted, forKey: .type) + try container.encode(value, forKey: .value) + case .logs(let report): + try container.encode(ValueType.logs, forKey: .type) + try container.encode(report.sessions, forKey: .sessions) + case .legacyHTML(let value): + try container.encode(ValueType.legacyHTML, forKey: .type) + try container.encode(value, forKey: .value) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(ValueType.self, forKey: .type) + + switch type { + case .table: + self = .table(try container.decode([DiagnosticsReportTableRow].self, forKey: .rows)) + case .text: + self = .text(try container.decode(String.self, forKey: .value)) + case .preformatted: + self = .preformatted(try container.decode(String.self, forKey: .value)) + case .logs: + self = .logs(DiagnosticsLogReport(sessions: try container.decode([DiagnosticsLogSession].self, forKey: .sessions))) + case .legacyHTML: + self = .legacyHTML(try container.decode(String.self, forKey: .value)) + } + } +} + +struct DiagnosticsReportTableRow: Codable, Sendable { + let key: String + let value: String +} + +extension Diagnostics { + var reportDocumentValue: DiagnosticsReportDocumentValue { + if let logs = self as? DiagnosticsLogReport { + return .logs(logs) + } + + if let table = self as? [String: String] { + return .table(table.sorted(by: { $0.key < $1.key }).map { DiagnosticsReportTableRow(key: $0.key, value: $0.value) }) + } + + if let keyValuePairs = self as? KeyValuePairs\(legacyHTML.addingHTMLEncoding())" + } else { + html += legacyHTML + } + } else { + html += "
\(key): \(value.addingHTMLEncoding())
" + } + html += "\(content)
\n" + } +} + +struct DiagnosticsLogRecord: Codable, Sendable { + static let linePrefix = "DIAGNOSTICS_JSON " + + let type: String + let date: String? + let level: String? + let prefix: String? + let message: String? + let metadata: [String: String]? + + var lineData: Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + + guard + let data = try? encoder.encode(self), + let json = String(data: data, encoding: .utf8) else { + return Data() + } + + return Data("\(Self.linePrefix)\(json)\n".utf8) + } + + init(loggable: Loggable) { + self.type = "log" + self.date = loggable.date.map(DateFormatter.current.string(from:)) + self.level = loggable.cssClass?.rawValue ?? "debug" + self.prefix = loggable.prefix + self.message = loggable.message.removingHTMLEncoding() + self.metadata = nil + } + + init(session: NewSession) { + self.type = "sessionStart" + self.date = session.metadata["Date"] + self.level = nil + self.prefix = nil + self.message = nil + self.metadata = session.metadata + } + + init?(line: String) { + guard line.hasPrefix(Self.linePrefix) else { + return nil + } + + let json = line.dropFirst(Self.linePrefix.count) + guard let data = String(json).data(using: .utf8), + let record = try? JSONDecoder().decode(Self.self, from: data) else { + return nil + } + + self = record + } + + var session: DiagnosticsLogSession { + let metadata = metadata ?? [:] + let title = metadata["Date"].map { "Session \($0)" } ?? "Session" + return DiagnosticsLogSession(title: title, metadata: metadata) + } + + var event: DiagnosticsLogEvent? { + guard type == "log", let message else { + return nil + } + + return DiagnosticsLogEvent(date: date, level: level ?? "debug", prefix: prefix, message: message) + } +} + +struct DiagnosticsLogParser: Sendable { + func parse(_ logs: String) -> DiagnosticsLogReport { + var sessions: [DiagnosticsLogSession] = [] + var currentStructuredSession: DiagnosticsLogSession? + + logs.components(separatedBy: "\n\n---\n\n").forEach { chunk in + var legacyLines: [String] = [] + + func flushLegacyLines() { + let legacyHTML = legacyLines.joined(separator: "\n") + legacyLines.removeAll() + guard !legacyHTML.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + let title = legacyHTML.split(whereSeparator: \.isNewline).first.map(String.init) ?? "Unknown session title" + sessions.append(DiagnosticsLogSession(title: title, legacyHTML: legacyHTML)) + } + + chunk.components(separatedBy: .newlines).forEach { line in + guard let record = DiagnosticsLogRecord(line: line) else { + legacyLines.append(line) + return + } + + flushLegacyLines() + + if record.type == "sessionStart" { + if let currentStructuredSession { + sessions.append(currentStructuredSession) + } + currentStructuredSession = record.session + } else if let event = record.event { + if currentStructuredSession == nil { + currentStructuredSession = DiagnosticsLogSession(title: "Session") + } + currentStructuredSession?.events.append(event) + } + } + + flushLegacyLines() + } + + if let currentStructuredSession { + sessions.append(currentStructuredSession) + } + + return DiagnosticsLogReport(sessions: sessions) + } +} + +private extension String { + var isOldStyleSession: Bool { + !contains("class=\"session-header") + } + + func removingHTMLEncoding() -> String { + replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } +} diff --git a/Sources/Logging/Loggable.swift b/Sources/Logging/Loggable.swift index ff27382..a3dbc20 100644 --- a/Sources/Logging/Loggable.swift +++ b/Sources/Logging/Loggable.swift @@ -25,11 +25,17 @@ extension Loggable { var cssClass: LoggableCSSClass? { nil } var logData: Data { + if let newSession = self as? NewSession { + return DiagnosticsLogRecord(session: newSession).lineData + } + return DiagnosticsLogRecord(loggable: self).lineData + } + + var legacyLogData: Data { if let cssClass { - Data("\(logMessage)
\n".utf8) - } else { - Data("\(message)\n".utf8) + return Data("\(logMessage)
\n".utf8) } + return Data("\(message)\n".utf8) } private var logMessage: String { @@ -48,12 +54,19 @@ extension Loggable { struct NewSession: Loggable { let message: String + let metadata: [String: String] init() { let date = DateFormatter.current.string(from: Date()) let appVersion = "\(Bundle.appVersion) (\(Bundle.appBuildNumber))" let system = "\(Device.systemName) \(Device.systemVersion)" let locale = Locale.preferredLanguages[0] + self.metadata = [ + "Date": date, + "System": system, + "Locale": locale, + "Version": appVersion + ] /// We start with `\n\n---\n\n` for backwards compatibility since it's /// used for splitting the log into sections. diff --git a/Sources/Logging/LogsTrimmer.swift b/Sources/Logging/LogsTrimmer.swift index 6b34735..6ff29a4 100644 --- a/Sources/Logging/LogsTrimmer.swift +++ b/Sources/Logging/LogsTrimmer.swift @@ -19,12 +19,19 @@ struct LogsTrimmer: Sendable { func trim(data: inout Data) { var logs = String(decoding: data, as: UTF8.self) + let structuredRecordsTrimmed = trimStructuredRecords(from: &logs) + guard structuredRecordsTrimmed < numberOfLinesToTrim else { + data = Data(logs.utf8) + return + } + let nsLogs = logs as NSString let matches = LogsTrimmer.regex .matches(in: logs, range: NSRange(location: 0, length: nsLogs.length)) - let linesToRemove = matches.prefix(numberOfLinesToTrim) + let linesToRemove = matches.prefix(numberOfLinesToTrim - structuredRecordsTrimmed) guard let firstMatch = linesToRemove.first, let lastMatch = linesToRemove.last else { + data = Data(logs.utf8) return } @@ -36,4 +43,25 @@ struct LogsTrimmer: Sendable { logs = nsLogs.replacingCharacters(in: range, with: "") data = Data(logs.utf8) } + + private func trimStructuredRecords(from logs: inout String) -> Int { + var trimmedRecords = 0 + var lines = logs.components(separatedBy: .newlines) + + while trimmedRecords < numberOfLinesToTrim { + guard let index = lines.firstIndex(where: { line in + line.hasPrefix(DiagnosticsLogRecord.linePrefix) && line.contains("\"type\":\"log\"") + }) else { + break + } + + lines.remove(at: index) + trimmedRecords += 1 + } + + guard trimmedRecords > 0 else { return 0 } + + logs = lines.joined(separator: "\n") + return trimmedRecords + } } diff --git a/Sources/Reporters/LogsReporter.swift b/Sources/Reporters/LogsReporter.swift index c333aea..5da263a 100644 --- a/Sources/Reporters/LogsReporter.swift +++ b/Sources/Reporters/LogsReporter.swift @@ -13,32 +13,19 @@ struct LogsReporter: DiagnosticsReporting { let title: String = "Session Logs" - var diagnostics: String { + var diagnostics: DiagnosticsLogReport { do { guard let data = try DiagnosticsLogger.standard.readLog(), let logs = String(data: data, encoding: .utf8) else { - return "Parsing the log failed (Unknown error)" + return DiagnosticsLogReport(sessions: [ + DiagnosticsLogSession(title: "Parsing failed", legacyHTML: "Parsing the log failed (Unknown error)") + ]) } - let sessions = logs.components(separatedBy: "\n\n---\n\n").reversed() - var diagnostics = "" - sessions.forEach { session in - guard !session.isEmpty else { return } - - diagnostics += "\(session.addingHTMLEncoding())" - } else { - diagnostics += session - } - diagnostics += "