AppleTrace
iOS / macOS Tracing → Perfetto

The AppleTrace field guide.

A lightweight, embeddable tracer that records your app's execution timeline — manual sections, Swift macros, or every objc_msgSend — and renders it in Perfetto, right in the browser.

Introduction

AppleTrace instruments your app — by adding markers, annotating Swift functions, or hooking message sends — and writes a timeline of events into sandbox trace fragments. The appletrace command-line tool pulls them off the device, merges them into a single trace.json, and opens it in Perfetto to explore the call timeline, durations, threads, and counters.

There are three ways to produce events. Pick whichever fits — they all land in the same trace:

all platforms

Manual sections

Wrap code in APTBeginSection/APTEndSection (or the Swift macros). The lowest-risk baseline; you control exactly what is timed.

arm64 / arm64e

objc_msgSend hook

Automatically trace every Objective-C message send via a fishhook-style symbol rebind. Zero code changes, Objective-C only.

Simulator / macOS

Swift auto-hook

The optional AppleTraceAuto product bridges SwiftTrace to trace Swift class hierarchies with no annotations.

Quick Start

The fastest path to a trace is the bundled demo app — no instrumentation to write.

  1. Clone & open a demo.
    bash
    git clone https://github.com/everettjf/AppleTrace.git
    cd AppleTrace
    open sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj
  2. Run it once on a device (or the Simulator) so it is installed.
  3. Record it — launch, wait, stop, pull, merge and open Perfetto in one command:
    bash
    swift run appletrace record --bundle-id com.everettjf.AppleTraceSwiftDemo \
      --env APPLETRACE_AUTORUN=1 --open
💡

On the Simulator, tap Generate Trace and run the swift run appletrace open <dir> command the app shows. The trace is handed to Perfetto in your browser — never uploaded.

Installation

Requirements

  • Xcode 16+ (Swift 6), macOS 12+, and a browser (Perfetto runs at ui.perfetto.dev). Apps need iOS 15+ / macOS 12+.
  • ldid and xz only to build the jailbreak packages; Python only for the legacy merge.py.

Swift Package (for Swift, and the cleanest path for new projects)

Add the package and depend on the AppleTrace product (and optionally AppleTraceAuto):

swift — Package.swift
dependencies: [
    .package(url: "https://github.com/everettjf/AppleTrace.git", from: "2.1.0"),
],
targets: [
    .target(name: "MyApp", dependencies: [
        .product(name: "AppleTrace", package: "AppleTrace"),
        // optional, Simulator/macOS only:
        .product(name: "AppleTraceAuto", package: "AppleTrace"),
    ]),
]

Framework embedding (Objective-C / C / C++)

Use appletrace.xcframework from the release, or open appletrace/appletrace.xcodeproj, build the framework, and embed appletrace.framework into your target. See sample/TraceAllMsgDemo for a working setup.

Tutorial · Manual Instrumentation

Manual sections are the recommended baseline — they work on every iOS/macOS version and produce clean, descriptively-named slices.

Objective-C

objective-c
#import <appletrace/appletrace.h>

- (void)viewDidLoad {
    APTBegin;                       // auto-named "[ClassName viewDidLoad]"
    [super viewDidLoad];
    APTEnd;
}

- (void)loadFeed {
    APTBeginSection("network");     // explicit section name
    // ... work ...
    APTEndSection("network");
}

APTBegin / APTEnd name the section after the enclosing class and selector. Use APTBeginSection / APTEndSection when you want a custom name. Pairs nest on the same thread (LIFO).

C / C++

cpp
#include <appletrace/appletrace.h>

void process() {
    APTBeginSection("process");
    // ... work ...
    APTEndSection("process");
}

void safer() {
    APTScopeSection("decode");      // RAII: ends automatically at scope exit
    APTScopeSection("inner");       // several per scope are fine
    // ... work ...
}
💡

Events are buffered per thread and drained to a memory-mapped file every second (APPLETRACE_FLUSH_INTERVAL_MS), when the app enters the background, at thread exit and at process exit — so even a killed app leaves a readable trace. Call APTFlush() only when you read the trace in-process.

Tutorial · Tracing Swift

The objc_msgSend hook can't see Swift's static / vtable / witness dispatch, so Swift is traced at the source level. After adding the Swift package, import AppleTrace.

Scoped spans & macros

swift
import AppleTrace

// Scoped span — closes even on throw / early return. Literal names bind to a
// StaticString overload and are passed to the C core without allocating.
withSpan("loadFeed") { try? loadFeed() }

// Async work may resume on another thread, so use an async span:
let feed = try await withAsyncSpan("fetchFeed") { try await api.feed() }

// Annotate a function. The begin/end is inserted into the body at compile
// time, so it works for final classes, structs, actors and protocol methods.
@Traced
func decodeImage() { /* span "decodeImage()" */ }

// @TraceAll stamps @Traced onto every method and initializer with a body.
// Names are qualified by type: "FeedViewModel.reload(force:)".
@TraceAll
final class FeedViewModel {
    func reload(force: Bool) { /* traced */ }
    func refresh() async throws { /* async span — safe across await */ }
    @NotTraced func hotPath() { /* excluded */ }
}

