Wanted to measure my app but all I got is this post ft. OSSinposter

Recently, I started digging deeper into app performance analysis, and at first I assumed things weren’t that bad. But as with most performance work, once you start measuring, reality looks very different.

While exploring Apple’s tooling, I came across OSSignposter, a framework that -when paired with Instruments – provides precise insight into how long your app spends doing real work. Let’s take a closer look.

OSSignposter

OSSignposter is a lightweight logging framework that allows you to mark points and intervals in your code and visualize them on a timeline in Instruments.

In practice, this means you can measure:

  • Disk reads and writes
  • Database operations
  • Network requests
  • Expensive computations
  • Async task execution and threading behavior

Instead of guessing where time is spent, you can see it.

Simple example

import OSLog
// Default signposter with a basic subsystem and category.
let signposter = OSSignposter(subsystem: "DashboardViewModel", category: "Data Featching")
        
func fetchDashboardData(_ request: URLRequest) {
   // Signpost ID to associate with a signposted interval.
   let signpostID = signposter.makeSignpostID()
        
    // Begin a signposted interval and store the interval state.
    // The name is used to easaly find it in Instruments if you have more mesurments.
    let signposterState = signposter.beginInterval("fetchDashboardData", id: signpostID)
        
    let data = fetchData(from: request)
    processData(data)
        
    // End the signposted interval using the stored interval state.
    signposter.endInterval("fetchDashboardData", state)
}

What’s happening here?

  • Subsystem
    Appears in Instruments and helps group related measurements.
  • Category
    Used for filtering and organizing traces.
  • Signpost ID
    Associates begin/end calls with the same interval.
  • Interval name
    Must be identical in beginInterval and endInterval.

⚠️ If the names don’t match, Instruments will treat the interval as never-ending—often spanning the entire trace.

Visualizing in Instruments

Once you run your app with the os_signpost instrument enabled, you’ll see clearly labeled intervals on the timeline showing exactly how long each task took.

You can use Logging to see signpost or if you want to see with other templet find os_signpost.

Show me more

In the previous example, we had a fairly simple case, but we can take it further. For example, when working with more complex logic, we can check whether our request or task is working the way we want.


func fetchDashboardData() async {
        let signpostID = signposter.makeSignpostID()

        let overallState = signposter.beginInterval("fetchDashboardData", id: signpostID)
        defer { signposter.endInterval("fetchDashboardData", overallState) }

        let catState = signposter.beginInterval("await fetchCategories", id: signpostID)
        _ = try? await fetchCategories()
        signposter.endInterval("await fetchCategories", catState)

        let cocktailsState = signposter.beginInterval("await fetchCocktails", id: signpostID)
        _ = try? await fetchCocktails()
        signposter.endInterval("await fetchCocktails", cocktailsState)

        let computeState = signposter.beginInterval("compute state", id: signpostID)
        defer { signposter.endInterval("compute state", computeState) }
...
}

With this example we are getting this result.

In Instruments, this immediately shows:

  • Which tasks run sequentially
  • Which tasks block the main actor
  • Whether async work actually runs in parallel

In this example, it becomes obvious that the requests are not parallelized, even though they could be.

After restructuring the code to run tasks concurrently, the timeline clearly reflects the improvement.

Measurement turns assumptions into facts.

If our app heavily depends on multithreading and performs a lot of work asynchronously, we can see which thread a task starts on and ends.

Event Sent

Not everything needs a duration.

OSSignposter also supports instant events, which are perfect for:

  • Counting method calls
  • Tracking lifecycle events
  • Debugging unexpected execution paths
let signposter = OSSignposter(subsystem: "DashboardViewModel", category: "Data Featching")

let signpostID = signposter.makeSignpostID()

signposter.emitEvent("Fetch complete.", id: signpostID)

I’ve used this technique to detect how many times a method was invoked when the app returned from the background – paired with Time Profiler, it becomes very effective.

Subsystem

For larger apps, subsystem organization matters. Instead of using a single subsystem everywhere, I recommend grouping by logical responsibility:

com.myapp.data
com.myapp.ui
com.myapp.audio

With this definition, when using os_signpost in Instruments, we can choose to display only a selected subsystem, resulting in a less noisy timeline.

By default, all subsystems are visible unless explicitly filtered.

Bonus

Calling beginInterval and endInterval repeatedly gets tedious, so I created a small wrapper to simplify usage:

import Foundation
import OSLog

final public class Signpost {

    @MainActor public static let shared = Signpost()

    private let signposter: OSSignposter

    init(subsystem: String = Bundle.main.bundleIdentifier ?? "Signpost",
         category: String = "Performance") {
        self.signposter = OSSignposter(subsystem: subsystem, category: category)
    }

    // MARK: - Interval signposts

    @discardableResult
    public func begin(_ name: StaticString,
               id: OSSignpostID = .exclusive) -> OSSignpostIntervalState {
        signposter.beginInterval(name, id: id)
    }

    public func end(_ name: StaticString,
             _ state: OSSignpostIntervalState) {
        signposter.endInterval(name, state)
    }

    @discardableResult
    public func measure<T>(_ name: StaticString,
                    id: OSSignpostID = .exclusive,
                    _ work: () throws -> T) rethrows -> T {
        let state = signposter.beginInterval(name, id: id)
        defer { signposter.endInterval(name, state) }
        return try work()
    }

    // MARK: - Instant events

    public func event(_ name: StaticString,
               id: OSSignpostID = .exclusive) {
        signposter.emitEvent(name, id: id)
    }
}

Bonus 2

I also spent some time experimenting with Swift Macros and created SignpostKita package that includes a signpost wrapper with an @OSSignpostMeasure macro. Simply place the macro above your calls, and all methods are automatically measured. Pretty neat, right?

Github link: https://github.com/SwiftandSour/SignpostKit

Note: I did the Marco as a gimmick 🫣. In a long term I believe simple manager would be more sufficient.

Conclusion

Uff, we’ve reached the end! I hope you enjoyed our little signpost adventure. Keep posting the signs to perform better 🪧🚀.


Szymon Szysz is a ex-Apple hater and has come a long way. Now making apps for iOS since 2017. Likes good design and amazing UX. Likes to focus on code quality and does not hesitate to say that something “is not yes” (*ponglish, “is not correct”).


Posted

in

, ,

by