Measuring application performance in Swift using transactions

Lazar Nikolov - Nov 22 '22 - - Dev Community

So you're building a mobile app that’s performing big data requests; or crunching big data. But now you're asking yourself:

  • How will my app perform in production?
  • How will it perform on a lower-tier phone?
  • Is there a scenario where the function's execution time is unbearably long?

With Sentry’s Custom Instrumentation you can keep an eye on those big data-handling functions. Let’s see how you can implement them in your Storyboard and SwiftUI projects.

Requirements

First, you need to setup performance monitoring. With performance monitoring, Sentry tracks application performance, measures metrics like throughput and latency, and displays the impact of errors across multiple services.

To get your app setup with performance monitoring, you need to configure the traces sample rate in your Swift.UI.App file's init() method:

import Sentry

// This should be in your SwiftUI.App file's init() method
SentrySDK.start { options in
  options.dsn = "[YOUR_DSN_HERE]"

  // Example uniform sample rate: capture 100% of transactions
  // In Production you will probably want a smaller number such as 0.5 for 50%
  options.tracesSampleRate = 1.0

  // OR if you prefer, determine traces sample rate based on the
  // sampling context
  options.tracesSampler = { context in
    // Don't miss any transactions for VIP users 
    if context?["vip"] as? Bool == true { 
      return 1.0 
    } else { 
      return 0.25 // 25% for everything else
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementing custom transactions

Now that you’ve got performance monitoring enabled, you can start setting up custom transactions in the functions you’d like to measure. The measurement starts when you start a new transaction:

// don't forget to import Sentry's SDK at the top
import Sentry

// ...

func syncPersonDatabase() {
  let transaction = SentrySDK.startTransaction(
    // Give the transaction a descriptive name
    name: "transaction-name",

    // Give the operation a descriptive name
    operation: "transaction-operation"
  )

  // ...
  // perform the operation
  // ...
}
Enter fullscreen mode Exit fullscreen mode

For example, let’s say you want to measure how long it takes to sync a database of all people within a company; the “person database”. The name and operation could be "Sync person database" and "data.update" respectfully.

When you’re done with the operation you want to measure, you can call the finish() method of the transaction. Finishing the transaction will stop the measurement and send it to your Sentry project.

The final structure of your function should look like this:

// don't forget to import Sentry's SDK at the top
import Sentry

// ...

func syncPersonDatabase() {
  let transaction = SentrySDK.startTransaction(
    name: "Sync person database", // give it a name
    operation: "data.update" // and a name for the operation
  )

  // ...
  // perform the operation
  // ...

  transaction.finish() // stop the measurement and report it to Sentry
}
Enter fullscreen mode Exit fullscreen mode

Monitoring the performance

So far you’ve set up the performance measuring mechanism of your syncPersonDatabase function. Now you run your app...

You run it again...

Maybe you run it one more time for good measure...

Okay, that should have kicked off a transaction. You visit your Sentry dashboard and open the Performance tab and see the new custom transaction appear at the bottom:

Transactions Table

Clicking on the transaction and then into the “Suspect Span” found in the second table, you will see a detailed representation of the span operation:

Span Summary

Let's break this view down a bit.

The first thing that you’ll see is the Self Time Breakdown chart.

Span Summary Breakdown

This chart visualises the p50, p75, p95 and p99 metrics, which are called “latency percentiles”. p50 means that 50% of the function executions are faster than the p50 value (let’s say 0.47ms). To learn more about the latency percentiles, check out the Latency section in the Metrics documentation.

In the top right corner you can see the Self Time Percentiles widgets.

Span Summary Self Time Percentiles

If you don’t want to look at the chart, these values will give you an insight on the average “p” values.

Below the chart is the events table.

Span Summary Events Table

Here if you notice an event that took longer than you anticipated, you can click on it to get more details about it, like:

  • The device that the user was using when the transaction occurred
  • The device’s memory at the time of the transaction
  • How long the user had been using the app prior to the transaction
  • The version of the app
  • The breadcrumbs (bits of events and interactions that led up to that transaction)

This gives you enough information on the context and environment to be able to discover potential ways to refactor the function to improve its performance. And, you can get even more granular if your function has multiple steps of execution. You can measure each steps individually and still keep them under the umbrella of the main function transaction.

Enter: child spans

More granular measurement with child spans

Child spans are like mini transactions that are attached to the main transaction. And child spans can have their own child spans as well. This feature allows you to measure your function’s performance in a greater detail, by starting a child span for every step of the function.

Let’s say your function performs the following steps:

1. Gather changed person properties
2. Pass each of them through a validation mechanism
3. Perform the updates to the database
4. Save the current timestamp as the "lastUpdated"
Enter fullscreen mode Exit fullscreen mode

You can measure each step individually using child spans like this:

// don't forget to import Sentry's SDK at the top
import Sentry

// ...

func syncPersonDatabase() {
  let transaction = SentrySDK.startTransaction(
    name: "Sync person database", // give it a name
    operation: "data.update" // and a name for the operation
  )

  // span the first child
  let gatherSpan = transaction.startChild(operation: "gather-changed")
  // perform the gather operation
  gatherSpan.finish()

  // span the second child
  let validationSpan = transaction.startChild(operation: "validate-changed")
  // perform the validation
  validationSpan.finish()

  // span the third child
  let updateSpan = transaction.startChild(operation: "update-database")
  // perform the update
  updateSpan.finish()

  // span the last child
  let timestampSpan = transaction.startChild(operation: "update-timestamp")
  // perform the timestamp update
  timestampSpan.finish()

  transaction.finish()
}
Enter fullscreen mode Exit fullscreen mode

Remember: Only finished spans will be sent along with the transaction. So you need to make sure that you’re finishing each child span before finishing the main transaction.

Monitoring the performance of child spans

Alright, you have set up child spans and you're ready to run your app again with the latest changes...

You run it again...

Maybe you run it one more time for good measure...

Done! You head back to your Sentry dashboard and notice that the Suspect Spans table provides more information now. You can see the child spans, and how long they took to execute.

Suspect Spans Table

Clicking on the “View All Spans” button you can see and sort all of them.

But you realize that your function has conditional steps that don’t always execute. Or maybe you have dynamically generated child spans whose name contains a unique id. To see the actual order of the child spans, you have to pick a specific event from the Events Table above and a new page will open with more details around that specific event.

Event Details

You can see from the Gantt chart the order of execution of the child spans.

Event Details Gantt Chart

Here you can see that:

  • The data.update encapsulates all child spans
  • The gather-changed started executing first and took 16.02ms
  • Then the validate-changed ran for 5.64ms
  • Then the update-database took its 121.02ms to run
  • And finally the update-timestamp flashed for 5.63ms.

You can identify which step contributes the most to the performance of your function with this. You can now turn our whole focus to improving the performance of your update-database function.

Using a transaction in multiple functions

Now that you have a hang of measuring straightforward functions, you're ready to tackle something a bit more complex. You realize that you want to measure performance on multiple nested functions. You do not need to pass the transaction as an argument. Instead, you can bind the transaction to the current scope:

let transaction = SentrySDK.startTransaction(
  name: "Sync person database", // give it a name
  operation: "data.update", // and a name for the operation
  bindToScope: true // bind the transaction to the curent scope
)
Enter fullscreen mode Exit fullscreen mode

Now you can access this transaction without having to pass it as an argument while you're in the current scope. You could have a function gatherChangedProperties, that calls another function filterProperties. And to gain access to the transaction within the filterProperties, you can directly reference SentrySDK.span, and if no transaction exists yet, you can create it. Your structure should look something like this:

let transaction = SentrySDK.startTransaction(
  name: "Sync person database", // give it a name
  operation: "data.update", // and a name for the operation
  bindToScope: true // bind the transaction to the curent scope
)

let properties = gatherChangedProperties()

// ...

transaction.finish()

func gatherChangedProperties() {
  // ...
  let filteredProperties = filterProperties();
  // ...
}

func filterProperties() {
  // obtain the transaction
  var span = SentrySDK.span

  // if non existing, recreate it
  if span == nil {
    span = SentrySDK.startTransaction(...)
  }

  // use it like a normal transaction
  let filterSpan = span.startChild(operation: "filter-properties")

  //...
}
Enter fullscreen mode Exit fullscreen mode

Recap

  1. You can now create custom transactions to measure long running functions in your app with SentrySDK.startTransaction(...).
  2. You can measure more granularly by starting child spans to our transaction for each logical step in our function with transaction.startChild(...) to zero in on the slowest part of the function and improve it.
  3. If you’re measuring a more complex function with multiple branches, you can bind the transaction to the current scope by setting the bindToScope property to true.

Time to measure all the things! 🙌

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .