Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions Sources/XCLogParser/activityparser/ActivityParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -436,14 +436,12 @@ public class ActivityParser {
backtrace: try parseAsJson(token: iterator.next(),
type: jsonType))
case .some("BuildOperationMetrics"):
let jsonType = IDEActivityLogSectionAttachment.BuildOperationMetrics.self
return try IDEActivityLogSectionAttachment(identifier: identifier,
majorVersion: try parseAsInt(token: iterator.next()),
minorVersion: try parseAsInt(token: iterator.next()),
metrics: nil,
buildOperationMetrics: try parseAsJson(
token: iterator.next(),
type: jsonType
buildOperationMetrics: try parseBuildOperationMetrics(
token: iterator.next()
),
backtrace: nil)
default:
Expand Down Expand Up @@ -693,6 +691,25 @@ public class ActivityParser {
}
}

private func parseBuildOperationMetrics(
token: Token?
) throws -> IDEActivityLogSectionAttachment.BuildOperationMetrics? {
guard let token = token else {
throw XCLogParserError.parseError("Unexpected EOF parsing BuildOperationMetrics")
}
switch token {
case .json(let string):
guard let data = string.data(using: .utf8) else {
throw XCLogParserError.parseError("Unexpected JSON string \(string)")
}
return try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data)
case .null:
return nil
default:
throw XCLogParserError.parseError("Unexpected token parsing BuildOperationMetrics: \(token)")
}
}

private func parseAsInt(token: Token?) throws -> UInt64 {
guard let token = token else {
throw XCLogParserError.parseError("Unexpected EOF parsing Int")
Expand Down
52 changes: 31 additions & 21 deletions Sources/XCLogParser/activityparser/IDEActivityModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -773,30 +773,40 @@ public class IDEActivityLogSectionAttachment: Encodable {
// Empty struct for objects with no properties
}

/// Build operation metrics whose JSON schema differs by Xcode version.
public struct BuildOperationMetrics: Codable {
public let clangCacheHits: Int
public let clangCacheMisses: Int
public let swiftCacheHits: Int
public let swiftCacheMisses: Int

public init(
clangCacheHits: Int = 0,
clangCacheMisses: Int = 0,
swiftCacheHits: Int = 0,
swiftCacheMisses: Int = 0
) {
self.clangCacheHits = clangCacheHits
self.clangCacheMisses = clangCacheMisses
self.swiftCacheHits = swiftCacheHits
self.swiftCacheMisses = swiftCacheMisses
public let counters: [String: Int]
public let taskCounters: [String: [String: Int]]

public init(counters: [String: Int], taskCounters: [String: [String: Int]]) {
self.counters = counters
self.taskCounters = taskCounters
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
clangCacheHits = try container.decodeIfPresent(Int.self, forKey: .clangCacheHits) ?? 0
clangCacheMisses = try container.decodeIfPresent(Int.self, forKey: .clangCacheMisses) ?? 0
swiftCacheHits = try container.decodeIfPresent(Int.self, forKey: .swiftCacheHits) ?? 0
swiftCacheMisses = try container.decodeIfPresent(Int.self, forKey: .swiftCacheMisses) ?? 0
/// Parses BuildOperationMetrics from JSON data, translating the legacy
/// Xcode 15.3 - Xcode 26.3 cache format into the unified counter format.
public init(from jsonData: Data) throws {
let decoder = JSONDecoder()
if let direct = try? decoder.decode(BuildOperationMetrics.self, from: jsonData) {
self = direct
} else {
let legacy = try decoder.decode(LegacyCacheMetrics.self, from: jsonData)
self.counters = [
"clangCacheHits": legacy.clangCacheHits,
"clangCacheMisses": legacy.clangCacheMisses,
"swiftCacheHits": legacy.swiftCacheHits,
"swiftCacheMisses": legacy.swiftCacheMisses,
]
self.taskCounters = [:]
}
}
}
}

/// Legacy Xcode 15.3 - Xcode 26.3 BuildOperationMetrics format, used only for deserialization.
private struct LegacyCacheMetrics: Decodable {
let clangCacheHits: Int
let clangCacheMisses: Int
let swiftCacheHits: Int
let swiftCacheMisses: Int
}
109 changes: 88 additions & 21 deletions Tests/XCLogParserTests/ActivityParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,42 @@ class ActivityParserTests: XCTestCase {
return startTokens + logMessageTokens + endTokens
}()

// Xcode 26.4 format: BuildOperationMetrics JSON format changed
lazy var IDEActivityLogSectionTokensXcode264: [Token] = {
let startTokens = [Token.int(2),
Token.string("com.apple.dt.IDE.BuildLogSection"),
Token.string("Prepare build"),
Token.string("Prepare build"),
Token.double(575479851.278759),
Token.double(575479851.778325),
Token.null,
Token.string("note: Using legacy build system"),
Token.list(1),
Token.className("IDEActivityLogMessage"),
Token.classNameRef("IDEActivityLogMessage"),
]
let logMessageTokens = IDEActivityLogMessageTokens
let endTokens = [Token.int(1),
Token.int(0),
Token.int(1),
Token.int(42), // unknown integer before subtitle (Xcode 26.2+)
Token.string("subtitle"),
Token.null,
Token.string("commandDetailDesc"),
Token.string("501796C4-6BE4-4F80-9F9D-3269617ECC17"),
Token.string("localizedResultString"),
Token.string("xcbuildSignature"),
Token.list(1), // 1 attachment
Token.classNameRef("IDEFoundation.IDEActivityLogSectionAttachment"),
Token.string("com.apple.dt.ActivityLogSectionAttachment.BuildOperationMetrics"),
Token.int(1),
Token.int(0),
// swiftlint:disable:next line_length
Token.json(#"{"counters":{},"taskCounters":{"SwiftDriver":{"moduleDependenciesNotValidatedTasks":1}}}"#),
]
return startTokens + logMessageTokens + endTokens
}()

let IDEConsoleItemTokens: [Token] = [
Token.className("IDEConsoleItem"),
Token.classNameRef("IDEConsoleItem"),
Expand Down Expand Up @@ -384,7 +420,11 @@ class ActivityParserTests: XCTestCase {
XCTAssertEqual(3, logSection.attachments.count)
XCTAssertEqual(logSection.attachments[0].backtrace?.frames.first?.category, .ruleNeverBuilt)
print(logSection.attachments)
XCTAssertEqual(logSection.attachments[1].buildOperationMetrics?.clangCacheMisses, 2)
if let metrics = logSection.attachments[1].buildOperationMetrics {
XCTAssertEqual(metrics.counters["clangCacheMisses"], 2)
} else {
XCTFail("Expected BuildOperationMetrics")
}
XCTAssertEqual(logSection.attachments[2].metrics?.wcDuration, 1)
XCTAssertEqual(0, logSection.unknown)
}
Expand Down Expand Up @@ -445,6 +485,40 @@ class ActivityParserTests: XCTestCase {
XCTAssertEqual(42, logSection.unknown)
}

func testParseIDEActivityLogSectionXcode264() throws {
parser.logVersion = 12
let tokens = IDEActivityLogSectionTokensXcode264
var iterator = tokens.makeIterator()
let logSection = try parser.parseIDEActivityLogSection(iterator: &iterator)
XCTAssertEqual(2, logSection.sectionType)
XCTAssertEqual("com.apple.dt.IDE.BuildLogSection", logSection.domainType)
XCTAssertEqual("Prepare build", logSection.title)
XCTAssertEqual("Prepare build", logSection.signature)
XCTAssertEqual(575479851.278759, logSection.timeStartedRecording)
XCTAssertEqual(575479851.778325, logSection.timeStoppedRecording)
XCTAssertEqual(0, logSection.subSections.count)
XCTAssertEqual("note: Using legacy build system", logSection.text)
XCTAssertEqual(1, logSection.messages.count)
XCTAssertTrue(logSection.wasCancelled)
XCTAssertFalse(logSection.isQuiet)
XCTAssertTrue(logSection.wasFetchedFromCache)
XCTAssertEqual("subtitle", logSection.subtitle)
XCTAssertEqual("", logSection.location.documentURLString)
XCTAssertEqual(0, logSection.location.timestamp)
XCTAssertEqual("commandDetailDesc", logSection.commandDetailDesc)
XCTAssertEqual("501796C4-6BE4-4F80-9F9D-3269617ECC17", logSection.uniqueIdentifier)
XCTAssertEqual("localizedResultString", logSection.localizedResultString)
XCTAssertEqual("xcbuildSignature", logSection.xcbuildSignature)
XCTAssertEqual(1, logSection.attachments.count)
if let metrics = logSection.attachments[0].buildOperationMetrics {
XCTAssertEqual(metrics.counters, [:])
XCTAssertEqual(metrics.taskCounters["SwiftDriver"]?["moduleDependenciesNotValidatedTasks"], 1)
} else {
XCTFail("Expected BuildOperationMetrics")
}
XCTAssertEqual(42, logSection.unknown)
}

func testParseActivityLog() throws {
let activityLog = try parser.parseIDEActiviyLogFromTokens(IDEActivityLogTokens)
XCTAssertEqual(10, activityLog.version)
Expand Down Expand Up @@ -562,30 +636,23 @@ class ActivityParserTests: XCTestCase {
XCTAssertEqual(expectedDVTMemberDocumentLocation, documentMemberLocation)
}

func testBuildOperationMetricsWithMissingKeys() throws {
let json = #"{}"#
func testBuildOperationMetricsWithCacheFormat() throws {
let json = #"{"clangCacheHits":1,"clangCacheMisses":2,"swiftCacheHits":3,"swiftCacheMisses":4}"#
let data = json.data(using: .utf8)!
let metrics = try JSONDecoder().decode(
IDEActivityLogSectionAttachment.BuildOperationMetrics.self,
from: data
)
XCTAssertEqual(metrics.clangCacheHits, 0)
XCTAssertEqual(metrics.clangCacheMisses, 0)
XCTAssertEqual(metrics.swiftCacheHits, 0)
XCTAssertEqual(metrics.swiftCacheMisses, 0)
let metrics = try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data)
XCTAssertEqual(metrics.counters["clangCacheHits"], 1)
XCTAssertEqual(metrics.counters["clangCacheMisses"], 2)
XCTAssertEqual(metrics.counters["swiftCacheHits"], 3)
XCTAssertEqual(metrics.counters["swiftCacheMisses"], 4)
XCTAssertEqual(metrics.taskCounters, [:])
}

func testBuildOperationMetricsWithPartialKeys() throws {
let json = #"{"swiftCacheHits":5,"swiftCacheMisses":3}"#
func testBuildOperationMetricsWithCounterFormat() throws {
let json = #"{"counters":{"a":1},"taskCounters":{"SwiftDriver":{"x":2}}}"#
let data = json.data(using: .utf8)!
let metrics = try JSONDecoder().decode(
IDEActivityLogSectionAttachment.BuildOperationMetrics.self,
from: data
)
XCTAssertEqual(metrics.clangCacheHits, 0)
XCTAssertEqual(metrics.clangCacheMisses, 0)
XCTAssertEqual(metrics.swiftCacheHits, 5)
XCTAssertEqual(metrics.swiftCacheMisses, 3)
let metrics = try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data)
XCTAssertEqual(metrics.counters["a"], 1)
XCTAssertEqual(metrics.taskCounters["SwiftDriver"]?["x"], 2)
}

}
81 changes: 81 additions & 0 deletions docs/JSON Format.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,87 @@ Other fields:
- `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.
- `compilationDuration` Actual duration in seconds of just the compilation phase. In a Target this could be significant shorter than the `duration`.

## Attachments (Xcode 15.3+)

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:

### BuildOperationTaskMetrics

Per-task execution metrics:

```json
{
"identifier": "com.apple.dt.ActivityLogSectionAttachment.TaskMetrics",
"majorVersion": 1,
"minorVersion": 0,
"metrics": {
"utime": 1834,
"stime": 1723,
"maxRSS": 3391488,
"wcStartTime": 793866997013418,
"wcDuration": 141479
}
}
```

### BuildOperationMetrics

Aggregate build operation metrics. The JSON format differs by Xcode version:

**Xcode 15.3 - Xcode 16.x:**
```json
{
"identifier": "com.apple.dt.ActivityLogSectionAttachment.BuildOperationMetrics",
"majorVersion": 1,
"minorVersion": 0,
"buildOperationMetrics": {
"clangCacheHits": 0,
"clangCacheMisses": 2,
"swiftCacheHits": 0,
"swiftCacheMisses": 8
}
}
```

**Xcode 26.4+:**
```json
{
"identifier": "com.apple.dt.ActivityLogSectionAttachment.BuildOperationMetrics",
"majorVersion": 1,
"minorVersion": 0,
"buildOperationMetrics": {
"counters": {},
"taskCounters": {
"SwiftDriver": {
"moduleDependenciesNotValidatedTasks": 1
}
}
}
}
```

XCLogParser automatically detects and supports both formats.

### BuildOperationTaskBacktrace

Backtrace frames explaining why a build task was executed:

```json
{
"identifier": "com.apple.dt.ActivityLogSectionAttachment.TaskBacktrace",
"majorVersion": 1,
"minorVersion": 0,
"backtrace": [
{
"description": "'Planning Swift module Foo' had never run",
"category": { "ruleNeverBuilt": {} }
}
]
}
```

## Detail step types

When possible, the `signature` content of `detail` steps is parsed to determine its type. This makes it easier to aggregate the data.

Value | Description
Expand Down
36 changes: 35 additions & 1 deletion docs/Xcactivitylog Format.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,15 @@ You can get these tokens:
[type: "string", value: "Prepare build"],
```

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.
The first integer is the version of the `SLF` format used. The values after the version are the actual content of the log.

### SLF version history

Version | Xcode Version | Changes
--- | --- | ---
10 | Xcode 10.x - 14.x | Initial format
11 | Xcode 15.3+ | Added `IDEFoundation.IDEActivityLogSectionAttachment` list to `IDEActivityLogSection`
12 | Xcode 26.2+ | Added an unknown integer field before `subtitle` in `IDEActivityLogSection`

## Parsing an xcactivitylog

Expand All @@ -156,3 +164,29 @@ Inside the logs you can find these classes:
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.

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).

## IDEActivityLogSectionAttachment (SLF version 11+)

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:

Identifier | JSON Type | Description
--- | --- | ---
`...TaskMetrics` | `BuildOperationTaskMetrics` | Per-task timing metrics (`utime`, `stime`, `maxRSS`, `wcStartTime`, `wcDuration`)
`...TaskBacktrace` | `BuildOperationTaskBacktrace` | Backtrace frames explaining why a task was run
`...BuildOperationMetrics` | `BuildOperationMetrics` | Aggregate build operation metrics (see below)

### BuildOperationMetrics format changes

In **Xcode 15.3 - Xcode 16.x**, the `BuildOperationMetrics` JSON contains compiler cache statistics:

```json
{"clangCacheHits":0,"clangCacheMisses":2,"swiftCacheHits":0,"swiftCacheMisses":8}
```

Starting with **Xcode 26.4**, the format changed to a dynamic counter-based structure:

```json
{"counters":{},"taskCounters":{"SwiftDriver":{"moduleDependenciesNotValidatedTasks":1}}}
```

XCLogParser automatically detects and supports both formats.