From f0d154a6854a3dd9c8c04cb5026d379731ad04ea Mon Sep 17 00:00:00 2001 From: everettjf Date: Sun, 24 May 2026 12:39:04 -0700 Subject: [PATCH 1/6] feat(swift): Swift macro tracing (main route) via a SwiftPM package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a SwiftPM package so AppleTrace can trace Swift, which the objc_msgSend hook can't reach (Swift's static / vtable / witness dispatch never goes through objc_msgSend). The primary route is source-level instrumentation via Swift macros, which sidesteps dispatch entirely — the begin/end calls are inserted into the function body at compile time, so they cover final classes, structs, and protocol methods alike. Package layout: - CAppleTrace: reuses the existing appletrace.mm core (single source of truth — included from the Xcode tree, not duplicated). - AppleTrace: idiomatic Swift API (withSpan, beginSection/endSection, traceInstant/Counter, asyncBegin/End, flush, setEnabled, traceDirectory) plus the macro declarations. - AppleTraceMacrosPlugin: SwiftSyntax compiler plugin implementing @Traced (body macro) and @TraceAll (member-attribute macro that stamps @Traced on every method with a body). Tests (all green on Swift 6.2.4, no experimental flags needed): - Macro expansion tests for @Traced and @TraceAll. - A usage test that actually applies the macros and asserts the function names land in the trace. - A runtime test that drives the C core through withSpan and verifies a fragment is written. docs/swift-tracing.md updated to record the chosen direction and status. The SwiftTrace-style runtime hook (secondary route) lands separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 + Package.resolved | 15 ++ Package.swift | 49 ++++++ Sources/AppleTrace/AppleTrace.swift | 110 ++++++++++++ Sources/AppleTraceMacrosPlugin/Plugin.swift | 56 ++++++ Sources/CAppleTrace/appletrace_impl.mm | 11 ++ Sources/CAppleTrace/include/CAppleTrace.h | 11 ++ Sources/CAppleTrace/include/module.modulemap | 4 + docs/swift-tracing.md | 163 ++++++++++++++++++ .../AppleTraceTests/MacroExpansionTests.swift | 94 ++++++++++ tests/AppleTraceTests/MacroUsageTests.swift | 44 +++++ tests/AppleTraceTests/RuntimeTests.swift | 40 +++++ 12 files changed, 601 insertions(+) create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 Sources/AppleTrace/AppleTrace.swift create mode 100644 Sources/AppleTraceMacrosPlugin/Plugin.swift create mode 100644 Sources/CAppleTrace/appletrace_impl.mm create mode 100644 Sources/CAppleTrace/include/CAppleTrace.h create mode 100644 Sources/CAppleTrace/include/module.modulemap create mode 100644 docs/swift-tracing.md create mode 100644 tests/AppleTraceTests/MacroExpansionTests.swift create mode 100644 tests/AppleTraceTests/MacroUsageTests.swift create mode 100644 tests/AppleTraceTests/RuntimeTests.swift diff --git a/.gitignore b/.gitignore index b691f70..55f3ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ build/ __pycache__/ *.pyc .pytest_cache/ + +# SwiftPM +.build/ +.swiftpm/ diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..7b3a67b --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "b2e6d3bc48895e7f76c0a6c9afd620e1f2c8124303987d6dbe81d1f6d8012e67", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..7c5a0ed --- /dev/null +++ b/Package.swift @@ -0,0 +1,49 @@ +// swift-tools-version: 6.0 +import PackageDescription +import CompilerPluginSupport + +let package = Package( + name: "AppleTrace", + platforms: [ + .iOS(.v13), + .macOS(.v11), + ], + products: [ + .library(name: "AppleTrace", targets: ["AppleTrace"]), + ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0"), + ], + targets: [ + // The existing Objective-C++ trace core, reused from the Xcode tree. + .target( + name: "CAppleTrace" + ), + + // Swift surface: idiomatic wrappers + the @Traced / @TraceAll macros. + .target( + name: "AppleTrace", + dependencies: ["CAppleTrace", "AppleTraceMacrosPlugin"] + ), + + // SwiftSyntax compiler plugin implementing the macros. + .macro( + name: "AppleTraceMacrosPlugin", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + + .testTarget( + name: "AppleTraceTests", + dependencies: [ + "AppleTrace", + "AppleTraceMacrosPlugin", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ] + ), + ] +) diff --git a/Sources/AppleTrace/AppleTrace.swift b/Sources/AppleTrace/AppleTrace.swift new file mode 100644 index 0000000..a5eb5fb --- /dev/null +++ b/Sources/AppleTrace/AppleTrace.swift @@ -0,0 +1,110 @@ +// +// AppleTrace.swift +// Idiomatic Swift surface over the AppleTrace C core, plus the tracing macros. +// +// Why this exists: the automatic objc_msgSend hook only sees Objective-C +// dynamic dispatch, so most Swift calls (static / vtable / witness dispatch) +// are invisible to it. Instrumenting Swift at the source level — via these +// wrappers and the @Traced / @TraceAll macros — sidesteps dispatch entirely: +// the begin/end calls are emitted into the function body at compile time, so +// they work for final classes, structs, and protocol methods alike. +// + +import CAppleTrace + +// MARK: - Manual API + +/// Begins a trace section. Pair with ``endSection(_:)`` on the same thread. +/// Prefer ``withSpan(_:_:)`` or the ``Traced()`` macro, which can't leak a +/// missing end. +@inlinable +public func beginSection(_ name: String) { + APTBeginSection(name) +} + +/// Ends a trace section previously opened with ``beginSection(_:)``. +@inlinable +public func endSection(_ name: String) { + APTEndSection(name) +} + +/// Runs `body` inside a trace section named `name`, closing the section even if +/// `body` throws or returns early. +@inlinable +public func withSpan(_ name: String, _ body: () throws -> T) rethrows -> T { + APTBeginSection(name) + defer { APTEndSection(name) } + return try body() +} + +/// Emits an instantaneous marker at the current time. +@inlinable +public func traceInstant(_ name: String) { + APTInstant(name) +} + +/// Emits a counter sample that Perfetto renders as a graph track. +@inlinable +public func traceCounter(_ name: String, _ value: Double) { + APTCounter(name, value) +} + +/// Begins an async span identified by `(name, id)`; it may end on another thread. +@inlinable +public func asyncBegin(_ name: String, id: UInt64) { + APTAsyncBegin(name, id) +} + +/// Ends the async span identified by `(name, id)`. +@inlinable +public func asyncEnd(_ name: String, id: UInt64) { + APTAsyncEnd(name, id) +} + +/// Flushes buffered events to disk. Call before reading the trace off-device +/// (the writer batches per thread). +@inlinable +public func flush() { + APTFlush() +} + +/// Enables or disables recording at runtime. +@inlinable +public func setEnabled(_ enabled: Bool) { + APTSetEnabled(enabled ? true : false) +} + +/// Whether recording is currently enabled. +@inlinable +public var isEnabled: Bool { + APTIsEnabled() +} + +/// The directory trace fragments are written to. +@inlinable +public var traceDirectory: String { + String(cString: APTGetTraceDirectory()) +} + +// MARK: - Macros + +/// Wraps the annotated function's body in a trace section named after the +/// function (`#function`, including argument labels). Works regardless of how +/// the method is dispatched. +/// +/// ```swift +/// @Traced +/// func loadConfig() { /* ... */ } +/// ``` +@attached(body) +public macro Traced() = #externalMacro(module: "AppleTraceMacrosPlugin", type: "TracedMacro") + +/// Applies ``Traced()`` to every method with a body declared directly in the +/// annotated type or extension. +/// +/// ```swift +/// @TraceAll +/// final class FeedViewModel { /* every method is traced */ } +/// ``` +@attached(memberAttribute) +public macro TraceAll() = #externalMacro(module: "AppleTraceMacrosPlugin", type: "TraceAllMacro") diff --git a/Sources/AppleTraceMacrosPlugin/Plugin.swift b/Sources/AppleTraceMacrosPlugin/Plugin.swift new file mode 100644 index 0000000..5a0ea85 --- /dev/null +++ b/Sources/AppleTraceMacrosPlugin/Plugin.swift @@ -0,0 +1,56 @@ +// +// Plugin.swift +// SwiftSyntax implementation of the AppleTrace tracing macros. +// + +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// `@Traced` — a body macro that wraps the function body in a trace section +/// named after `#function`, closed via `defer` so it survives throws / early +/// returns. +public struct TracedMacro: BodyMacro { + public static func expansion( + of node: AttributeSyntax, + providingBodyFor declaration: some DeclSyntaxProtocol & WithOptionalCodeBlockSyntax, + in context: some MacroExpansionContext + ) throws -> [CodeBlockItemSyntax] { + let original = declaration.body?.statements ?? [] + var statements: [CodeBlockItemSyntax] = [ + "AppleTrace.beginSection(#function)", + "defer { AppleTrace.endSection(#function) }", + ] + statements.append(contentsOf: original) + return statements + } +} + +/// `@TraceAll` — a member-attribute macro that stamps `@Traced` onto every +/// method (with a body) declared directly in the type or extension. +public struct TraceAllMacro: MemberAttributeMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingAttributesFor member: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AttributeSyntax] { + guard let function = member.as(FunctionDeclSyntax.self), function.body != nil else { + return [] + } + let alreadyTraced = function.attributes.contains { element in + element.as(AttributeSyntax.self)? + .attributeName.trimmedDescription == "Traced" + } + return alreadyTraced ? [] : ["@Traced"] + } +} + +@main +struct AppleTraceMacrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + TracedMacro.self, + TraceAllMacro.self, + ] +} diff --git a/Sources/CAppleTrace/appletrace_impl.mm b/Sources/CAppleTrace/appletrace_impl.mm new file mode 100644 index 0000000..66fb66a --- /dev/null +++ b/Sources/CAppleTrace/appletrace_impl.mm @@ -0,0 +1,11 @@ +// +// appletrace_impl.mm +// Compiles the existing AppleTrace core into the SwiftPM C target. +// +// Rather than duplicate the implementation, this pulls in the canonical +// source from the Xcode framework tree so both build systems share one copy. +// Only the manual-instrumentation core is included here; the arm64 +// objc_msgSend hook stays in the Xcode project. +// + +#include "../../appletrace/appletrace/src/appletrace.mm" diff --git a/Sources/CAppleTrace/include/CAppleTrace.h b/Sources/CAppleTrace/include/CAppleTrace.h new file mode 100644 index 0000000..55028b9 --- /dev/null +++ b/Sources/CAppleTrace/include/CAppleTrace.h @@ -0,0 +1,11 @@ +// +// CAppleTrace.h +// Public umbrella header for the SwiftPM C target. +// +// Re-exports the existing AppleTrace public C API (declared in the Xcode +// framework's header) so Swift can `import CAppleTrace` without duplicating +// the declarations. The single source of truth stays in +// appletrace/appletrace/src/appletrace.h. +// + +#import "../../../appletrace/appletrace/src/appletrace.h" diff --git a/Sources/CAppleTrace/include/module.modulemap b/Sources/CAppleTrace/include/module.modulemap new file mode 100644 index 0000000..84b7b6d --- /dev/null +++ b/Sources/CAppleTrace/include/module.modulemap @@ -0,0 +1,4 @@ +module CAppleTrace { + header "CAppleTrace.h" + export * +} diff --git a/docs/swift-tracing.md b/docs/swift-tracing.md new file mode 100644 index 0000000..21f5305 --- /dev/null +++ b/docs/swift-tracing.md @@ -0,0 +1,163 @@ +# Research: Tracing Swift Code in AppleTrace + +Status: **Phase 1 (Swift macros) implemented; auxiliary runtime hook pending.** +This document surveys how the industry traces Swift, why AppleTrace's current +`objc_msgSend` hook cannot, and lays out phased options. The chosen direction is +**source-level instrumentation via Swift macros as the primary route**, with a +SwiftTrace-style runtime hook as an optional secondary route. + +Implemented so far (SwiftPM package at the repo root — `Package.swift`, +`Sources/AppleTrace`, `Sources/AppleTraceMacrosPlugin`): +- `withSpan(_:_:)`, plus `beginSection` / `endSection` / `traceInstant` / + `traceCounter` / `asyncBegin` / `asyncEnd` / `flush` Swift wrappers over the C + core. +- `@Traced` (body macro) and `@TraceAll` (member-attribute macro), which wrap + function bodies in a `#function`-named section regardless of dispatch kind. + Verified on Swift 6.2 with no experimental feature flags. + +## 1. Problem + +AppleTrace's automatic mode rebinds `objc_msgSend` (a fishhook-style symbol +rebind, see `appletrace/appletrace/src/objc/hook_objc_msgSend.m`) and records a +B/E pair around every Objective-C message send. That captures essentially all +Objective-C dynamic dispatch, but **most Swift calls never go through +`objc_msgSend`**, so a pure-Swift app is almost invisible to the auto-hook. + +Swift deliberately avoids message dispatch for speed. Its calls resolve through +one of four mechanisms: + +| Dispatch | Used for | Goes through `objc_msgSend`? | +|---|---|---| +| **Static** (direct branch to a symbol) | `struct` / `enum` methods, `final` classes, global/free functions, and most internal methods under Whole-Module Optimization | ❌ never | +| **vtable** (per-class function-pointer table) | non-`final` `class` methods | ❌ | +| **witness table** (per-conformance table) | `protocol` requirements | ❌ | +| **`objc_msgSend`** | only `@objc dynamic` members and overrides on `NSObject` subclasses | ✅ | + +Consequence: in a modern Swift codebase the auto-hook sees only the thin `@objc` +surface. This is a structural limitation of the technique, not a bug. (Background: +[Method Dispatch in Swift](https://blog.jacobstechtavern.com/p/swift-method-dispatch), +[ObjC vs Swift dispatch](https://www.untitledkingdom.com/blog/objective-c-vs-swift-messages-dispatch).) + +## 2. Landscape of approaches + +Five families of solution exist, with representative open-source projects. + +### 2.1 Sampling / backtrace (language-agnostic) — *root fix* + +Instead of hooking each method, periodically capture the call stack of every +thread (or capture a synchronous backtrace at a few high-frequency native +trigger points), symbolicate, then diff consecutive stacks to reconstruct +per-function durations as Perfetto slices. Because it works on **native call +stacks**, it covers Swift, C, and Objective-C uniformly. + +- **[bytedance/btrace (RheaTrace)](https://github.com/bytedance/btrace)** — the + closest comparable to AppleTrace: **also Perfetto-based**, supports iOS, + embeddable, no Instruments required. btrace 3.0 uses a **hybrid model**: + *synchronous* backtraces triggered by hooks on high-frequency native points + (allocation, locks, I/O) plus *asynchronous* periodic sampling of all threads + for continuity. It computes durations by comparing consecutive stacks, then + emits Perfetto. (See its + [INTRODUCTION](https://github.com/bytedance/btrace/blob/master/INTRODUCTION.MD).) +- Apple **Instruments → Time Profiler** — same idea (periodic sampling), but + not embeddable and doesn't export Perfetto. + + - Coverage: ★★★ (all languages). Accuracy: statistical, not exact enter/exit. + - Effort: ★★★ (high). Risk: async-signal-safe stack walking, thread + suspension/dedup, symbolication, overhead control. + +### 2.2 Swift runtime metadata hooking — *precise, with blind spots* + +Patch Swift class **vtables** (and patchable **witness tables**) so each slot +points at a trampoline that logs the method, then chains to the original. + +- **[johnno1962/SwiftTrace](https://github.com/johnno1962/SwiftTrace)** — mature + (8 yrs). Replaces vtable pointers with an assembly trampoline. Can trace + non-`final` class methods and struct methods reached **through a protocol** + (witness table is patchable). **Cannot** trace `final`/internal methods under + WMO because those are statically linked at the call site — explicitly stated + in its README. +- **[p-x9/swift-hook](https://github.com/p-x9/swift-hook)** — newer Swift method + /function hooking library in the same spirit. + + - Coverage: ★★ (misses static dispatch). Accuracy: exact per-method. + - Effort: ★★ (medium). Risk: tracks Swift ABI / metadata layout, fragile + across toolchain versions. + +### 2.3 Compiler instrumentation — *fullest coverage, needs a rebuild* + +Clang's **`-finstrument-functions`** inserts `__cyg_profile_func_enter/exit` +calls at every function boundary +([how it works](https://balau82.wordpress.com/2010/10/06/trace-and-profile-function-calls-with-gcc/)). +For C/C++/Objective-C this catches **everything, including static dispatch**. + + - Caveat for Swift: there is **no first-class Swift frontend equivalent**. + Options would be a SIL instrumentation pass or repurposing + `-sanitize-coverage=func` (built for fuzzing) — both non-trivial and + unofficial. Realistically this path covers the C/C++/ObjC parts of a mixed + app well, and Swift only with significant toolchain work. + - Coverage: ★★★ (C/C++/ObjC), Swift TBD. Accuracy: exact. Effort: ★★ for + ObjC/C++, ★★★ for Swift. Risk: must compile target with the flag; large + overhead; not usable on prebuilt frameworks. + +### 2.4 os_signpost / manual instrumentation bridge — *low effort* + +Apple's **os_signpost** ("Points of Interest") is the official low-overhead +manual primitive, visualized in Instruments. The AppleTrace analogue is simply +giving Swift users an ergonomic manual API on top of the existing pipeline. + + - Coverage: only what the developer marks. Effort: ★ (low). Risk: minimal. + +### 2.5 Interpose / dynamic replacement — *single-point only* + +- **[steipete/InterposeKit](https://github.com/steipete/InterposeKit)**, Swift's + `@_dynamicReplacement` — good for hooking specific functions, not whole-app + tracing. Not a fit for "trace everything," but useful building blocks. + +## 3. Recommendation for AppleTrace (phased) + +Given AppleTrace's positioning (lightweight, embeddable, Perfetto-only, arm64), +go low-risk → root-fix: + +- **Phase 1 — Swift-friendly manual API (quick win, low risk).** A Swift overlay + exposing the existing `APT*` calls idiomatically: a scoped `withSpan("…") {}`, + `APTBeginSection`/`APTEndSection` wrappers, instants/counters/async, and — + Swift 5.9+ — a `@Traced` **macro** that wraps a function body in begin/end. + Reuses the current writer/merge pipeline; near-zero runtime risk; immediately + unblocks Swift users for the code they care about. + +- **Phase 2 — sampling/backtrace tracer (the actual Swift fix).** Follow btrace's + asynchronous model: periodic multi-thread stack capture + arm64 unwinding + + symbolication (`dladdr` + `swift_demangle`) + stack-diffing into Perfetto + slices. This is what genuinely makes pure Swift traceable automatically. + Largest effort; highest payoff. + +- **Phase 3 — optional precision add-ons.** + - 3a: SwiftTrace-style vtable/witness hooking for exact per-method traces + (accepting the `final`/static blind spot). + - 3b: opt-in `-finstrument-functions` for users who control their build and + want exact, full coverage of the C/C++/ObjC layers. + +## 4. Decision points + +1. **Precise vs. complete:** exact per-method (hook-based, has blind spots) or + statistical-but-complete (sampling, the btrace route)? +2. **Rebuild acceptable?** Is requiring `-finstrument-functions` / a custom + build step acceptable for any user segment, or must we work on prebuilt apps? +3. **Phase 1 standalone?** Ship the Swift manual API + `@Traced` macro first for + fast value, independent of the bigger Phase 2 work? +4. **Overhead budget & accuracy bar:** what runtime overhead and timing + resolution are acceptable in production-like builds? + +## 5. References + +- AppleTrace internals: `appletrace/appletrace/src/objc/hook_objc_msgSend.m`, + `docs/perf-batching-design.md`, `docs/binary-fragment-format.md`. +- [bytedance/btrace](https://github.com/bytedance/btrace) · + [INTRODUCTION](https://github.com/bytedance/btrace/blob/master/INTRODUCTION.MD) +- [johnno1962/SwiftTrace](https://github.com/johnno1962/SwiftTrace) · + [p-x9/swift-hook](https://github.com/p-x9/swift-hook) · + [steipete/InterposeKit](https://github.com/steipete/InterposeKit) +- [Method Dispatch in Swift](https://blog.jacobstechtavern.com/p/swift-method-dispatch) · + [ObjC vs Swift dispatch](https://www.untitledkingdom.com/blog/objective-c-vs-swift-messages-dispatch) +- [`-finstrument-functions` overview](https://balau82.wordpress.com/2010/10/06/trace-and-profile-function-calls-with-gcc/) + diff --git a/tests/AppleTraceTests/MacroExpansionTests.swift b/tests/AppleTraceTests/MacroExpansionTests.swift new file mode 100644 index 0000000..5585c10 --- /dev/null +++ b/tests/AppleTraceTests/MacroExpansionTests.swift @@ -0,0 +1,94 @@ +// +// MacroExpansionTests.swift +// Verifies the @Traced / @TraceAll macros expand to the expected source. +// + +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +import AppleTraceMacrosPlugin + +private let testMacros: [String: Macro.Type] = [ + "Traced": TracedMacro.self, + "TraceAll": TraceAllMacro.self, +] + +final class MacroExpansionTests: XCTestCase { + func testTracedWrapsBody() { + assertMacroExpansion( + """ + @Traced + func loadConfig() { + work() + } + """, + expandedSource: """ + func loadConfig() { + AppleTrace.beginSection(#function) + defer { + AppleTrace.endSection(#function) + } + work() + } + """, + macros: testMacros + ) + } + + func testTracedWrapsThrowingBodyWithReturn() { + assertMacroExpansion( + """ + @Traced + func value() throws -> Int { + return try compute() + } + """, + expandedSource: """ + func value() throws -> Int { + AppleTrace.beginSection(#function) + defer { + AppleTrace.endSection(#function) + } + return try compute() + } + """, + macros: testMacros + ) + } + + func testTraceAllStampsTracedOnMethods() { + assertMacroExpansion( + """ + @TraceAll + final class FeedViewModel { + let title = "Feed" + func reload() { + } + func render(_ x: Int) -> Int { + x + } + } + """, + expandedSource: """ + final class FeedViewModel { + let title = "Feed" + func reload() { + AppleTrace.beginSection(#function) + defer { + AppleTrace.endSection(#function) + } + } + func render(_ x: Int) -> Int { + AppleTrace.beginSection(#function) + defer { + AppleTrace.endSection(#function) + } + x + } + } + """, + macros: testMacros + ) + } +} diff --git a/tests/AppleTraceTests/MacroUsageTests.swift b/tests/AppleTraceTests/MacroUsageTests.swift new file mode 100644 index 0000000..b25e431 --- /dev/null +++ b/tests/AppleTraceTests/MacroUsageTests.swift @@ -0,0 +1,44 @@ +// +// MacroUsageTests.swift +// Compiles and runs code that actually applies @Traced / @TraceAll, proving +// the macros work end-to-end (not just in expansion tests) and emit events. +// + +import XCTest +@testable import AppleTrace + +@Traced +private func tracedFreeFunction() { + _ = (1...10).reduce(0, +) +} + +@TraceAll +private final class TracedSample { + func alpha() { + beta() + } + + func beta() { + _ = (1...10).reduce(0, +) + } +} + +final class MacroUsageTests: XCTestCase { + func testMacrosEmitSections() throws { + tracedFreeFunction() + TracedSample().alpha() + flush() + + let dir = traceDirectory + let fragments = try FileManager.default + .contentsOfDirectory(atPath: dir) + .filter { $0.hasSuffix(".appletrace") } + XCTAssertFalse(fragments.isEmpty) + + let url = URL(fileURLWithPath: dir).appendingPathComponent(fragments[0]) + let text = String(decoding: try Data(contentsOf: url).filter { $0 != 0 }, as: UTF8.self) + XCTAssertTrue(text.contains("tracedFreeFunction()"), "@Traced free function missing") + XCTAssertTrue(text.contains("alpha()"), "@TraceAll method alpha missing") + XCTAssertTrue(text.contains("beta()"), "@TraceAll method beta missing") + } +} diff --git a/tests/AppleTraceTests/RuntimeTests.swift b/tests/AppleTraceTests/RuntimeTests.swift new file mode 100644 index 0000000..44a681f --- /dev/null +++ b/tests/AppleTraceTests/RuntimeTests.swift @@ -0,0 +1,40 @@ +// +// RuntimeTests.swift +// End-to-end check that the Swift API drives the C core and writes a trace. +// + +import XCTest +@testable import AppleTrace + +final class RuntimeTests: XCTestCase { + func testWithSpanWritesFragment() throws { + withSpan("unit-test-span") { + _ = (1...1000).reduce(0, +) + } + traceInstant("unit-test-instant") + traceCounter("unit-test-counter", 42) + flush() + + let dir = traceDirectory + let fragments = try FileManager.default + .contentsOfDirectory(atPath: dir) + .filter { $0.hasSuffix(".appletrace") } + XCTAssertFalse(fragments.isEmpty, "expected a trace fragment in \(dir)") + + let url = URL(fileURLWithPath: dir).appendingPathComponent(fragments[0]) + let bytes = try Data(contentsOf: url) + // Fragments are zero-padded mmap blocks; strip the padding before search. + let text = String(decoding: bytes.filter { $0 != 0 }, as: UTF8.self) + XCTAssertTrue(text.contains("unit-test-span"), "section name missing from trace") + XCTAssertTrue(text.contains("unit-test-instant"), "instant missing from trace") + } + + func testToggleEnabled() { + let original = isEnabled + setEnabled(false) + XCTAssertFalse(isEnabled) + setEnabled(true) + XCTAssertTrue(isEnabled) + setEnabled(original) + } +} From d4e2d1e00019b6ca8374d56fe342739aab67a6d0 Mon Sep 17 00:00:00 2001 From: everettjf Date: Sun, 24 May 2026 13:34:49 -0700 Subject: [PATCH 2/6] feat(swift): SwiftTrace bridge (secondary route) + example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AppleTraceAuto, an optional product that bridges the proven johnno1962/SwiftTrace runtime hook into AppleTrace: a SwiftTrace.Decorated subclass forwards each traced method's entry/exit to APTBeginSection / APTEndSection, so zero-annotation auto-tracing of Swift class hierarchies lands in the same Perfetto trace. API: AppleTraceAuto.trace(aClass:) / traceClasses(matchingPattern:) / traceBundle(containing:) / stop(). Per SwiftTrace's design it can't see final / statically-dispatched methods — the @Traced / @TraceAll macros cover those, so the two routes complement each other. Verification: the SwiftTrace vtable patch can't be exercised from an XCTest bundle (its metadata scanning needs a normal executable / app image), so the bridge is verified by the runnable AppleTraceAutoExample target instead — `swift run AppleTraceAutoExample` traces a non-final class through a protocol and asserts the methods land in the trace (exits non-zero otherwise). The macro + runtime XCTests stay green. README / README_CN gain a "Tracing Swift" section; docs/swift-tracing.md records both routes as implemented. Co-Authored-By: Claude Opus 4.7 (1M context) --- Package.resolved | 11 ++- Package.swift | 24 +++++++ README.md | 38 +++++++++++ README_CN.md | 38 +++++++++++ Sources/AppleTraceAuto/AppleTraceAuto.swift | 63 ++++++++++++++++++ Sources/AppleTraceAutoExample/main.swift | 74 +++++++++++++++++++++ docs/swift-tracing.md | 35 ++++++---- 7 files changed, 268 insertions(+), 15 deletions(-) create mode 100644 Sources/AppleTraceAuto/AppleTraceAuto.swift create mode 100644 Sources/AppleTraceAutoExample/main.swift diff --git a/Package.resolved b/Package.resolved index 7b3a67b..7dd3e1e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b2e6d3bc48895e7f76c0a6c9afd620e1f2c8124303987d6dbe81d1f6d8012e67", + "originHash" : "76657f316e4e4ecfe7c536d93c17ee34f9ec7da3dcb505dea413138c3222211f", "pins" : [ { "identity" : "swift-syntax", @@ -9,6 +9,15 @@ "revision" : "0687f71944021d616d34d922343dcef086855920", "version" : "600.0.1" } + }, + { + "identity" : "swifttrace", + "kind" : "remoteSourceControl", + "location" : "https://github.com/johnno1962/SwiftTrace.git", + "state" : { + "revision" : "589f37149d5b32cbdd44c70809df23b4f4fa0260", + "version" : "8.6.1" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 7c5a0ed..1a35eef 100644 --- a/Package.swift +++ b/Package.swift @@ -10,9 +10,11 @@ let package = Package( ], products: [ .library(name: "AppleTrace", targets: ["AppleTrace"]), + .library(name: "AppleTraceAuto", targets: ["AppleTraceAuto"]), ], dependencies: [ .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0"), + .package(url: "https://github.com/johnno1962/SwiftTrace.git", from: "8.6.0"), ], targets: [ // The existing Objective-C++ trace core, reused from the Xcode tree. @@ -26,6 +28,19 @@ let package = Package( dependencies: ["CAppleTrace", "AppleTraceMacrosPlugin"] ), + // Secondary route: zero-annotation auto-tracing by bridging the proven + // SwiftTrace runtime hook into AppleTrace events. + .target( + name: "AppleTraceAuto", + dependencies: [ + "CAppleTrace", + .product(name: "SwiftTrace", package: "SwiftTrace"), + ], + // SwiftTrace's API relies on global mutable state (swizzleFactory); + // build this thin bridge in the Swift 5 language mode to match it. + swiftSettings: [.swiftLanguageMode(.v5)] + ), + // SwiftSyntax compiler plugin implementing the macros. .macro( name: "AppleTraceMacrosPlugin", @@ -37,10 +52,19 @@ let package = Package( ] ), + // Runnable demo + smoke check for the SwiftTrace bridge (which an + // XCTest bundle can't exercise — see the file header). + .executableTarget( + name: "AppleTraceAutoExample", + dependencies: ["AppleTrace", "AppleTraceAuto"], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .testTarget( name: "AppleTraceTests", dependencies: [ "AppleTrace", + "AppleTraceAuto", "AppleTraceMacrosPlugin", .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), ] diff --git a/README.md b/README.md index 1b09568..9956621 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,44 @@ void saferCppFunction() { } ``` +### Tracing Swift (SwiftPM) + +The `objc_msgSend` hook can't see Swift's static / vtable / witness dispatch, so +Swift is traced at the **source level**. Add the package +(`https://github.com/everettjf/AppleTrace.git`) and `import AppleTrace`: + +```swift +import AppleTrace + +// Scoped span (closes even on throw / early return): +withSpan("loadFeed") { try? loadFeed() } + +// Or annotate — works for final classes, structs, and protocol methods alike, +// because the begin/end is inserted into the body at compile time: +@Traced +func decodeImage() { /* ... */ } + +@TraceAll // stamps @Traced on every method with a body +final class FeedViewModel { + func reload() { /* traced */ } + func render() { /* traced */ } +} + +APTFlush() // (or AppleTrace.flush()) before pulling the trace +``` + +Want zero-annotation auto-tracing of a class hierarchy? The optional +`AppleTraceAuto` product bridges [SwiftTrace](https://github.com/johnno1962/SwiftTrace): + +```swift +import AppleTraceAuto +AppleTraceAuto.trace(aClass: FeedViewModel.self) // entry/exit → AppleTrace +``` + +`AppleTraceAuto` can't see `final` / statically-dispatched methods (SwiftTrace's +blind spot) — use the macros for those. See `docs/swift-tracing.md` and the +runnable `AppleTraceAutoExample` (`swift run AppleTraceAutoExample`). + ### Instant Markers, Counters & Async Events ```objc diff --git a/README_CN.md b/README_CN.md index fc77ef4..993018b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -256,6 +256,44 @@ void saferCppFunction() { } ``` +### 追踪 Swift 代码(SwiftPM) + +`objc_msgSend` hook 看不到 Swift 的静态 / vtable / witness 派发,所以 Swift 走 +**源码级埋点**。把本仓库作为 SwiftPM 依赖添加 +(`https://github.com/everettjf/AppleTrace.git`),然后 `import AppleTrace`: + +```swift +import AppleTrace + +// 作用域 span(即使 throw / 提前返回也会闭合): +withSpan("loadFeed") { try? loadFeed() } + +// 或用宏标注——对 final 类、struct、protocol 方法都生效, +// 因为 begin/end 是在编译期插入函数体的: +@Traced +func decodeImage() { /* ... */ } + +@TraceAll // 给每个有函数体的方法都自动加 @Traced +final class FeedViewModel { + func reload() { /* 已追踪 */ } + func render() { /* 已追踪 */ } +} + +APTFlush() // (或 AppleTrace.flush())读取 trace 前先 flush +``` + +想要零标注地自动追踪整个类层级?可选的 `AppleTraceAuto` product 桥接了 +[SwiftTrace](https://github.com/johnno1962/SwiftTrace): + +```swift +import AppleTraceAuto +AppleTraceAuto.trace(aClass: FeedViewModel.self) // 进入/退出 → AppleTrace +``` + +`AppleTraceAuto` 看不到 `final` / 静态派发的方法(SwiftTrace 的盲区)——这类用宏。 +详见 `docs/swift-tracing.md` 与可运行的 `AppleTraceAutoExample` +(`swift run AppleTraceAutoExample`)。 + ### 瞬时标记、计数器与异步事件 ```objc diff --git a/Sources/AppleTraceAuto/AppleTraceAuto.swift b/Sources/AppleTraceAuto/AppleTraceAuto.swift new file mode 100644 index 0000000..c36511e --- /dev/null +++ b/Sources/AppleTraceAuto/AppleTraceAuto.swift @@ -0,0 +1,63 @@ +// +// AppleTraceAuto.swift +// Secondary (zero-annotation) Swift tracing route. +// +// Bridges the proven SwiftTrace runtime hook into AppleTrace's pipeline: +// SwiftTrace patches Swift vtables/witness tables with trampolines, and this +// module forwards each traced method's entry/exit to APTBeginSection / +// APTEndSection so the calls land in the same Perfetto trace as everything +// else. +// +// This is a development/diagnostics tool. Per SwiftTrace's own limitations it +// cannot see `final`/internal methods that the optimizer dispatches directly +// (use the @Traced / @TraceAll macros for those). For exact, dispatch-agnostic +// coverage of your own code, prefer the macros; use this when you want +// zero-annotation coverage of class hierarchies. + +import CAppleTrace +import SwiftTrace + +/// A SwiftTrace swizzle that emits an AppleTrace section spanning each traced +/// method invocation. +final class AppleTraceSwizzle: SwiftTrace.Decorated { + override func onEntry(stack: inout SwiftTrace.EntryStack, invocation: Invocation) { + APTBeginSection(signature) + super.onEntry(stack: &stack, invocation: invocation) + } + + override func onExit(stack: inout SwiftTrace.ExitStack, invocation: Invocation) { + super.onExit(stack: &stack, invocation: invocation) + APTEndSection(signature) + } +} + +public enum AppleTraceAuto { + /// Routes SwiftTrace through AppleTrace. Called automatically by the + /// `trace*` helpers; call it yourself before using SwiftTrace's own API. + public static func installBridge() { + SwiftTrace.swizzleFactory = AppleTraceSwizzle.self + } + + /// Traces every method of `aClass` into the AppleTrace timeline. + public static func trace(aClass: AnyClass) { + installBridge() + SwiftTrace.trace(aClass: aClass) + } + + /// Traces classes whose name matches `pattern` (a regular expression). + public static func traceClasses(matchingPattern pattern: String) { + installBridge() + SwiftTrace.traceClasses(matchingPattern: pattern) + } + + /// Traces all classes in the bundle that defines `aClass`. + public static func traceBundle(containing aClass: AnyClass) { + installBridge() + SwiftTrace.traceBundle(containing: aClass) + } + + /// Removes all installed traces. + public static func stop() { + SwiftTrace.removeAllTraces() + } +} diff --git a/Sources/AppleTraceAutoExample/main.swift b/Sources/AppleTraceAutoExample/main.swift new file mode 100644 index 0000000..8251ca0 --- /dev/null +++ b/Sources/AppleTraceAutoExample/main.swift @@ -0,0 +1,74 @@ +// +// AppleTraceAutoExample +// Runnable demonstration + smoke check for both Swift tracing routes. +// +// Run it with: +// APPLETRACE_DATA_DIR=/tmp/aptdemo swift run AppleTraceAutoExample +// then merge and open the trace: +// python3 merge.py -d /tmp/aptdemo # → trace.json in ui.perfetto.dev +// +// It also doubles as the verification for the SwiftTrace bridge, which can't +// be exercised from an XCTest bundle (SwiftTrace's metadata scanning needs a +// normal executable / app image). Exits non-zero if the bridge didn't trace. +// + +import Foundation +import AppleTrace +import AppleTraceAuto + +// MARK: - A small workload + +// Reached through a protocol so calls dispatch dynamically — what the +// SwiftTrace route can hook. (final / exact-typed calls are devirtualized and +// only the @Traced / @TraceAll macros can see them.) +protocol Service { + func fetch() + func parse() +} + +// Note: not `final`. A final class is statically dispatched, which SwiftTrace +// can't hook (use @Traced / @TraceAll for those); this is the route's blind spot. +class FeedService: Service { + func fetch() { Thread.sleep(forTimeInterval: 0.002) } + func parse() { Thread.sleep(forTimeInterval: 0.001) } +} + +// MARK: - Run + +// Secondary route: zero-annotation auto-tracing via SwiftTrace. +AppleTraceAuto.trace(aClass: FeedService.self) + +// Primary route: explicit scoped span (works regardless of dispatch). +withSpan("startup") { + let service: Service = FeedService() + for _ in 0..<3 { + service.fetch() + service.parse() + } +} +traceInstant("ready") +flush() + +// Verify the bridge actually captured the auto-traced methods. +let dir = traceDirectory +let fragments = (try? FileManager.default + .contentsOfDirectory(atPath: dir) + .filter { $0.hasSuffix(".appletrace") }) ?? [] +let text = fragments.reduce(into: "") { result, name in + let url = URL(fileURLWithPath: dir).appendingPathComponent(name) + if let data = try? Data(contentsOf: url) { + result += String(decoding: data.filter { $0 != 0 }, as: UTF8.self) + } +} + +let tracedFetch = text.contains("fetch") +let tracedParse = text.contains("parse") +let hasSpan = text.contains("startup") + +print("AppleTrace example → directory: \(dir)") +print(" manual span 'startup': \(hasSpan ? "✅" : "❌")") +print(" SwiftTrace auto fetch(): \(tracedFetch ? "✅" : "❌")") +print(" SwiftTrace auto parse(): \(tracedParse ? "✅" : "❌")") +print("Merge with: python3 merge.py -d \"\(dir)\" then open trace.json in ui.perfetto.dev") + +exit(hasSpan && tracedFetch && tracedParse ? 0 : 1) diff --git a/docs/swift-tracing.md b/docs/swift-tracing.md index 21f5305..f7d7652 100644 --- a/docs/swift-tracing.md +++ b/docs/swift-tracing.md @@ -1,19 +1,26 @@ # Research: Tracing Swift Code in AppleTrace -Status: **Phase 1 (Swift macros) implemented; auxiliary runtime hook pending.** -This document surveys how the industry traces Swift, why AppleTrace's current -`objc_msgSend` hook cannot, and lays out phased options. The chosen direction is -**source-level instrumentation via Swift macros as the primary route**, with a -SwiftTrace-style runtime hook as an optional secondary route. - -Implemented so far (SwiftPM package at the repo root — `Package.swift`, -`Sources/AppleTrace`, `Sources/AppleTraceMacrosPlugin`): -- `withSpan(_:_:)`, plus `beginSection` / `endSection` / `traceInstant` / - `traceCounter` / `asyncBegin` / `asyncEnd` / `flush` Swift wrappers over the C - core. -- `@Traced` (body macro) and `@TraceAll` (member-attribute macro), which wrap - function bodies in a `#function`-named section regardless of dispatch kind. - Verified on Swift 6.2 with no experimental feature flags. +Status: **Both routes implemented.** This document surveys how the industry +traces Swift, why AppleTrace's current `objc_msgSend` hook cannot, and lays out +the chosen direction: **source-level instrumentation via Swift macros as the +primary route**, with a SwiftTrace-backed runtime hook as the secondary route. + +Implemented (SwiftPM package at the repo root — `Package.swift`, `Sources/`): +- **Primary (macros), `AppleTrace` target:** `withSpan(_:_:)`, plus + `beginSection` / `endSection` / `traceInstant` / `traceCounter` / + `asyncBegin` / `asyncEnd` / `flush` wrappers over the C core; and `@Traced` + (body macro) + `@TraceAll` (member-attribute macro) that wrap function bodies + in a `#function`-named section regardless of dispatch kind. Verified on + Swift 6.2 with no experimental feature flags (`tests/AppleTraceTests`). +- **Secondary (SwiftTrace bridge), `AppleTraceAuto` target:** bridges + `johnno1962/SwiftTrace` so each traced method's entry/exit becomes an + AppleTrace section (`AppleTraceAuto.trace(aClass:)` / + `traceClasses(matchingPattern:)` / `traceBundle(containing:)`). Zero + annotation; subject to SwiftTrace's blind spot (`final` / statically-dispatched + methods — use the macros for those). Verified via the runnable + `AppleTraceAutoExample` target (`swift run AppleTraceAutoExample`); it can't be + exercised from an XCTest bundle because SwiftTrace's metadata scanning needs a + normal executable / app image. ## 1. Problem From 152c43033ce2174cca21bd11c5eecb27f3b1e3a4 Mon Sep 17 00:00:00 2001 From: everettjf Date: Sun, 24 May 2026 13:37:25 -0700 Subject: [PATCH 3/6] ci: add Swift package workflow (test + example smoke + iOS build) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/swift-tests.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/swift-tests.yml diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml new file mode 100644 index 0000000..806beaf --- /dev/null +++ b/.github/workflows/swift-tests.yml @@ -0,0 +1,31 @@ +name: Swift Tests + +on: + push: + pull_request: + +jobs: + swift-package: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Swift version + run: swift --version + + - name: Run package tests (macros + runtime) + run: swift test + env: + APPLETRACE_DATA_DIR: ${{ runner.temp }}/appletracedata-tests + + - name: Smoke-test the SwiftTrace bridge (executable) + run: swift run AppleTraceAutoExample + env: + APPLETRACE_DATA_DIR: ${{ runner.temp }}/appletracedata-example + + - name: Build both routes for the iOS Simulator + run: | + xcodebuild -scheme AppleTrace \ + -destination 'generic/platform=iOS Simulator' build + xcodebuild -scheme AppleTraceAuto \ + -destination 'generic/platform=iOS Simulator' build From b8971928ed836f75020443ec245e8094d73fa1a0 Mon Sep 17 00:00:00 2001 From: everettjf Date: Sun, 24 May 2026 13:51:54 -0700 Subject: [PATCH 4/6] demo(swift): add AppleTraceSwiftDemo + harden the SwiftTrace bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds sample/AppleTraceSwiftDemo, a SwiftUI app that consumes the local SwiftPM package and demonstrates both Swift tracing routes in one guided app: - macros / withSpan: an "App Launch" span tree, an @Traced free function, a @TraceAll final class, async arcs, counters, and a 60-frame render loop across named worker threads; - AppleTraceAuto: zero-annotation hooking of a non-final ImageLoader reached through a protocol. Tap "Generate Trace" (or set APPLETRACE_AUTORUN=1) and the screen shows the trace directory plus the exact merge/Perfetto steps. Verified on the iPhone 17 Simulator — ~490 events across 5 named tracks, both routes present, no hang — and built + signed for a real device. Bridge hardening (Sources/AppleTraceAuto): subclass the lightweight SwiftTrace.Swizzle instead of Decorated and skip super. The trampoline does the real call and only uses onEntry/onExit as observers, so emitting begin/end is enough; Decorated's argument-reflection/logging path hung in the iOS app context. The AppleTraceAutoExample smoke check still passes. Docs: README / README_CN gain a samples table; AGENT.md and docs/swift-tracing.md describe the demo and the `-destination`-only build note (a stray `-sdk iphonesimulator` forces the macro plugin onto the wrong SDK). Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENT.md | 3 +- README.md | 15 +- README_CN.md | 15 +- Sources/AppleTraceAuto/AppleTraceAuto.swift | 10 +- docs/swift-tracing.md | 8 + .../project.pbxproj | 260 ++++++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 24 ++ .../AppleTraceSwiftDemoApp.swift | 15 + .../AppleTraceSwiftDemo/ContentView.swift | 118 ++++++++ .../AppleTraceSwiftDemo/Showcase.swift | 147 ++++++++++ 10 files changed, 607 insertions(+), 8 deletions(-) create mode 100644 sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj/project.pbxproj create mode 100644 sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/AppleTraceSwiftDemoApp.swift create mode 100644 sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/ContentView.swift create mode 100644 sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/Showcase.swift diff --git a/AGENT.md b/AGENT.md index 9af41ed..aa97c45 100644 --- a/AGENT.md +++ b/AGENT.md @@ -11,7 +11,8 @@ This reference is for AI agents and contributors working inside the AppleTrace r ## Repository Map - `appletrace/` — Core framework sources (`appletrace.xcodeproj`, Objective-C runtime hooks, exported headers). - `loader/` — Loader/packaging project plus `resign.sh` for re-signing the embedded `appletrace.framework`. -- `sample/ManualSectionDemo` and `sample/TraceAllMsgDemo` — Xcode samples that show manual instrumentation and automatic `objc_msgSend` tracing. +- `sample/ManualSectionDemo` and `sample/TraceAllMsgDemo` — Objective-C Xcode samples that show manual instrumentation and automatic `objc_msgSend` tracing. +- `sample/AppleTraceSwiftDemo` — Swift sample (SwiftUI) that consumes the local SwiftPM package and demonstrates both Swift routes: the `@Traced`/`@TraceAll`/`withSpan` macros and the `AppleTraceAuto` SwiftTrace bridge. Build with `-destination` only (no `-sdk`, which would force the macro plugin onto the wrong SDK). - `springboard/AppleTraceSpringBoard` — Additional loader project for SpringBoard-focused experiments. - `hookzz/` — Legacy embedded HookZz dependency (the current `objc_msgSend` hook uses a direct symbol rebind instead). - `go.sh`, `merge.py`, `scripts/appletrace_cli.py` — Scripts for merging trace fragments into `trace.json` and opening Perfetto. diff --git a/README.md b/README.md index 9956621..0746c80 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,19 @@ sh go.sh "" On the Simulator the directory is already on your Mac. On a device, pull the app container first (Xcode ▸ *Window ▸ Devices and Simulators ▸ Download Container*, or `xcrun devicectl device copy from …`) — the app shows the full -command. `sample/TraceAllMsgDemo` is the companion sample for the automatic -`objc_msgSend` hook. +command. + +Three samples are included: + +| Sample | Language | Shows | +|--------|----------|-------| +| `sample/ManualSectionDemo` | Objective-C | Manual `APTBeginSection` sections, counters, async, threads | +| `sample/AppleTraceSwiftDemo` | Swift | `@Traced` / `@TraceAll` / `withSpan` macros **and** the `AppleTraceAuto` SwiftTrace auto-hook | +| `sample/TraceAllMsgDemo` | Objective-C | Automatic `objc_msgSend` hook | + +The Swift demo consumes the local SwiftPM package, so open it from the repo +root (`open sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj`) and Xcode +resolves the `AppleTrace` / `AppleTraceAuto` products automatically. ### Mode A — Manual Instrumentation (recommended baseline) diff --git a/README_CN.md b/README_CN.md index 993018b..17c9135 100644 --- a/README_CN.md +++ b/README_CN.md @@ -119,8 +119,19 @@ sh go.sh "" 模拟器上该目录就在你的 Mac 本地。真机上需先拉取 App 容器(Xcode ▸ *Window ▸ Devices and Simulators ▸ Download Container*,或 `xcrun devicectl device -copy from …`)——App 内会显示完整命令。`sample/TraceAllMsgDemo` 则是自动 -`objc_msgSend` hook 的配套示例。 +copy from …`)——App 内会显示完整命令。 + +仓库内含三个示例: + +| 示例 | 语言 | 演示内容 | +|------|------|---------| +| `sample/ManualSectionDemo` | Objective-C | 手动 `APTBeginSection`、counter、async、多线程 | +| `sample/AppleTraceSwiftDemo` | Swift | `@Traced` / `@TraceAll` / `withSpan` 宏,**以及** `AppleTraceAuto` 的 SwiftTrace 自动 hook | +| `sample/TraceAllMsgDemo` | Objective-C | 自动 `objc_msgSend` hook | + +Swift demo 依赖本地 SwiftPM 包,从仓库根目录打开 +(`open sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj`),Xcode 会自动 +解析 `AppleTrace` / `AppleTraceAuto` 两个 product。 ### 模式 A — 手动埋点(推荐基线) diff --git a/Sources/AppleTraceAuto/AppleTraceAuto.swift b/Sources/AppleTraceAuto/AppleTraceAuto.swift index c36511e..739a331 100644 --- a/Sources/AppleTraceAuto/AppleTraceAuto.swift +++ b/Sources/AppleTraceAuto/AppleTraceAuto.swift @@ -19,14 +19,18 @@ import SwiftTrace /// A SwiftTrace swizzle that emits an AppleTrace section spanning each traced /// method invocation. -final class AppleTraceSwizzle: SwiftTrace.Decorated { +/// +/// It subclasses the lightweight `Swizzle` (not `Decorated`) and deliberately +/// does not call `super`: the trampoline performs the real call and only uses +/// `onEntry`/`onExit` as observers, so emitting begin/end here is sufficient. +/// This avoids `Decorated`'s argument-reflection/logging path, which is heavier +/// and less robust across threads and platforms. +final class AppleTraceSwizzle: SwiftTrace.Swizzle { override func onEntry(stack: inout SwiftTrace.EntryStack, invocation: Invocation) { APTBeginSection(signature) - super.onEntry(stack: &stack, invocation: invocation) } override func onExit(stack: inout SwiftTrace.ExitStack, invocation: Invocation) { - super.onExit(stack: &stack, invocation: invocation) APTEndSection(signature) } } diff --git a/docs/swift-tracing.md b/docs/swift-tracing.md index f7d7652..213f765 100644 --- a/docs/swift-tracing.md +++ b/docs/swift-tracing.md @@ -21,6 +21,14 @@ Implemented (SwiftPM package at the repo root — `Package.swift`, `Sources/`): `AppleTraceAutoExample` target (`swift run AppleTraceAutoExample`); it can't be exercised from an XCTest bundle because SwiftTrace's metadata scanning needs a normal executable / app image. +- **Demo app:** `sample/AppleTraceSwiftDemo` (SwiftUI) consumes the local package + and exercises both routes in one guided app — tap "Generate Trace" to run a + multi-threaded workload (~490 events across 5 named tracks: macro-route spans, + the SwiftTrace-hooked `ImageLoader`, counters, async arcs) and see the steps to + open it in Perfetto. Verified on the iPhone 17 Simulator and built+signed for + device. Note: the bridge subclasses the lightweight `Swizzle` (not `Decorated`) + and skips `super` — `Decorated`'s argument-reflection path hung in the iOS + app context. ## 1. Problem diff --git a/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj/project.pbxproj b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj/project.pbxproj new file mode 100644 index 0000000..ea67a6a --- /dev/null +++ b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj/project.pbxproj @@ -0,0 +1,260 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + D5A7C0DE0000000000000013 /* AppleTraceSwiftDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A7C0DE0000000000000010 /* AppleTraceSwiftDemoApp.swift */; }; + D5A7C0DE0000000000000014 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A7C0DE0000000000000011 /* ContentView.swift */; }; + D5A7C0DE0000000000000015 /* Showcase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A7C0DE0000000000000012 /* Showcase.swift */; }; + D5A7C0DE0000000000000019 /* AppleTrace in Frameworks */ = {isa = PBXBuildFile; productRef = D5A7C0DE0000000000000017 /* AppleTrace */; }; + D5A7C0DE000000000000001A /* AppleTraceAuto in Frameworks */ = {isa = PBXBuildFile; productRef = D5A7C0DE0000000000000018 /* AppleTraceAuto */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + D5A7C0DE000000000000000B /* AppleTraceSwiftDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppleTraceSwiftDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D5A7C0DE0000000000000010 /* AppleTraceSwiftDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleTraceSwiftDemoApp.swift; sourceTree = ""; }; + D5A7C0DE0000000000000011 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + D5A7C0DE0000000000000012 /* Showcase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Showcase.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + D5A7C0DE0000000000000009 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D5A7C0DE0000000000000019 /* AppleTrace in Frameworks */, + D5A7C0DE000000000000001A /* AppleTraceAuto in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + D5A7C0DE0000000000000002 = { + isa = PBXGroup; + children = ( + D5A7C0DE0000000000000003 /* AppleTraceSwiftDemo */, + D5A7C0DE0000000000000004 /* Products */, + ); + sourceTree = ""; + }; + D5A7C0DE0000000000000003 /* AppleTraceSwiftDemo */ = { + isa = PBXGroup; + children = ( + D5A7C0DE0000000000000010 /* AppleTraceSwiftDemoApp.swift */, + D5A7C0DE0000000000000011 /* ContentView.swift */, + D5A7C0DE0000000000000012 /* Showcase.swift */, + ); + path = AppleTraceSwiftDemo; + sourceTree = ""; + }; + D5A7C0DE0000000000000004 /* Products */ = { + isa = PBXGroup; + children = ( + D5A7C0DE000000000000000B /* AppleTraceSwiftDemo.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + D5A7C0DE0000000000000005 /* AppleTraceSwiftDemo */ = { + isa = PBXNativeTarget; + buildConfigurationList = D5A7C0DE0000000000000006 /* Build configuration list for PBXNativeTarget "AppleTraceSwiftDemo" */; + buildPhases = ( + D5A7C0DE0000000000000008 /* Sources */, + D5A7C0DE0000000000000009 /* Frameworks */, + D5A7C0DE000000000000000A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AppleTraceSwiftDemo; + packageProductDependencies = ( + D5A7C0DE0000000000000017 /* AppleTrace */, + D5A7C0DE0000000000000018 /* AppleTraceAuto */, + ); + productName = AppleTraceSwiftDemo; + productReference = D5A7C0DE000000000000000B /* AppleTraceSwiftDemo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + D5A7C0DE0000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + D5A7C0DE0000000000000005 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = D5A7C0DE0000000000000007 /* Build configuration list for PBXProject "AppleTraceSwiftDemo" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = D5A7C0DE0000000000000002; + packageReferences = ( + D5A7C0DE0000000000000016 /* XCLocalSwiftPackageReference "../.." */, + ); + productRefGroup = D5A7C0DE0000000000000004 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + D5A7C0DE0000000000000005 /* AppleTraceSwiftDemo */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + D5A7C0DE000000000000000A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + D5A7C0DE0000000000000008 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D5A7C0DE0000000000000014 /* ContentView.swift in Sources */, + D5A7C0DE0000000000000013 /* AppleTraceSwiftDemoApp.swift in Sources */, + D5A7C0DE0000000000000015 /* Showcase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + D5A7C0DE000000000000000C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_OPTIMIZATION_LEVEL = 0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + D5A7C0DE000000000000000D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + D5A7C0DE000000000000000E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YPV49M8592; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.everettjf.AppleTraceSwiftDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D5A7C0DE000000000000000F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YPV49M8592; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.everettjf.AppleTraceSwiftDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + D5A7C0DE0000000000000006 /* Build configuration list for PBXNativeTarget "AppleTraceSwiftDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D5A7C0DE000000000000000E /* Debug */, + D5A7C0DE000000000000000F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D5A7C0DE0000000000000007 /* Build configuration list for PBXProject "AppleTraceSwiftDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D5A7C0DE000000000000000C /* Debug */, + D5A7C0DE000000000000000D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D5A7C0DE0000000000000016 /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D5A7C0DE0000000000000017 /* AppleTrace */ = { + isa = XCSwiftPackageProductDependency; + productName = AppleTrace; + }; + D5A7C0DE0000000000000018 /* AppleTraceAuto */ = { + isa = XCSwiftPackageProductDependency; + productName = AppleTraceAuto; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = D5A7C0DE0000000000000001 /* Project object */; +} diff --git a/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4999939 --- /dev/null +++ b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "6300281be3efd3fcbe51463ca04762a38199675a4f996552570d2630bb611e2e", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swifttrace", + "kind" : "remoteSourceControl", + "location" : "https://github.com/johnno1962/SwiftTrace.git", + "state" : { + "revision" : "589f37149d5b32cbdd44c70809df23b4f4fa0260", + "version" : "8.6.1" + } + } + ], + "version" : 3 +} diff --git a/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/AppleTraceSwiftDemoApp.swift b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/AppleTraceSwiftDemoApp.swift new file mode 100644 index 0000000..1b8ac03 --- /dev/null +++ b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/AppleTraceSwiftDemoApp.swift @@ -0,0 +1,15 @@ +// +// AppleTraceSwiftDemoApp.swift +// Entry point for the Swift tracing demo. +// + +import SwiftUI + +@main +struct AppleTraceSwiftDemoApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/ContentView.swift b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/ContentView.swift new file mode 100644 index 0000000..04c0bd9 --- /dev/null +++ b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/ContentView.swift @@ -0,0 +1,118 @@ +// +// ContentView.swift +// Guided UI: tap "Generate Trace" to run the Swift showcase, then follow the +// on-screen steps to merge and open the trace in Perfetto on your Mac. +// + +import SwiftUI +import AppleTrace + +struct ContentView: View { + @State private var status = "Ready." + @State private var statusColor: Color = .secondary + @State private var traceDir = traceDirectory + @State private var isRunning = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("AppleTrace 🍎") + .font(.largeTitle.bold()) + Text("Swift demo — macros (`@Traced` / `@TraceAll` / `withSpan`) plus the optional SwiftTrace auto-hook.") + .font(.callout) + .foregroundStyle(.secondary) + + Button(action: generate) { + Text(isRunning ? "Generating…" : "▶︎ Generate Trace") + .font(.headline) + .frame(maxWidth: .infinity, minHeight: 52) + } + .buttonStyle(.borderedProminent) + .disabled(isRunning) + + Text(status) + .font(.subheadline) + .foregroundStyle(statusColor) + + card(title: "Trace directory on this build") { + Text(traceDir).textSelection(.enabled) + } + + card(title: "Next steps on your Mac") { + Text(guidance).textSelection(.enabled) + } + } + .padding(20) + } + .onAppear { + // Convenience for automation (smoke tests / CI): run once without a + // tap if APPLETRACE_AUTORUN is set. Normal use is the button. + if ProcessInfo.processInfo.environment["APPLETRACE_AUTORUN"] != nil { + generate() + } + } + } + + private func card(title: String, @ViewBuilder _ content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline) + content() + .font(.system(.footnote, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Color(white: 0.96))) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(white: 0.85))) + } + } + + private func generate() { + isRunning = true + status = "Generating trace…" + statusColor = .secondary + DispatchQueue.global(qos: .userInitiated).async { + let dir = SwiftShowcase.run() + DispatchQueue.main.async { + traceDir = dir + status = "✅ Trace generated across multiple threads. Follow the steps below." + statusColor = .green + isRunning = false + } + } + } + + private var guidance: String { + let dir = traceDir + #if targetEnvironment(simulator) + return """ + Running in the Simulator, so the trace is already on this Mac. + + 1. Tap “Generate Trace” above. + 2. In the AppleTrace repo, merge the fragments: + python3 merge.py -d "\(dir)" + (or: sh go.sh "\(dir)" ) + 3. Open https://ui.perfetto.dev and drag in: + \(dir)/trace.json + """ + #else + return """ + Running on a device, so copy the trace to your Mac first. + + 1. Tap “Generate Trace” above. + 2. Pull the app’s container, either: + • Xcode ▸ Window ▸ Devices and Simulators ▸ + select this app ▸ ⚙ ▸ Download Container, or + • xcrun devicectl device copy from --device \\ + --domain-type appDataContainer \\ + --domain-identifier com.everettjf.AppleTraceSwiftDemo \\ + --source Library/appletracedata --destination ./trace + 3. Merge and open in Perfetto: + python3 merge.py -d /Library/appletracedata + then drag trace.json into https://ui.perfetto.dev + """ + #endif + } +} + +#Preview { + ContentView() +} diff --git a/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/Showcase.swift b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/Showcase.swift new file mode 100644 index 0000000..a0a49a8 --- /dev/null +++ b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/Showcase.swift @@ -0,0 +1,147 @@ +// +// Showcase.swift +// A curated Swift workload that exercises BOTH Swift tracing routes and emits +// a rich Perfetto timeline (nested spans, named worker threads, async arcs, +// counters, instants). +// +// Route 1 — macros / withSpan: source-level instrumentation that works for +// final classes, structs, and protocol methods (dispatch-agnostic). +// Route 2 — AppleTraceAuto: zero-annotation hooking of a non-final class +// hierarchy via SwiftTrace. +// + +import Foundation +import AppleTrace +import AppleTraceAuto + +// MARK: - Route 2 subject (non-final, reached through a protocol so it is +// vtable-dispatched — what SwiftTrace can hook). + +protocol ImageLoading { + func load(_ index: Int) +} + +class ImageLoader: ImageLoading { + func load(_ index: Int) { + readBytes(index) + resize(index) + } + + func readBytes(_ index: Int) { usleep(UInt32(1200 + (index % 3) * 400)) } + func resize(_ index: Int) { usleep(1500) } +} + +// MARK: - Route 1 subjects (macros). + +@Traced +func warmCaches() { + usleep(3000) +} + +// @TraceAll stamps @Traced on every method — note this is a `final` class, the +// exact case SwiftTrace can't hook, which the macro covers cleanly. +@TraceAll +final class FeedViewModel { + func reload() { + parse() + layout() + } + + func parse() { usleep(900) } + func layout() { usleep(700) } +} + +// MARK: - The showcase + +enum SwiftShowcase { + /// Runs the whole workload and flushes. Returns the trace directory. + static func run() -> String { + Thread.current.name = "Coordinator" // names this track in Perfetto + + // Route 2: auto-trace the ImageLoader hierarchy (no annotations). + AppleTraceAuto.trace(aClass: ImageLoader.self) + defer { AppleTraceAuto.stop() } + + traceInstant("scenario_start") + + // Phase 1 — startup on the coordinator thread (Route 1). + withSpan("App Launch") { + warmCaches() + traceCounter("Config Keys", 142) + FeedViewModel().reload() + } + traceInstant("first_frame_ready") + + // Phase 2 — parallel named workers. + let group = DispatchGroup() + + runNamed("ImageDecoder", group: group) { + let loader: ImageLoading = ImageLoader() // protocol -> vtable -> SwiftTrace + for i in 1...8 { + loader.load(i) + traceCounter("Images Decoded", Double(i)) + } + } + + runNamed("NetworkClient", group: group) { + asyncBegin("GET /feed.json", id: 1001) + withSpan("TLS Handshake") { usleep(9000) } + asyncBegin("GET /avatar.png", id: 1002) + withSpan("Download Body") { + usleep(13000) + traceCounter("Bytes Received", 48 * 1024) + } + withSpan("Parse JSON") { usleep(6000) } + asyncEnd("GET /avatar.png", id: 1002) + asyncEnd("GET /feed.json", id: 1001) + } + + runNamed("DatabaseWriter", group: group) { + for batch in 1...5 { + withSpan("Write batch #\(batch)") { usleep(4000) } + traceCounter("Rows Written", Double(batch * 40)) + } + } + + group.wait() + + // Phase 3 — a 60-frame render loop with live counters. + runNamed("RenderLoop", group: group) { + var memoryMB = 82.0 + for frame in 0..<60 { + withSpan("Frame") { + withSpan("Layout") { usleep(800) } + withSpan("Tick Animations") { usleep(500) } + withSpan("Draw") { usleep(1200) } + withSpan("Composite") { usleep(600) } + } + var fps = 60.0 + if frame % 7 == 0 { fps -= 4 } else if frame % 3 == 0 { fps -= 1.5 } + traceCounter("FPS", fps) + memoryMB += (frame % 10 == 0) ? 6 : 0.6 + if frame % 17 == 0 { memoryMB -= 4 } + traceCounter("Memory (MB)", memoryMB) + if frame == 0 { traceInstant("first_rendered_frame") } + if frame == 30 { traceInstant("user_scrolled") } + } + } + group.wait() + + traceInstant("scenario_complete") + flush() // batched per thread; flush before reading the trace + return traceDirectory + } + + /// Runs `body` on a freshly-named thread (so it becomes its own Perfetto + /// track) and joins it via `group`. + private static func runNamed(_ name: String, group: DispatchGroup, _ body: @escaping () -> Void) { + group.enter() + let thread = Thread { + Thread.current.name = name // surfaces as the track name + body() + group.leave() + } + thread.name = name + thread.start() + } +} From e8249e420fc5eff9770f2099daf1f124fa66a3b2 Mon Sep 17 00:00:00 2001 From: everettjf Date: Sun, 24 May 2026 13:52:09 -0700 Subject: [PATCH 5/6] ci: build the Swift demo app in the Swift workflow Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/swift-tests.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 806beaf..43cbd91 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -29,3 +29,13 @@ jobs: -destination 'generic/platform=iOS Simulator' build xcodebuild -scheme AppleTraceAuto \ -destination 'generic/platform=iOS Simulator' build + + - name: Build the Swift demo app for the iOS Simulator + # Note: -destination only, no -sdk (that would force the macro plugin + # onto the simulator SDK instead of the macOS host). + run: | + xcodebuild \ + -project sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj \ + -scheme AppleTraceSwiftDemo \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO build From 58a6b3c079c9f68609b9f34d30ca121e7027bbdc Mon Sep 17 00:00:00 2001 From: everettjf Date: Sun, 24 May 2026 16:37:13 -0700 Subject: [PATCH 6/6] fix(swift demo): make it run on a real device; gate SwiftTrace to Simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues surfaced running AppleTraceSwiftDemo on a real iPhone 17 Pro: 1. dyld crash at launch — "Library not loaded: @rpath/SwiftTrace.framework". The embedded dynamic framework wasn't found because the project lacked LD_RUNPATH_SEARCH_PATHS; added @executable_path/Frameworks. (The Simulator happened to fall back to the on-disk build-products path, so it only failed on device.) 2. SwiftTrace patches pointer-authenticated vtable slots, which is unsafe on real devices. Gated AppleTraceAuto.trace(...) behind `#if targetEnvironment(simulator)`; the @Traced / @TraceAll macros are the on-device path and have no such limitation. Verified end-to-end: - Simulator: ~487 events, BOTH routes (macros + SwiftTrace-hooked ImageLoader.load/readBytes/resize). - Real device: ~463 events, macro route only (App Launch, Frame, Draw, warmCaches()/reload()/parse()/layout(), counters, async arcs across 5 named threads); SwiftTrace route correctly absent. Docs (README / README_CN / swift-tracing.md / AppleTraceAuto header) and the demo UI now state that AppleTraceAuto is Simulator/macOS-only and the macros are the on-device path. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 9 ++++++--- README_CN.md | 7 ++++--- Sources/AppleTraceAuto/AppleTraceAuto.swift | 14 +++++++++----- docs/swift-tracing.md | 17 ++++++++++++----- .../project.pbxproj | 8 ++++++++ .../AppleTraceSwiftDemo/ContentView.swift | 2 +- .../AppleTraceSwiftDemo/Showcase.swift | 7 ++++++- 7 files changed, 46 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0746c80..4e7c296 100644 --- a/README.md +++ b/README.md @@ -310,9 +310,12 @@ import AppleTraceAuto AppleTraceAuto.trace(aClass: FeedViewModel.self) // entry/exit → AppleTrace ``` -`AppleTraceAuto` can't see `final` / statically-dispatched methods (SwiftTrace's -blind spot) — use the macros for those. See `docs/swift-tracing.md` and the -runnable `AppleTraceAutoExample` (`swift run AppleTraceAutoExample`). +`AppleTraceAuto` is **Simulator / macOS only** (SwiftTrace patches +pointer-authenticated vtable slots, unsafe on real devices — gate it with +`#if targetEnvironment(simulator)`), and it can't see `final` / +statically-dispatched methods. The macros have neither limitation and are the +on-device path. See `docs/swift-tracing.md` and the runnable +`AppleTraceAutoExample` (`swift run AppleTraceAutoExample`). ### Instant Markers, Counters & Async Events diff --git a/README_CN.md b/README_CN.md index 17c9135..64eee74 100644 --- a/README_CN.md +++ b/README_CN.md @@ -301,9 +301,10 @@ import AppleTraceAuto AppleTraceAuto.trace(aClass: FeedViewModel.self) // 进入/退出 → AppleTrace ``` -`AppleTraceAuto` 看不到 `final` / 静态派发的方法(SwiftTrace 的盲区)——这类用宏。 -详见 `docs/swift-tracing.md` 与可运行的 `AppleTraceAutoExample` -(`swift run AppleTraceAutoExample`)。 +`AppleTraceAuto` **仅限模拟器 / macOS**(SwiftTrace 会改写经过指针认证的 vtable 槽, +在真机上不安全——请用 `#if targetEnvironment(simulator)` 包起来),且看不到 `final` / +静态派发的方法。宏没有这些限制,是真机上的首选路径。详见 `docs/swift-tracing.md` +与可运行的 `AppleTraceAutoExample`(`swift run AppleTraceAutoExample`)。 ### 瞬时标记、计数器与异步事件 diff --git a/Sources/AppleTraceAuto/AppleTraceAuto.swift b/Sources/AppleTraceAuto/AppleTraceAuto.swift index 739a331..5eb208d 100644 --- a/Sources/AppleTraceAuto/AppleTraceAuto.swift +++ b/Sources/AppleTraceAuto/AppleTraceAuto.swift @@ -8,11 +8,15 @@ // APTEndSection so the calls land in the same Perfetto trace as everything // else. // -// This is a development/diagnostics tool. Per SwiftTrace's own limitations it -// cannot see `final`/internal methods that the optimizer dispatches directly -// (use the @Traced / @TraceAll macros for those). For exact, dispatch-agnostic -// coverage of your own code, prefer the macros; use this when you want -// zero-annotation coverage of class hierarchies. +// This is a development/diagnostics tool, **best suited to the Simulator and +// macOS**. SwiftTrace patches vtable function pointers, which are +// pointer-authenticated on real devices, so tracing typically isn't safe +// on-device — gate calls with `#if targetEnvironment(simulator)` and rely on +// the @Traced / @TraceAll macros there (they work everywhere). Per SwiftTrace's +// own limitations it also cannot see `final`/internal methods the optimizer +// dispatches directly. For exact, dispatch-agnostic coverage of your own code, +// prefer the macros; use this for zero-annotation coverage of class hierarchies +// in the Simulator. import CAppleTrace import SwiftTrace diff --git a/docs/swift-tracing.md b/docs/swift-tracing.md index 213f765..31ca4df 100644 --- a/docs/swift-tracing.md +++ b/docs/swift-tracing.md @@ -17,7 +17,10 @@ Implemented (SwiftPM package at the repo root — `Package.swift`, `Sources/`): AppleTrace section (`AppleTraceAuto.trace(aClass:)` / `traceClasses(matchingPattern:)` / `traceBundle(containing:)`). Zero annotation; subject to SwiftTrace's blind spot (`final` / statically-dispatched - methods — use the macros for those). Verified via the runnable + methods — use the macros for those). **Simulator / macOS only**: SwiftTrace + patches pointer-authenticated vtable slots, which is unsafe on real devices, so + gate its use with `#if targetEnvironment(simulator)`. The macros are the + on-device path. Verified via the runnable `AppleTraceAutoExample` target (`swift run AppleTraceAutoExample`); it can't be exercised from an XCTest bundle because SwiftTrace's metadata scanning needs a normal executable / app image. @@ -25,10 +28,14 @@ Implemented (SwiftPM package at the repo root — `Package.swift`, `Sources/`): and exercises both routes in one guided app — tap "Generate Trace" to run a multi-threaded workload (~490 events across 5 named tracks: macro-route spans, the SwiftTrace-hooked `ImageLoader`, counters, async arcs) and see the steps to - open it in Perfetto. Verified on the iPhone 17 Simulator and built+signed for - device. Note: the bridge subclasses the lightweight `Swizzle` (not `Decorated`) - and skips `super` — `Decorated`'s argument-reflection path hung in the iOS - app context. + open it in Perfetto. Verified on the iPhone 17 Simulator (~490 events, both + routes) **and on a real iPhone 17 Pro** (~460 events, macro route only — the + SwiftTrace route is `#if targetEnvironment(simulator)`-gated). Two device + issues were fixed along the way: the app project needs + `LD_RUNPATH_SEARCH_PATHS = @executable_path/Frameworks` or dyld can't load the + embedded SwiftTrace framework; and the bridge subclasses the lightweight + `Swizzle` (not `Decorated`, whose argument-reflection path hung in the iOS app + context). ## 1. Problem diff --git a/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj/project.pbxproj b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj/project.pbxproj index ea67a6a..1c19ece 100644 --- a/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj/project.pbxproj +++ b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj/project.pbxproj @@ -185,6 +185,10 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.everettjf.AppleTraceSwiftDemo; @@ -205,6 +209,10 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.everettjf.AppleTraceSwiftDemo; diff --git a/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/ContentView.swift b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/ContentView.swift index 04c0bd9..131c00d 100644 --- a/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/ContentView.swift +++ b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/ContentView.swift @@ -18,7 +18,7 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 16) { Text("AppleTrace 🍎") .font(.largeTitle.bold()) - Text("Swift demo — macros (`@Traced` / `@TraceAll` / `withSpan`) plus the optional SwiftTrace auto-hook.") + Text("Swift demo — macros (`@Traced` / `@TraceAll` / `withSpan`) everywhere, plus the optional SwiftTrace auto-hook (Simulator / macOS only).") .font(.callout) .foregroundStyle(.secondary) diff --git a/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/Showcase.swift b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/Showcase.swift index a0a49a8..2e4752c 100644 --- a/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/Showcase.swift +++ b/sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo/Showcase.swift @@ -58,9 +58,14 @@ enum SwiftShowcase { static func run() -> String { Thread.current.name = "Coordinator" // names this track in Perfetto - // Route 2: auto-trace the ImageLoader hierarchy (no annotations). + // Route 2 (Simulator / macOS only): auto-trace the ImageLoader hierarchy + // with no annotations. SwiftTrace patches vtable function pointers, which + // are pointer-authenticated on real devices (arm64e-style signing), so it + // is unsupported on-device — the macros below cover those cases there. + #if targetEnvironment(simulator) AppleTraceAuto.trace(aClass: ImageLoader.self) defer { AppleTraceAuto.stop() } + #endif traceInstant("scenario_start")