Skip to content

Commit 3285807

Browse files
authored
Merge pull request #244 from giginet/fix/xcode-26.4-build-operation-metrics-format
fix: support Xcode 26.4 BuildOperationMetrics JSON format change
2 parents ff2bb9b + b291bac commit 3285807

5 files changed

Lines changed: 256 additions & 47 deletions

File tree

‎Sources/XCLogParser/activityparser/ActivityParser.swift‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -436,14 +436,12 @@ public class ActivityParser {
436436
backtrace: try parseAsJson(token: iterator.next(),
437437
type: jsonType))
438438
case .some("BuildOperationMetrics"):
439-
let jsonType = IDEActivityLogSectionAttachment.BuildOperationMetrics.self
440439
return try IDEActivityLogSectionAttachment(identifier: identifier,
441440
majorVersion: try parseAsInt(token: iterator.next()),
442441
minorVersion: try parseAsInt(token: iterator.next()),
443442
metrics: nil,
444-
buildOperationMetrics: try parseAsJson(
445-
token: iterator.next(),
446-
type: jsonType
443+
buildOperationMetrics: try parseBuildOperationMetrics(
444+
token: iterator.next()
447445
),
448446
backtrace: nil)
449447
default:
@@ -693,6 +691,25 @@ public class ActivityParser {
693691
}
694692
}
695693

