Build better Jetpack Compose apps with Sentry
Build better Jetpack Compose apps with Sentry
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:
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:
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”) }
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:
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 theinit
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.
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.
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.