Build better Jetpack Compose apps with Sentry

Sentry’s out-of-the-box integration with Jetpack Compose allows you to quickly identify and resolve errors and performance issues in your Android applications. Here’s how to get started.

Android mobile phone
Jenu Prasad / Google (CC0)

Jetpack Compose is Android’s recommended toolkit for building native UIs, representing the platform’s demonstrative shift from imperative to declarative UIs. Google is making a big push to drive adoption, and it’s paying off. As announced at the Android Dev Summit ’22 last October, 160 of the top 1,000 apps on the Google Play store are shipping Jetpack Compose, including companies like Airbnb, Lyft, and Square.

Jetpack Compose offers many benefits—it’s more intuitive, requires less code, and accelerates development. But it’s not without its challenges. Moving from an imperative toolkit to Jetpack Compose comes with a learning curve, which is exacerbated by limited documentation, a smaller community, and performance issues.

Sentry recently announced their support of Jetpack Compose, with an out-of-the-box integration that allows developers to quickly identify and solve issues in their application. Here’s exactly how Sentry helps teams get started with Jetpack Compose.

Start with Android Studio

If you are building a new application from scratch with Jetpack Compose, first download and install Android Studio, an integrated development environment (IDE) optimized for Android apps. Then, create a new project and select either the Empty Compose Activity, which uses Material v2, or Empty Compose Activity (Material3), which uses Material v3. You can see both options in the top right of this screenshot:

jetpack compose sentry 01 Sentry

If you’d like to integrate Jetpack Compose into an existing Android application, add the following build configurations in your app’s build.gradle file.

android {
    buildFeatures {
        // this flag enables Jetpack Compose
        compose true
    }
    composeOptions {
        // the compiler version should match
        // your project's Kotlin version
        kotlinCompilerExtensionVersion = "1.3.2"
    }
}

Then, add the Compose BOM (Bill of Materials) and the subset of Compose dependencies to your dependencies.

dependencies {
    def composeBom = platform('androidx.compose:compose-bom:2023.01.00')
    implementation composeBom
    androidTestImplementation composeBom
    // Choose one of the following:
    // Material Design 3
    implementation 'androidx.compose.material3:material3'
    // or Material Design 2
    implementation 'androidx.compose.material:material'
    // or skip Material Design and build directly on top of foundational components
    implementation 'androidx.compose.foundation:foundation'
    // or only import the main APIs for the underlying toolkit systems,
    // such as input and measurement/layout
    implementation 'androidx.compose.ui:ui'      
    // Android Studio Preview support
    implementation 'androidx.compose.ui:ui-tooling-preview'
    debugImplementation 'androidx.compose.ui:ui-tooling'
    // UI Tests
    androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
    debugImplementation 'androidx.compose.ui:ui-test-manifest'
    // Optional - Included automatically by material, only add when you need
    // the icons but not the material library (e.g. when using Material3 or a
    // custom design system based on Foundation)
    implementation 'androidx.compose.material:material-icons-core'
    // Optional - Add full set of material icons
    implementation 'androidx.compose.material:material-icons-extended'
    // Optional - Add window size utils
    implementation 'androidx.compose.material3:material3-window-size-class'
    // Optional - Integration with activities
    implementation 'androidx.activity:activity-compose:1.5.1'
    // Optional - Integration with ViewModels
    implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
    // Optional - Integration with LiveData
    implementation 'androidx.compose.runtime:runtime-livedata'
    // Optional - Integration with RxJava
    implementation 'androidx.compose.runtime:runtime-rxjava2'
}

Integrate Sentry

To integrate Sentry into your new Jetpack Compose app, all you need to do is add Sentry’s Gradle plugin in your module’s build.gradle file and perform a Gradle sync afterwards.

buildscript {
  repositories {
    mavenCentral()
  }
}
plugins {
  id "com.android.application"
  id "io.sentry.android.gradle" version "3.4.2"
}

And then add the necessary values in the AndroidManifest.xml file.

<application>
  <!-- Required: set your sentry.io project identifier (DSN) -->
  <meta-data android:name="io.sentry.dsn" android:value="https://examplePublicKey@o0.ingest.sentry.io/0" />
  <!-- enable automatic breadcrumbs for user interactions (clicks, swipes, scrolls) -->
  <meta-data android:name="io.sentry.traces.user-interaction.enable" android:value="true" />
  <!-- enable screenshot for crashes -->
  <meta-data android:name="io.sentry.attach-screenshot" android:value="true" />
  <!-- enable view hierarchy for crashes -->
  <meta-data android:name="io.sentry.attach-view-hierarchy" android:value="true" />
  <!-- enable the performance API by setting a sample-rate, adjust in production env -->
  <meta-data android:name="io.sentry.traces.sample-rate" android:value="1.0" />
  <!-- enable profiling when starting transactions, adjust in production env -->
  <meta-data android:name="io.sentry.traces.profiling.sample-rate" android:value="1.0" />
</application>

These two steps install and configure Sentry into your project. Aside from error reporting, your project now also has automatically instrumented performance monitoring. The Sentry SDK will automatically collect and analyze performance profiles so you can see how your application performs on different user devices in production.

Capturing errors

By default, Sentry captures all errors and crashes automatically for you. If you want to capture errors and exceptions manually, you can use the captureException method.

import io.sentry.Sentry
try {
  aMethodThatMightFail()
} catch (e: Exception) {
  Sentry.captureException(e)
}

Adding context

You have the option to add additional context to all of the errors that happen within your app. That’s arbitrary data that automatically gets attached to the event, and is viewable on the issue details page. To do that, we can attach custom contexts on the current scope like this:

import io.sentry.Sentry
Sentry.configureScope { scope ->
  scope.setContexts("Hero Details", mapOf(
    "Name" to "Mighty Fighter",
    "Age" to 19,
    "Attack type" to "Melee",
  ))
}

This data will now be appended to each issue. We can check it out at the issue details page:

jetpack compose sentry 02 Sentry

Important: There are two limitations you should be aware of. Namely:

  • This data is not searchable. It’s only used to attach values to the events. If you need to be able to search on custom data, use tags instead.
  • There are size limitations on how much data you can add to the scope. Sentry does not recommend sending the entire application state and large data blobs in contexts. In the event of appending too much data, Sentry will respond with the HTTP error “413 Payload Too Large” and reject the event.

Adding tags

Just like the additional context, we can also add custom tags on your events, which by contrast are indexed and searchable. You can use tags to quickly access related events and view the tag distribution for a set of events. Common uses for tags include hostname, platform version, and user language.

Adding tags is very similar to adding additional contexts. They’re key-value pairs and they can be added to the current scope by using the setTag method.

import io.sentry.Sentry
Sentry.configureScope { scope ->
  scope.setTag(“user-type”, “premium”)
}
jetpack compose sentry 03 Sentry

As mentioned, tags are indexed and searchable, so if you add “user-type:premium” in the Custom Search field in the Issues page you’ll see all of the issues that have that tag:

jetpack compose sentry 04 Sentry

Three things to be aware of when working with tags:

  • Sentry automatically adds some tags to every issue. It is not a good idea to overwrite those tags. Instead, name your tags using your organization’s nomenclature.
  • The keys have a maximum length of 32 characters and they can contain only letters, numbers, underscores, periods, colons, and dashes.
  • The values have a maximum length of 200 characters and they cannot contain the newline (\n) character.

Adding attachments

Adding attachments is yet another way to supplement the events with additional data, and it’s the recommended way if you need to add larger data than contexts and tags. Attachments can be any type of file.

To add an attachment, you can either add it to the scope, pass it to any of the capture methods, or manipulate the list of attachments in an EventProcessor or beforeSend.