@Traced("feed.decode")   // explicit name
func decode() { /* ... */ }
✅

async functions get async spans. A section must begin and end on the same thread, but an await can resume elsewhere; @Traced therefore emits an async begin/end pair with a per-call id for async functions, drawn by Perfetto on its own track.

Zero-annotation auto-tracing

The optional AppleTraceAuto product bridges SwiftTrace to hook a class hierarchy without annotations:

swift
import AppleTraceAuto

#if targetEnvironment(simulator)
AppleTraceAuto.trace(aClass: FeedViewModel.self)   // entry/exit → AppleTrace
#endif
🚫

AppleTraceAuto is Simulator / macOS only. SwiftTrace patches pointer-authenticated vtable slots, which is unsafe on real devices — always gate it with #if targetEnvironment(simulator). It also can't see final / statically-dispatched methods. The macros have neither limitation and are the on-device path.

Tutorial · Automatic objc_msgSend Hook

For Objective-C apps you can trace every message send with no manual markers. The hook is an arm64/arm64e fishhook-style symbol rebind; install it once after launch:

objective-c
// e.g. early in application:didFinishLaunchingWithOptions:
if (APTInstallObjcMsgSendHook()) {
    NSLog(@"AppleTrace: objc_msgSend hook installed");
}
// Swift: AppleTrace.installObjCMsgSendHook()
// No code at all: launch with APPLETRACE_AUTO_HOOK_OBJC_MSGSEND=1
//   (what `appletrace record --hook` does)

Every traced send becomes a [Class]selector begin/end pair. See sample/TraceAllMsgDemo for a full setup, including the validated handling of floating-point arguments, struct returns, and super dispatch.

✅

arm64e is supported. The backend re-signs chained-fixup __auth_got slots for PAC and is validated on an iPhone 17 Pro (iOS 27) and an iPhone 11 (iOS 26.0.1). Images loaded later with dlopen are rebound too.

⚠️

With iOS 14+ / macOS 11+ deployment targets, clang turns some alloc, init, class and self sends into direct objc_alloc_init / objc_opt_* calls, so those don't show up as hooked events.

Tutorial · Event Types

Beyond sections, AppleTrace records the event kinds Perfetto knows how to draw:

objective-c
// Instantaneous marker on the current thread's timeline
APTInstant("cache_miss");

// A value plotted over time → a counter graph track (memory, FPS, queue depth…)
APTCounter("resident_mb", 142.5);
APTCounter("fps", 60);

// Work that flows across threads / queues, matched by (name, id) → async arc
uint64_t reqID = APTNewAsyncID();   // process-unique
APTAsyncBegin("image_load", reqID);
dispatch_async(queue, ^{
    APTAsyncEnd("image_load", reqID);
});
EventAPIPerfetto rendering
SectionAPTBeginSection / APTEndSectionNested slices on a thread track
InstantAPTInstantA marker at a point in time
CounterAPTCounterA graph track
AsyncAPTAsyncBegin / APTAsyncEndArcs that can cross threads

The Swift wrappers mirror these: traceInstant, traceCounter, newAsyncID, asyncBegin, asyncEnd, withAsyncSpan. Async spans from Swift record the current task's name (Swift 6.2 Task(name:), iOS 26 / macOS 26 and later) as their task argument; from Objective-C use APTAsyncBeginWithTask(name, id, label).

Visualize in Perfetto

Fragments are written to <app sandbox>/Library/appletracedata. The appletrace CLI does the rest:

bash
# Everything in one go (development-signed app on a connected device):
swift run appletrace record --bundle-id com.example.MyApp --duration 15 --hook --open

# Or step by step:
swift run appletrace pull  --bundle-id com.example.MyApp -o mytrace   # devicectl copy
swift run appletrace merge mytrace                                    # → mytrace/trace.json
swift run appletrace open  mytrace                                    # → Perfetto

appletrace open serves a small page on 127.0.0.1 that hands the trace to ui.perfetto.dev in your browser with Perfetto's postMessage API — the file is never uploaded. You can also drag trace.json in yourself. Use W/S to zoom, A/D to pan, and the search box to jump to a slice.

Binary and text fragments

Fragments are binary by default (no string formatting on the hot path, about half the size). Set APPLETRACE_FORMAT=text for human-readable JSON lines. appletrace merge (and the legacy merge.py) decode both, including fragments left behind by a killed process.

Performance gates in CI

appletrace summary reports per-span count, total, self, mean, p50, p95 and max times; appletrace diff compares two traces or a saved baseline and exits with status 2 when a span regressed.

bash
appletrace summary run1 run2 run3 --only MyApp. -o perf/baseline.json   # save a baseline
appletrace diff perf/baseline.json current.json \
  --metric p50 --threshold 20% --min-delta 2ms --only MyApp.            # exit 2 on regression

Record several runs on each side and gate only your own spans. The CI guide has a Simulator recording loop and a GitHub Actions job.

Demo Apps

Two runnable samples, each a complete reference:

SampleLanguageDemonstrates
sample/AppleTraceSwiftDemoSwiftwithSpan + @Traced/@TraceAll macros, counters/async, and the AppleTraceAuto hook
sample/TraceAllMsgDemoObjective-CManual APTBeginSection sections and the automatic objc_msgSend hook

Each guided app has a Generate Trace button, shows the trace directory, and prints the exact appletrace commands. The Swift demo consumes the local package, so open it from the repo so Xcode resolves the products automatically.

Tracing Any App on a Jailbroken Device

The AppleTraceLoader tweak injects AppleTrace into apps you choose on rootless (/var/jb, e.g. Dopamine) and roothide jailbreaks. It derives the jailbreak root from its own path, so the same binaries work on both.

  1. Install the package from the release — appletrace-loader_<version>_iphoneos-arm64.deb for rootless, …_iphoneos-arm64e.deb for roothide:
    bash
    swift run appletrace jb install appletrace-loader_2.1.0_iphoneos-arm64e.deb --host <device>
  2. Record an app — it is relaunched with tracing on, then stopped, pulled over SSH and merged:
    bash
    swift run appletrace jb record com.example.App --host <device> \
      --duration 15 --hook --allow "EX,App" --open

Step by step: jb enable <bundle-id> --hook, use the app, jb pull <bundle-id>, open appletracedata-<bundle-id>, and jb disable <bundle-id>. enable writes <jbroot>/var/mobile/Library/AppleTrace/<bundle-id>.plist; the loader only touches apps that have one.

💡

SSH needs key-based login (ssh-copy-id). Over USB, forward the port with iproxy 2222 22 and pass --host 127.0.0.1 --port 2222. Only jb install needs root; if you can only log in as mobile, install the .deb with Sileo/Zebra and pass --user mobile.

The loader writes a status line to the app's tmp/AppleTraceLoader.log (shown by jb pull), including how many GOT slots the hook rebound.

⚠️

Sealed apps: on iOS 26, Apple's own apps map their read-only __DATA_CONST as a permanent non-writable region, so the hook can't rebind their GOT (main_executable_slots=0 in the log). They keep running normally; third-party apps are unaffected.

Environment Variables

VariableDefaultEffect
APPLETRACE_ENABLED1Master on/off switch for recording.
APPLETRACE_FORMATbinarybinary or text (JSON lines). APPLETRACE_BINARY=0/1 still works.
APPLETRACE_FLUSH_INTERVAL_MS1000Background drain interval; 0 disables it.
APPLETRACE_AUTO_HOOK_OBJC_MSGSEND0Install the objc_msgSend hook at load.
APPLETRACE_TRACE_CLASS_ALLOW / _DENY—Comma-separated class-name prefixes to include / exclude.
APPLETRACE_DATA_DIRsandboxOverride where fragments are written.
APPLETRACE_BLOCK_SIZE_MB16Per-fragment mmap block size (1–256 MB).
APPLETRACE_KEEP_EXISTING0Keep prior trace dirs instead of replacing them.

Malformed values are logged and ignored, so a typo never silently changes behavior.

Runtime controls are also available in code: APTSetEnabled(BOOL), APTIsEnabled(), APTGetTraceDirectory(), APTFlush(), APTSyncWait().

Platform Support

ModeWhere it works
Manual sections & eventsEvery iOS/macOS version, all languages
Swift macros (@Traced/@TraceAll/withSpan)Simulator and device
objc_msgSend hookarm64 and arm64e
AppleTraceAuto (SwiftTrace)Simulator / macOS only
Jailbreak loaderRootless and roothide jailbreaks (arm64 / arm64e)

Why Swift needs source-level tracing

Swift avoids message dispatch for speed: struct/final methods and whole-module-optimized calls are statically dispatched; class methods use a vtable; protocol methods use a witness table. None go through objc_msgSend, so only the thin @objc dynamic surface is visible to the auto-hook. The macros instrument the source directly, which is why they cover all four dispatch kinds.

FAQ & Troubleshooting

My trace is empty / only has metadata

The writer drains every second, on backgrounding and at exit. If you pull while the app is still running, wait a second first (appletrace record does), or call APTFlush(). Also check APPLETRACE_ENABLED isn't 0.

Build fails on the deployment target

AppleTrace 2.0 needs iOS 15+ / macOS 12+, the oldest targets current Xcode accepts.

My @Traced async function shows up on its own track, not on a thread

That's intended: async functions use async spans because an await may resume on another thread.

The Swift app crashes at launch with Library not loaded: @rpath/SwiftTrace.framework

The app target needs LD_RUNPATH_SEARCH_PATHS = @executable_path/Frameworks so dyld can find the embedded dynamic framework on device.

AppleTraceAuto traces nothing for some methods

SwiftTrace can't hook final or statically-dispatched methods, and it's Simulator/macOS only. Use the @Traced/@TraceAll macros for those.

Command-line xcodebuild can't find SwiftSyntax for the macro plugin

Drop the -sdk iphonesimulator flag and pass only -destination; -sdk forces the host macro plugin onto the wrong SDK.