694+
private func parseBuildOperationMetrics(
695+
token: Token?
696+
) throws -> IDEActivityLogSectionAttachment.BuildOperationMetrics? {
697+
guard let token = token else {
698+
throw XCLogParserError.parseError("Unexpected EOF parsing BuildOperationMetrics")
699+
}
700+
switch token {
701+
case .json(let string):
702+
guard let data = string.data(using: .utf8) else {
703+
throw XCLogParserError.parseError("Unexpected JSON string \(string)")
704+
}
705+
return try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data)
706+
case .null:
707+
return nil
708+
default:
709+
throw XCLogParserError.parseError("Unexpected token parsing BuildOperationMetrics: \(token)")
710+
}
711+
}
712+
696713
private func parseAsInt(token: Token?) throws -> UInt64 {
697714
guard let token = token else {
698715
throw XCLogParserError.parseError("Unexpected EOF parsing Int")

‎Sources/XCLogParser/activityparser/IDEActivityModel.swift‎

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -773,30 +773,40 @@ public class IDEActivityLogSectionAttachment: Encodable {
773773
// Empty struct for objects with no properties
774774
}
775775

776+
/// Build operation metrics whose JSON schema differs by Xcode version.
776777
public struct BuildOperationMetrics: Codable {
777-
public let clangCacheHits: Int
778-
public let clangCacheMisses: Int
779-
public let swiftCacheHits: Int
780-
public let swiftCacheMisses: Int
781-
782-
public init(
783-
clangCacheHits: Int = 0,
784-
clangCacheMisses: Int = 0,
785-
swiftCacheHits: Int = 0,
786-
swiftCacheMisses: Int = 0
787-
) {
788-
self.clangCacheHits = clangCacheHits
789-
self.clangCacheMisses = clangCacheMisses
790-
self.swiftCacheHits = swiftCacheHits
791-
self.swiftCacheMisses = swiftCacheMisses
778+
public let counters: [String: Int]
779+
public let taskCounters: [String: [String: Int]]
780+
781+
public init(counters: [String: Int], taskCounters: [String: [String: Int]]) {
782+
self.counters = counters
783+
self.taskCounters = taskCounters
792784
}
793785

794-
public init(from decoder: Decoder) throws {
795-
let container = try decoder.container(keyedBy: CodingKeys.self)
796-
clangCacheHits = try container.decodeIfPresent(Int.self, forKey: .clangCacheHits) ?? 0
797-
clangCacheMisses = try container.decodeIfPresent(Int.self, forKey: .clangCacheMisses) ?? 0
798-
swiftCacheHits = try container.decodeIfPresent(Int.self, forKey: .swiftCacheHits) ?? 0
799-
swiftCacheMisses = try container.decodeIfPresent(Int.self, forKey: .swiftCacheMisses) ?? 0
786+
/// Parses BuildOperationMetrics from JSON data, translating the legacy
787+
/// Xcode 15.3 - Xcode 26.3 cache format into the unified counter format.
788+
public init(from jsonData: Data) throws {
789+
let decoder = JSONDecoder()
790+
if let direct = try? decoder.decode(BuildOperationMetrics.self, from: jsonData) {
791+
self = direct
792+
} else {
793+
let legacy = try decoder.decode(LegacyCacheMetrics.self, from: jsonData)
794+
self.counters = [
795+
"clangCacheHits": legacy.clangCacheHits,
796+
"clangCacheMisses": legacy.clangCacheMisses,
797+
"swiftCacheHits": legacy.swiftCacheHits,
798+
"swiftCacheMisses": legacy.swiftCacheMisses,
799+
]
800+
self.taskCounters = [:]
801+
}
800802
}
801803
}
802804
}
805+
806+
/// Legacy Xcode 15.3 - Xcode 26.3 BuildOperationMetrics format, used only for deserialization.
807+
private struct LegacyCacheMetrics: Decodable {
808+
let clangCacheHits: Int
809+
let clangCacheMisses: Int
810+
let swiftCacheHits: Int
811+
let swiftCacheMisses: Int
812+
}

‎Tests/XCLogParserTests/ActivityParserTests.swift‎

Lines changed: 88 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,42 @@ class ActivityParserTests: XCTestCase {
178178
return startTokens + logMessageTokens + endTokens
179179
}()
180180

181+
// Xcode 26.4 format: BuildOperationMetrics JSON format changed
182+
lazy var IDEActivityLogSectionTokensXcode264: [Token] = {
183+
let startTokens = [Token.int(2),
184+
Token.string("com.apple.dt.IDE.BuildLogSection"),
185+
Token.string("Prepare build"),
186+
Token.string("Prepare build"),
187+
Token.double(575479851.278759),
188+
Token.double(575479851.778325),
189+
Token.null,
190+
Token.string("note: Using legacy build system"),
191+
Token.list(1),
192+
Token.className("IDEActivityLogMessage"),
193+
Token.classNameRef("IDEActivityLogMessage"),
194+
]
195+
let logMessageTokens = IDEActivityLogMessageTokens
196+
let endTokens = [Token.int(1),
197+
Token.int(0),
198+
Token.int(1),
199+
Token.int(42), // unknown integer before subtitle (Xcode 26.2+)
200+
Token.string("subtitle"),
201+
Token.null,
202+
Token.string("commandDetailDesc"),
203+
Token.string("501796C4-6BE4-4F80-9F9D-3269617ECC17"),
204+
Token.string("localizedResultString"),
205+
Token.string("xcbuildSignature"),
206+
Token.list(1), // 1 attachment
207+
Token.classNameRef("IDEFoundation.IDEActivityLogSectionAttachment"),
208+
Token.string("com.apple.dt.ActivityLogSectionAttachment.BuildOperationMetrics"),
209+
Token.int(1),
210+
Token.int(0),
211+
// swiftlint:disable:next line_length
212+
Token.json(#"{"counters":{},"taskCounters":{"SwiftDriver":{"moduleDependenciesNotValidatedTasks":1}}}"#),
213+
]
214+
return startTokens + logMessageTokens + endTokens
215+
}()
216+
181217
let IDEConsoleItemTokens: [Token] = [
182218
Token.className("IDEConsoleItem"),
183219
Token.classNameRef("IDEConsoleItem"),
@@ -384,7 +420,11 @@ class ActivityParserTests: XCTestCase {
384420
XCTAssertEqual(3, logSection.attachments.count)
385421
XCTAssertEqual(logSection.attachments[0].backtrace?.frames.first?.category, .ruleNeverBuilt)
386422
print(logSection.attachments)
387-
XCTAssertEqual(logSection.attachments[1].buildOperationMetrics?.clangCacheMisses, 2)
423+
if let metrics = logSection.attachments[1].buildOperationMetrics {
424+
XCTAssertEqual(metrics.counters["clangCacheMisses"], 2)
425+
} else {
426+
XCTFail("Expected BuildOperationMetrics")
427+
}
388428
XCTAssertEqual(logSection.attachments[2].metrics?.wcDuration, 1)
389429
XCTAssertEqual(0, logSection.unknown)
390430
}
@@ -445,6 +485,40 @@ class ActivityParserTests: XCTestCase {
445485
XCTAssertEqual(42, logSection.unknown)
446486
}
447487

488+
func testParseIDEActivityLogSectionXcode264() throws {
489+
parser.logVersion = 12
490+
let tokens = IDEActivityLogSectionTokensXcode264
491+
var iterator = tokens.makeIterator()
492+
let logSection = try parser.parseIDEActivityLogSection(iterator: &iterator)
493+
XCTAssertEqual(2, logSection.sectionType)
494+
XCTAssertEqual("com.apple.dt.IDE.BuildLogSection", logSection.domainType)
495+
XCTAssertEqual("Prepare build", logSection.title)
496+
XCTAssertEqual("Prepare build", logSection.signature)
497+
XCTAssertEqual(575479851.278759, logSection.timeStartedRecording)
498+
XCTAssertEqual(575479851.778325, logSection.timeStoppedRecording)
499+
XCTAssertEqual(0, logSection.subSections.count)
500+
XCTAssertEqual("note: Using legacy build system", logSection.text)
501+
XCTAssertEqual(1, logSection.messages.count)
502+
XCTAssertTrue(logSection.wasCancelled)
503+
XCTAssertFalse(logSection.isQuiet)
504+
XCTAssertTrue(logSection.wasFetchedFromCache)
505+
XCTAssertEqual("subtitle", logSection.subtitle)
506+
XCTAssertEqual("", logSection.location.documentURLString)
507+
XCTAssertEqual(0, logSection.location.timestamp)
508+
XCTAssertEqual("commandDetailDesc", logSection.commandDetailDesc)
509+
XCTAssertEqual("501796C4-6BE4-4F80-9F9D-3269617ECC17", logSection.uniqueIdentifier)
510+
XCTAssertEqual("localizedResultString", logSection.localizedResultString)
511+
XCTAssertEqual("xcbuildSignature", logSection.xcbuildSignature)
512+
XCTAssertEqual(1, logSection.attachments.count)
513+
if let metrics = logSection.attachments[0].buildOperationMetrics {
514+
XCTAssertEqual(metrics.counters, [:])
515+
XCTAssertEqual(metrics.taskCounters["SwiftDriver"]?["moduleDependenciesNotValidatedTasks"], 1)
516+
} else {
517+
XCTFail("Expected BuildOperationMetrics")
518+
}
519+
XCTAssertEqual(42, logSection.unknown)
520+
}
521+
448522
func testParseActivityLog() throws {
449523
let activityLog = try parser.parseIDEActiviyLogFromTokens(IDEActivityLogTokens)
450524
XCTAssertEqual(10, activityLog.version)
@@ -562,30 +636,23 @@ class ActivityParserTests: XCTestCase {
562636
XCTAssertEqual(expectedDVTMemberDocumentLocation, documentMemberLocation)
563637
}
564638

565-
func testBuildOperationMetricsWithMissingKeys() throws {
566-
let json = #"{}"#
639+
func testBuildOperationMetricsWithCacheFormat() throws {
640+
let json = #"{"clangCacheHits":1,"clangCacheMisses":2,"swiftCacheHits":3,"swiftCacheMisses":4}"#
567641
let data = json.data(using: .utf8)!
568-
let metrics = try JSONDecoder().decode(
569-
IDEActivityLogSectionAttachment.BuildOperationMetrics.self,
570-
from: data
571-
)
572-
XCTAssertEqual(metrics.clangCacheHits, 0)
573-
XCTAssertEqual(metrics.clangCacheMisses, 0)
574-
XCTAssertEqual(metrics.swiftCacheHits, 0)
575-
XCTAssertEqual(metrics.swiftCacheMisses, 0)
642+
let metrics = try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data)
643+
XCTAssertEqual(metrics.counters["clangCacheHits"], 1)
644+
XCTAssertEqual(metrics.counters["clangCacheMisses"], 2)
645+
XCTAssertEqual(metrics.counters["swiftCacheHits"], 3)
646+
XCTAssertEqual(metrics.counters["swiftCacheMisses"], 4)
647+
XCTAssertEqual(metrics.taskCounters, [:])
576648
}
577649

578-
func testBuildOperationMetricsWithPartialKeys() throws {
579-
let json = #"{"swiftCacheHits":5,"swiftCacheMisses":3}"#
650+
func testBuildOperationMetricsWithCounterFormat() throws {
651+
let json = #"{"counters":{"a":1},"taskCounters":{"SwiftDriver":{"x":2}}}"#
580652
let data = json.data(using: .utf8)!
581-
let metrics = try JSONDecoder().decode(
582-
IDEActivityLogSectionAttachment.BuildOperationMetrics.self,
583-
from: data
584-
)
585-
XCTAssertEqual(metrics.clangCacheHits, 0)
586-
XCTAssertEqual(metrics.clangCacheMisses, 0)
587-
XCTAssertEqual(metrics.swiftCacheHits, 5)
588-
XCTAssertEqual(metrics.swiftCacheMisses, 3)
653+
let metrics = try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data)
654+
XCTAssertEqual(metrics.counters["a"], 1)
655+
XCTAssertEqual(metrics.taskCounters["SwiftDriver"]?["x"], 2)
589656
}
590657

591658
}

‎docs/JSON Format.md‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,87 @@ Other fields:
7676
- `compilationEndTimestamp`: Timestamp in which the actual compilation finished. For a Target this could be before `endTimestamp` because in the new Xcode Build System linking can happen way after compilation.
7777
- `compilationDuration` Actual duration in seconds of just the compilation phase. In a Target this could be significant shorter than the `duration`.
7878

79+
## Attachments (Xcode 15.3+)
80+
81+
When using the `dump` command, each `IDEActivityLogSection` may include an `attachments` array. This was introduced in SLF version 11 (Xcode 15.3). Each attachment has an `identifier`, `majorVersion`, `minorVersion`, and one of the following typed payloads:
82+
83+
### BuildOperationTaskMetrics
84+
85+
Per-task execution metrics:
86+
87+
```json
88+
{
89+
"identifier": "com.apple.dt.ActivityLogSectionAttachment.TaskMetrics",
90+
"majorVersion": 1,
91+
"minorVersion": 0,
92+
"metrics": {
93+
"utime": 1834,
94+
"stime": 1723,
95+
"maxRSS": 3391488,
96+
"wcStartTime": 793866997013418,
97+
"wcDuration": 141479
98+
}
99+
}
100+
```
101+
102+
### BuildOperationMetrics
103+
104+
Aggregate build operation metrics. The JSON format differs by Xcode version:
105+
106+
**Xcode 15.3 - Xcode 16.x:**
107+
```json
108+
{
109+
"identifier": "com.apple.dt.ActivityLogSectionAttachment.BuildOperationMetrics",
110+
"majorVersion": 1,
111+
"minorVersion": 0,
112+
"buildOperationMetrics": {
113+
"clangCacheHits": 0,
114+
"clangCacheMisses": 2,
115+
"swiftCacheHits": 0,
116+
"swiftCacheMisses": 8
117+
}
118+
}
119+
```
120+
121+
**Xcode 26.4+:**
122+
```json
123+
{
124+
"identifier": "com.apple.dt.ActivityLogSectionAttachment.BuildOperationMetrics",
125+
"majorVersion": 1,
126+
"minorVersion": 0,
127+
"buildOperationMetrics": {
128+
"counters": {},
129+
"taskCounters": {
130+
"SwiftDriver": {
131+
"moduleDependenciesNotValidatedTasks": 1
132+
}
133+
}
134+
}
135+
}
136+
```
137+
138+
XCLogParser automatically detects and supports both formats.
139+
140+
### BuildOperationTaskBacktrace
141+
142+
Backtrace frames explaining why a build task was executed:
143+
144+
```json
145+
{
146+
"identifier": "com.apple.dt.ActivityLogSectionAttachment.TaskBacktrace",
147+
"majorVersion": 1,
148+
"minorVersion": 0,
149+
"backtrace": [
150+
{
151+
"description": "'Planning Swift module Foo' had never run",
152+
"category": { "ruleNeverBuilt": {} }
153+
}
154+
]
155+
}
156+
```
157+
158+
## Detail step types
159+
79160
When possible, the `signature` content of `detail` steps is parsed to determine its type. This makes it easier to aggregate the data.
80161

81162
Value | Description

‎docs/Xcactivitylog Format.md‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,15 @@ You can get these tokens:
136136
[type: "string", value: "Prepare build"],
137137
```
138138

139-
The first integer is the version of the `SLF` format used. In Xcode 10.x and 11 Beta, the version is 10. The values after the version are the actual content of the log.
139+
The first integer is the version of the `SLF` format used. The values after the version are the actual content of the log.
140+
141+
### SLF version history
142+
143+
Version | Xcode Version | Changes
144+
--- | --- | ---
145+
10 | Xcode 10.x - 14.x | Initial format
146+
11 | Xcode 15.3+ | Added `IDEFoundation.IDEActivityLogSectionAttachment` list to `IDEActivityLogSection`
147+
12 | Xcode 26.2+ | Added an unknown integer field before `subtitle` in `IDEActivityLogSection`
140148

141149
## Parsing an xcactivitylog
142150

@@ -156,3 +164,29 @@ Inside the logs you can find these classes:
156164
If you search for them, you will find that they belong to the IDEFoundation.framework. A private framework part of Xcode. You can class dump it to get the headers of those classes. Once you have the headers, you will have the name and type of the properties that belong to the class. Now, you can match them to the tokens you got from the log. Some of them are in the same order than in the headers, but for others it will be about trial and error.
157165

158166
In the project you can find those classes with their properties in the order in which they appear in the log in the file [IDEActivityModel.swift](../Sources/XCLogParser/activityparser/IDEActivityModel.swift).
167+
168+
## IDEActivityLogSectionAttachment (SLF version 11+)
169+
170+
Starting with SLF version 11 (Xcode 15.3), each `IDEActivityLogSection` includes an array of `IDEFoundation.IDEActivityLogSectionAttachment` entries after the `xcbuildSignature` field. Each attachment has an `identifier` string, a `majorVersion` integer, a `minorVersion` integer, and a JSON payload. The identifier determines the type of the JSON payload:
171+
172+
Identifier | JSON Type | Description
173+
--- | --- | ---
174+
`...TaskMetrics` | `BuildOperationTaskMetrics` | Per-task timing metrics (`utime`, `stime`, `maxRSS`, `wcStartTime`, `wcDuration`)
175+
`...TaskBacktrace` | `BuildOperationTaskBacktrace` | Backtrace frames explaining why a task was run
176+
`...BuildOperationMetrics` | `BuildOperationMetrics` | Aggregate build operation metrics (see below)
177+
178+
### BuildOperationMetrics format changes
179+
180+
In **Xcode 15.3 - Xcode 16.x**, the `BuildOperationMetrics` JSON contains compiler cache statistics:
181+
182+
```json
183+
{"clangCacheHits":0,"clangCacheMisses":2,"swiftCacheHits":0,"swiftCacheMisses":8}
184+
```
185+
186+
Starting with **Xcode 26.4**, the format changed to a dynamic counter-based structure:
187+
188+
```json
189+
{"counters":{},"taskCounters":{"SwiftDriver":{"moduleDependenciesNotValidatedTasks":1}}}
190+
```
191+
192+
XCLogParser automatically detects and supports both formats.

0 commit comments

Comments
 (0)