Some rules you need to be aware of when working with attachments:

  • Attachments are kept for 30 days. If your total storage quota is exceeded, attachments won’t be stored.
  • You can delete attachments at any time, but that won’t affect your quota. Sentry counts an attachment towards your quota as soon as it is stored.
  • You can manage access to the attachments based on the user role. Navigate to your organization’s General Settings, then select the Attachment Access dropdown to set appropriate access. By default, access is granted to all members when storage is enabled.
  • The maximum size for each attachment is set on options.maxAttachmentSize in the init method. The scale is in bytes and the default is 20 MiB. You can change the size like so:
    Sentry.init { options ->
      options.maxAttachmentSize = 5 * 1024 * 1024 // 5 MiB
    }

Passing attachments to capture methods

This is probably the simplest way to add attachments. Whenever you’re using one of the capture methods, you can append the attachment as the second argument by using the Hint.withAttachment method.

import io.sentry.Attachment
import io.sentry.Hint
import io.sentry.Sentry
…
try {
  …
} catch (e: Exception) {
  Sentry.captureException(e, Hint.withAttachment(“/path/to/file.txt”))
}

Adding attachments in beforeSend

Another way of adding attachments is using the beforeSend callback.

import io.sentry.Sentry
import io.sentry.SentryOptions.BeforeSendCallback
import io.sentry.Hint
import io.sentry.Attachment
Sentry.init(this) { options ->
  options.beforeSend = BeforeSendCallback { event, hint ->
    hint.addAttachment(Attachment(“/path/to/file.txt”))
  }
}

This configuration will add the file.txt file to every issue before sending it to the cloud.

Viewing attachments

You can see the attachments for a given issue at the bottom of the issue details page. There’s an Attachments section that lists all attachments and you have the option to delete, download, or preview them.

jetpack compose sentry 05 Sentry

You can also access attachments through the Attachments tab on the same page, where you can view the type of attachment, as well as associated events. You can click the Event ID to open the Issue Details of that specific event.

jetpack compose sentry 06 Sentry

Measuring performance

If you’ve provided the Sample Rate value (io.sentry.traces.sample-rate) in your AndroidManifest.xml file, then you’ve already configured Sentry to automatically instrument your application. Sentry will automatically capture transactions for lifecycle events of activities and fragments, cold and warm app start, slow and frozen frames, and other events.

It’s also possible to manually instrument a specific function, for example a function that spends some time reshaping a large chunk of data, or a function that obtains data from an API and puts it in the local storage, etc. In order to create custom instrumentations, you’d need to start a transaction by calling the startTransaction method.

import io.sentry.Sentry
import io.sentry.SpanStatus
val transaction = Sentry.startTransaction(“processOrderBatch()”, “task”)
try {
  processOrderBatch()
} catch (e: Exception) {
  transaction.throwable = e
  transaction.status = SpanStatus.INTERNAL_ERROR
  throw e
} finally {
  transaction.finish()
}

Warning: Don’t forget to call the finish() method on the transaction, otherwise the transaction won’t be sent to Sentry.

If the function you’re trying to instrument is more complex and involves multiple sub-functions that you’d prefer to instrument individually, you can create child spans for each of them and attach them to the main transaction.

import io.sentry.SpanStatus
val transaction = Sentry.startTransaction("processOrderBatch()", "task")
try {
  processOrderBatch(transaction)
} catch (e: Exception) {
  transaction.throwable = e
  transaction.status = SpanStatus.INTERNAL_ERROR
  throw e
} finally {
  transaction.finish()
}
fun processOrderBatch(span: ISpan) {
  // span operation: task, span description: operation
  val innerSpan = it.startChild("task", "operation")
  try {
    // omitted code
  } catch (e: FileNotFoundException) {
    innerSpan.throwable = e
    innerSpan.status = SpanStatus.NOT_FOUND
    throw e
  } finally {
    innerSpan.finish()
  }
}

Don’t forget to call the finish() method on each of the spans before calling the main transaction’s finish() method! If you don’t, they won’t be attached to the main transaction.

1 2 Page 1
Page 1 of 2