OSLog signposts with Instruments: connecting logs to hangs
How to use OSLog signposts, intervals, and Instruments together so performance investigations connect user actions to real latency and hangs.
On this page
Performance work gets expensive when the trace cannot answer what the user was doing.
A Time Profiler run shows a busy main thread. The Hangs instrument points at a pause. The log stream contains a few hopeful messages. Someone says the app feels slow after tapping Save, opening Search, or scrolling a specific screen. Then the team starts guessing which spike belongs to which action, because the trace is technically full of data and operationally low on evidence.
That is where signposts earn their keep.
OSLog signposts let you mark meaningful product intervals inside the same timeline Instruments already uses for CPU, hangs, allocations, energy, and system activity. Done well, they turn a performance investigation from “there is a spike somewhere around here” into “import preview decoding took 640 ms, blocked the main actor twice, and the visible hang started inside thumbnail generation.”
1. Start with the user-visible operation
Do not signpost every function because the profiler already exists. Signpost the work a user, tester, or engineer would name when filing the problem.
Good signpost boundaries sound like product operations:
- open recipe detail
- render first search results
- import file preview
- sync inbox after launch
- generate widget timeline
- save edited trip
- decode thumbnail batch
- resolve deep link
Weak boundaries sound like implementation crumbs:
load()update()performTask()bodymanager did finish
The signpost should answer: what was the app trying to do?
For example, a packing app might have a user-visible slow path when importing a trip template. The useful interval is not “database work” or “parse JSON.” Those are substeps. The top-level interval is Import template, because that is the operation the user waited for.
import OSLog
private let performanceLog = OSLog(
subsystem: "com.example.packerly",
category: "Performance"
)
func importTemplate(from url: URL) async throws {
let signpostID = OSSignpostID(log: performanceLog)
os_signpost(
.begin,
log: performanceLog,
name: "Import template",
signpostID: signpostID,
"file=%{public}@",
url.lastPathComponent
)
defer {
os_signpost(
.end,
log: performanceLog,
name: "Import template",
signpostID: signpostID
)
}
let data = try Data(contentsOf: url)
let template = try decodeTemplate(from: data)
try await templateStore.save(template)
}That interval gives Instruments an anchor. If the trace shows a hang during import, the team can line up the hang, CPU stacks, disk activity, and the signpost interval instead of conducting archaeology with timestamps.
2. Use intervals for latency, events for facts
Signposts have two main shapes worth using in app code:
- Intervals for work that starts and ends.
- Events for point-in-time facts.
Use intervals for anything with duration:
- loading a screen
- running a search
- decoding images
- writing a batch
- refreshing sync state
- generating a timeline
Use events for facts that explain the interval:
- cache hit or miss
- result count
- cancellation
- retry attempt
- selected mode
- fallback path
- batch size
os_signpost(
.event,
log: performanceLog,
name: "Search cache miss",
"queryLength=%{public}d",
query.count
)Do not model a duration as two unrelated log lines. If the work has a beginning and an end, use an interval. Instruments understands intervals. Humans understand them too, which is always a pleasant accident.
The common mistake is adding ordinary logs and expecting them to behave like trace markers:
logger.debug("started import")
logger.debug("finished import")Those messages may help during development, but they do not give Instruments a structured interval with identity. When several imports overlap, or when one import contains parallel subwork, loose log lines become ambiguous quickly.
3. Keep signpost IDs scoped to the operation
OSSignpostID connects the begin and end of one interval. Create it at the operation boundary and pass it into substeps only when those substeps belong to the same operation.
struct ImportSignposts {
let id: OSSignpostID
let log: OSLog
}
func importTemplate(from url: URL) async throws {
let signposts = ImportSignposts(
id: OSSignpostID(log: performanceLog),
log: performanceLog
)
os_signpost(.begin, log: signposts.log, name: "Import template", signpostID: signposts.id)
defer { os_signpost(.end, log: signposts.log, name: "Import template", signpostID: signposts.id) }
let data = try await readTemplateData(url, signposts: signposts)
let model = try decodeTemplate(data, signposts: signposts)
try await saveTemplate(model, signposts: signposts)
}Nested intervals can be useful, but only if they are few and named clearly:
func decodeTemplate(
_ data: Data,
signposts: ImportSignposts
) throws -> TripTemplate {
os_signpost(
.begin,
log: signposts.log,
name: "Decode template",
signpostID: signposts.id,
"bytes=%{public}d",
data.count
)
defer {
os_signpost(
.end,
log: signposts.log,
name: "Decode template",
signpostID: signposts.id
)
}
return try JSONDecoder().decode(TripTemplate.self, from: data)
}Do not reuse one global signpost ID for unrelated work. That turns the trace into a badly labeled extension cord. Each operation instance needs its own identity, especially when the user can trigger the same action twice or multiple scenes can run the same work.
4. Mark the main-actor boundary explicitly
A hang is usually user-visible because the main thread or main actor is unavailable when the interface needs it.
That does not mean all slow work started on the main actor. A background operation can still publish too much state back to the UI, perform decoding on the wrong boundary, or trigger a SwiftUI update that does expensive layout at the worst possible time.
Signpost the transition points:
let parsed = try await parser.parse(data)
os_signpost(
.begin,
log: performanceLog,
name: "Publish import results",
signpostID: signpostID,
"itemCount=%{public}d",
parsed.items.count
)
await MainActor.run {
model.apply(parsed)
}
os_signpost(
.end,
log: performanceLog,
name: "Publish import results",
signpostID: signpostID
)Now a trace can separate the expensive parse from the expensive publish. That distinction matters. If parsing is slow, move or optimize parsing. If publishing is slow, look at observation scope, list identity, diffing, layout, or database fetches triggered by the update.
Without that boundary, the team may “fix performance” by moving a function to a background task while the actual hang remains in the UI update that follows it. This is how codebases acquire ceremonial queues and very little speed.
5. Pair signposts with the Hangs instrument
The Hangs instrument is more useful when it can be interpreted against product intervals.
A basic workflow:
- Launch Instruments.
- Record with Hangs, Time Profiler, and Points of Interest.
- Reproduce the slow user action once or twice.
- Find the hang interval.
- Check which signposted operation overlaps it.
- Inspect stacks and thread activity inside that overlap.
The Points of Interest track is where your signposts become visible. If the hang overlaps Open trip detail, that gives the profiler stacks a product context. If the hang starts after Fetch trip but inside Render packing list, the problem is probably not the network request. If the hang overlaps Publish search results, you have a useful suspect.
This matters because hang traces are noisy. The system is doing many things. The app may be doing several things. The user only experienced one thing: they tapped, scrolled, typed, or waited.
Signposts connect the system view to the user view.
6. Record counts, modes, and cache decisions
A duration without context can still mislead.
An image decoding interval that took 900 ms sounds bad. If it decoded 400 thumbnails after a cold import, the fix may be batching, progressive loading, or back-pressure. If it decoded three thumbnails from cache, the fix is probably somewhere else.
Include small, non-sensitive context values:
os_signpost(
.begin,
log: performanceLog,
name: "Decode thumbnails",
signpostID: signpostID,
"count=%{public}d source=%{public}@",
images.count,
source.description
)Useful fields:
- item count
- byte count
- cache hit or miss
- offline or online mode
- cold launch or warm path
- feature flag variant
- retry count
- page size
- destination surface
Avoid content values:
- names
- message text
- note bodies
- file contents
- customer data
- full URLs with private query parameters
- access tokens, identifiers, or anything pretending not to be sensitive
Performance diagnostics should help explain latency without becoming a privacy incident with better graphs.
7. Keep signpost categories boring and stable
Use log categories that describe investigation surfaces, not whatever feature name was fashionable that week.
A practical split:
extension OSLog {
static let performance = OSLog(
subsystem: "com.example.app",
category: "Performance"
)
static let syncPerformance = OSLog(
subsystem: "com.example.app",
category: "SyncPerformance"
)
static let renderingPerformance = OSLog(
subsystem: "com.example.app",
category: "RenderingPerformance"
)
}For many apps, one Performance category is enough. Add more only when filtering traces becomes painful.
The names should survive refactors. RecipeDetailV2LoadExperiment will age badly. Open recipe detail probably will not. Instruments sessions are easier to compare when the same operation keeps the same name across releases.
Treat signpost names like public diagnostics vocabulary:
- short
- stable
- user-action oriented
- specific enough to filter
- not tied to private implementation classes
8. Add a small helper, not a tracing framework
A tiny wrapper keeps signpost usage consistent and reduces begin/end mistakes.
import OSLog
struct PerformanceInterval {
let log: OSLog
let name: StaticString
let id: OSSignpostID
init(log: OSLog, name: StaticString) {
self.log = log
self.name = name
self.id = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: name, signpostID: id)
}
func end() {
os_signpost(.end, log: log, name: name, signpostID: id)
}
}Then the call site stays readable:
func generateTimeline() async throws -> Timeline<Entry> {
let interval = PerformanceInterval(
log: .performance,
name: "Generate widget timeline"
)
defer { interval.end() }
return try await timelineBuilder.build()
}Keep the helper boring. The goal is not to build a custom telemetry platform inside the app because someone found a weekend and a type system. The goal is consistent markers that make Instruments useful.
If the wrapper hides too much, delete it. The trace should remain understandable from the call site.
9. Use signposts in tests and release drills
Signposts are not only for heroic debugging after production starts smoking.
They can support repeatable performance checks:
- cold launch to first useful screen
- search query to first results
- import preview generation
- widget timeline generation
- large list refresh
- sync convergence after app launch
- opening a detail screen with realistic data
A release drill does not need to enforce a perfect benchmark in every pull request. It should at least record the important flows with signposts present so regressions are visible.
For example:
Scenario: Open a large trip
Data: 600 packed items, 40 categories, 80 notes
Trace: Hangs + Time Profiler + Points of Interest
Expectation: no visible hang over 250 ms during open and first scroll
Evidence: Open trip detail, Fetch trip, Render packing list intervalsThat evidence is much better than “felt fine on my machine.” A trace with named intervals gives the release discussion something sturdier than memory and optimism.
For automated tests, keep expectations at the right layer. Unit tests can verify that expensive operations are split into named boundaries. Performance tests can measure duration. Instruments traces explain why a measurement changed.
Do not force every local test run to collect traces. That turns diagnostics into friction, and friction eventually gets removed. Use signposts as always-available markers, then collect traces when the workflow needs evidence.
10. Know when signposts are the wrong tool
Signposts explain timing. They do not replace the rest of the investigation.
Use other tools when the question changes:
- Time Profiler: which code consumed CPU?
- Hangs: when was the app unresponsive?
- Allocations / Leaks: what is growing or leaking?
- SwiftUI Instruments: which views are rebuilding and why?
- Network instruments: what did the transport actually do?
- MetricKit: what happens across real user sessions?
- Structured logs: what decisions did the app make?
A good performance workflow combines them. Signposts provide the product map. Instruments provide the system evidence. Logs provide decisions and context. Tests keep the same regression from returning under a slightly different name.
The important habit is to stop investigating raw timelines without product markers. If a user action can be slow, hang, or regress, it deserves a named interval. Otherwise every future trace starts with the same avoidable question: what are we even looking at?
That question is expensive. Mark the timeline before it invoices you.