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:
Manual sections
Wrap code in APTBeginSection/APTEndSection (or the Swift macros). The lowest-risk baseline; you control exactly what is timed.
objc_msgSend hook
Automatically trace every Objective-C message send via a fishhook-style symbol rebind. Zero code changes, Objective-C only.
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.
- Clone & open a demo.
bash
git clone https://github.com/everettjf/AppleTrace.git cd AppleTrace open sample/AppleTraceSwiftDemo/AppleTraceSwiftDemo.xcodeproj - Run it once on a device (or the Simulator) so it is installed.
- 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+.
ldidandxzonly to build the jailbreak packages; Python only for the legacymerge.py.
Swift Package (for Swift, and the cleanest path for new projects)
Add the package and depend on the AppleTrace product (and optionally AppleTraceAuto):
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
#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++
#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
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:
import AppleTraceAuto
#if targetEnvironment(simulator)
AppleTraceAuto.trace(aClass: FeedViewModel.self) // entry/exit → AppleTrace
#endifAppleTraceAuto 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:
// 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:
// 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);
});| Event | API | Perfetto rendering |
|---|---|---|
| Section | APTBeginSection / APTEndSection | Nested slices on a thread track |
| Instant | APTInstant | A marker at a point in time |
| Counter | APTCounter | A graph track |
| Async | APTAsyncBegin / APTAsyncEnd | Arcs 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:
# 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 # → Perfettoappletrace 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.
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 regressionRecord 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:
| Sample | Language | Demonstrates |
|---|---|---|
sample/AppleTraceSwiftDemo | Swift | withSpan + @Traced/@TraceAll macros, counters/async, and the AppleTraceAuto hook |
sample/TraceAllMsgDemo | Objective-C | Manual 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.
- Install the package from the release —
appletrace-loader_<version>_iphoneos-arm64.debfor rootless,…_iphoneos-arm64e.debfor roothide:bashswift run appletrace jb install appletrace-loader_2.1.0_iphoneos-arm64e.deb --host <device> - 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
| Variable | Default | Effect |
|---|---|---|
APPLETRACE_ENABLED | 1 | Master on/off switch for recording. |
APPLETRACE_FORMAT | binary | binary or text (JSON lines). APPLETRACE_BINARY=0/1 still works. |
APPLETRACE_FLUSH_INTERVAL_MS | 1000 | Background drain interval; 0 disables it. |
APPLETRACE_AUTO_HOOK_OBJC_MSGSEND | 0 | Install the objc_msgSend hook at load. |
APPLETRACE_TRACE_CLASS_ALLOW / _DENY | — | Comma-separated class-name prefixes to include / exclude. |
APPLETRACE_DATA_DIR | sandbox | Override where fragments are written. |
APPLETRACE_BLOCK_SIZE_MB | 16 | Per-fragment mmap block size (1–256 MB). |
APPLETRACE_KEEP_EXISTING | 0 | Keep 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
| Mode | Where it works |
|---|---|
| Manual sections & events | Every iOS/macOS version, all languages |
Swift macros (@Traced/@TraceAll/withSpan) | Simulator and device |
objc_msgSend hook | arm64 and arm64e |
AppleTraceAuto (SwiftTrace) | Simulator / macOS only |
| Jailbreak loader | Rootless 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.