diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
new file mode 100644
index 0000000..911acf8
--- /dev/null
+++ b/.claude-plugin/marketplace.json
@@ -0,0 +1,47 @@
+{
+ "$schema": "https://raw.githubusercontent.com/anthropics/claude-code/refs/heads/main/marketplace/marketplace.schema.json",
+ "name": "diagnostics-report-analyzer-skill",
+ "version": "1.0.0",
+ "owner": {
+ "name": "Antoine van der Lee",
+ "email": "contact@avanderlee.com"
+ },
+ "metadata": {
+ "description": "Analyze AvdLee Diagnostics HTML reports using embedded JSON first, with legacy HTML report fallback."
+ },
+ "plugins": [
+ {
+ "name": "diagnostics-report-analyzer",
+ "description": "Support and debugging guidance for AvdLee Diagnostics reports, session logs, app metadata, and MetricKit diagnostics.",
+ "repository": "https://github.com/AvdLee/Diagnostics",
+ "version": "1.0.0",
+ "author": {
+ "name": "Antoine van der Lee",
+ "email": "contact@avanderlee.com"
+ },
+ "license": "MIT",
+ "category": "development",
+ "keywords": [
+ "diagnostics",
+ "support",
+ "debugging",
+ "logs",
+ "html-report",
+ "json",
+ "swift",
+ "ios",
+ "macos"
+ ],
+ "tags": [
+ "diagnostics",
+ "support",
+ "debugging",
+ "logs",
+ "swift",
+ "ios",
+ "macos"
+ ],
+ "source": "./"
+ }
+ ]
+}
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
new file mode 100644
index 0000000..8f959b4
--- /dev/null
+++ b/.claude-plugin/plugin.json
@@ -0,0 +1,25 @@
+{
+ "name": "diagnostics-report-analyzer",
+ "version": "1.0.0",
+ "description": "Analyze AvdLee Diagnostics HTML reports using embedded JSON first, with legacy HTML report fallback.",
+ "author": {
+ "name": "Antoine van der Lee",
+ "email": "contact@avanderlee.com"
+ },
+ "repository": "https://github.com/AvdLee/Diagnostics",
+ "license": "MIT",
+ "keywords": [
+ "diagnostics",
+ "support",
+ "debugging",
+ "logs",
+ "html-report",
+ "json",
+ "swift",
+ "ios",
+ "macos"
+ ],
+ "skills": [
+ "./diagnostics-report-analyzer-skill"
+ ]
+}
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
new file mode 100644
index 0000000..e75c0b4
--- /dev/null
+++ b/.cursor-plugin/plugin.json
@@ -0,0 +1,25 @@
+{
+ "name": "diagnostics-report-analyzer",
+ "version": "1.0.0",
+ "description": "Analyze AvdLee Diagnostics HTML reports using embedded JSON first, with legacy HTML report fallback.",
+ "author": {
+ "name": "Antoine van der Lee",
+ "email": "contact@avanderlee.com"
+ },
+ "repository": "https://github.com/AvdLee/Diagnostics",
+ "license": "MIT",
+ "keywords": [
+ "diagnostics",
+ "support",
+ "debugging",
+ "logs",
+ "html-report",
+ "json",
+ "swift",
+ "ios",
+ "macos"
+ ],
+ "skills": [
+ "diagnostics-report-analyzer-skill"
+ ]
+}
diff --git a/Changelog.md b/Changelog.md
index f4521aa..ce13059 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -1,3 +1,6 @@
+### 7.0.0
+- NEW: Diagnostics reports are now agent-friendly single-file HTML documents with embedded structured JSON. The browser view is rendered from the JSON payload, while agents can inspect structured chapters, session metadata, log events, and crash diagnostics directly. Existing historic log sessions remain readable, and uncaught exceptions are persisted as timestamped `crash` events.
+
### 6.0.1
- Add projects using Roadmap to README ([#185](https://github.com/AvdLee/Diagnostics/pull/185)) via [@AvdLee](https://github.com/AvdLee)
- Add Helm for App Store Connect to projects list ([#186](https://github.com/AvdLee/Diagnostics/pull/186)) via [@hiddevdploeg](https://github.com/hiddevdploeg)
diff --git a/DiagnosticsTests/DiagnosticsReporterTests.swift b/DiagnosticsTests/DiagnosticsReporterTests.swift
index 9545dbe..06e3248 100644
--- a/DiagnosticsTests/DiagnosticsReporterTests.swift
+++ b/DiagnosticsTests/DiagnosticsReporterTests.swift
@@ -29,18 +29,63 @@ final class DiagnosticsReporterTests: XCTestCase {
let reporters = [reporter]
let report = await DiagnosticsReporter.create(using: reporters)
let html = String(data: report.data, encoding: .utf8)!
-
- XCTAssertTrue(html.contains("
\(diagnosticsChapter.title)
"))
- XCTAssertTrue(html.contains(diagnosticsChapter.diagnostics as! String))
+ let document = try XCTUnwrap(html.diagnosticsReportDocument)
+
+ XCTAssertTrue(html.contains("", range: startRange.upperBound.. DiagnosticsChapter {
diff --git a/DiagnosticsTests/Logging/DiagnosticsLoggerTests.swift b/DiagnosticsTests/Logging/DiagnosticsLoggerTests.swift
index ebaf09c..c86cfa0 100644
--- a/DiagnosticsTests/Logging/DiagnosticsLoggerTests.swift
+++ b/DiagnosticsTests/Logging/DiagnosticsLoggerTests.swift
@@ -8,6 +8,30 @@ import XCTest
final class DiagnosticsLoggerTests: XCTestCase {
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ try DiagnosticsLogger.setup()
+ }
+
+ override func tearDownWithError() throws {
+ try DiagnosticsLogger.standard.deleteLogs()
+ try super.tearDownWithError()
+ }
+
+ func testSynchronousCrashLogIsPersistedBeforeReturning() throws {
+ let exception = NSException(name: .genericException, reason: "Synchronous crash test")
+
+ DiagnosticsLogger.standard.logSynchronously(ExceptionLog(exception, description: "Uncaught Exception"))
+
+ let logData = try XCTUnwrap(DiagnosticsLogger.standard.readLog())
+ let log = String(decoding: logData, as: UTF8.self)
+
+ XCTAssertTrue(log.contains(DiagnosticsLogRecord.linePrefix))
+ XCTAssertTrue(log.contains("\"level\":\"crash\""))
+ XCTAssertTrue(log.contains("Synchronous crash test"))
+ XCTAssertTrue(log.contains("Uncaught Exception"))
+ }
+
#if os(macOS)
/// On unsandboxed macOS processes (including the `swift test` runner), the Application
/// Support directory used for the log file must be scoped by the current bundle identifier
diff --git a/DiagnosticsTests/Logging/LogsWriterTests.swift b/DiagnosticsTests/Logging/LogsWriterTests.swift
index cd95cdd..6b685d3 100644
--- a/DiagnosticsTests/Logging/LogsWriterTests.swift
+++ b/DiagnosticsTests/Logging/LogsWriterTests.swift
@@ -33,6 +33,26 @@ final class LogsWriterTests: XCTestCase {
let contents = String(decoding: data, as: UTF8.self)
XCTAssertTrue(contents.contains("Test log line"))
+ XCTAssertTrue(contents.contains(DiagnosticsLogRecord.linePrefix))
+ }
+
+ func testWriteAppendsStructuredDataAfterLegacyContent() throws {
+ let legacyContent = """
+
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..c74c42c 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,62 @@ 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)
+ }
+
+ /// It should encode structured session metadata keys and values before rendering HTML.
+ func testStructuredSessionMetadataHTMLEncoding() {
+ let session = DiagnosticsLogSession(
+ title: "Session",
+ metadata: ["": "<2026-06-19>"]
+ )
+
+ let html = session.html()
+
+ XCTAssertTrue(html.contains("<Date>"))
+ XCTAssertTrue(html.contains("<2026-06-19>"))
+ XCTAssertFalse(html.contains(": "))
+ }
+
+ func testExceptionLogUsesCrashLevelAndPreformattedHTML() throws {
+ let exception = NSException(name: .genericException, reason: "Boom")
+ let recordLine = String(decoding: ExceptionLog(exception, description: "Crash description").logData, as: UTF8.self)
+
+ let report = DiagnosticsLogParser().parse(recordLine)
+ let event = try XCTUnwrap(report.sessions.first?.events.first)
+ let html = event.html()
+
+ XCTAssertEqual(event.level, "crash")
+ XCTAssertNotNil(event.date)
+ XCTAssertTrue(event.message.contains("CRASH:"))
+ XCTAssertTrue(html.contains("
"))
+ XCTAssertTrue(html.contains("Crash description"))
+ }
}
diff --git a/DiagnosticsTests/Reporters/LogsTrimmerTests.swift b/DiagnosticsTests/Reporters/LogsTrimmerTests.swift
index 19a79d2..a266f6d 100644
--- a/DiagnosticsTests/Reporters/LogsTrimmerTests.swift
+++ b/DiagnosticsTests/Reporters/LogsTrimmerTests.swift
@@ -58,5 +58,41 @@ 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"))
+ }
+
+ /// It should trim legacy records before structured records in mixed-format files.
+ func testTrimmingMixedFormatLogsTrimsLegacyRecordsFirst() {
+ let legacyLog = """
+
Old legacy log
+ """
+ let structuredSession = String(decoding: NewSession().logData, as: UTF8.self)
+ let structuredLog = String(decoding: SystemLog(line: "New structured log").logData, as: UTF8.self)
+
+ var inputData = Data((legacyLog + structuredSession + structuredLog).utf8)
+ let trimmer = LogsTrimmer(numberOfLinesToTrim: 1)
+
+ trimmer.trim(data: &inputData)
+
+ let outputString = String(decoding: inputData, as: UTF8.self)
+ XCTAssertFalse(outputString.contains("Old legacy log"))
+ XCTAssertTrue(outputString.contains("\"type\":\"sessionStart\""))
+ XCTAssertTrue(outputString.contains("New structured log"))
+ }
}
// swiftlint:enable line_length
diff --git a/README.md b/README.md
index 51557bd..6c3a568 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,8 @@ Diagnostics is a library written in Swift which makes it really easy to share Di
- [Features](#features)
- [Requirements](#requirements)
- [Usage](#usage)
+ - [Agent-friendly reports](#agent-friendly-reports)
+ - [Install the Agent Skill](#install-the-agent-skill)
- [Using a custom UserDefaults type](#using-a-custom-userdefaults-type)
- [Filtering out sensitive data](#filtering-out-sensitive-data)
- [Adding your own custom logs](#adding-your-own-custom-logs)
@@ -38,6 +40,7 @@ The library allows to easily attach the Diagnostics Report as an attachment to t
- System logs divided per session
- [x] Possibility to filter out sensitive data using a `DiagnosticsReportFilter`
- [x] A custom `DiagnosticsLogger` to add your own logs
+- [x] Agent-friendly single-file HTML reports with embedded structured JSON
- [x] Smart insights like _"⚠️ User is low on storage"_ and *"✅ User is using the latest app version"*
- [x] Flexible setup to add your own smart insights
- [x] Flexible setup to add your own custom diagnostics
@@ -130,6 +133,28 @@ func send(report: DiagnosticsReport) {
}
```
+### Agent-friendly reports
+
+Diagnostics reports remain a single `.html` attachment that users can email and open in a browser. New reports also embed a structured JSON payload in:
+
+```html
+"
+ html += ""
+ html += ""
html += footer()
- html += "