Skip to content

Add sample app #249

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Dec 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions sample-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Sample Android application for the Elastic APM Agent

> This is part of
> our [blog post](https://www.elastic.co/blog/monitoring-android-applications-elastic-apm) on
> Monitoring Android applications with Elastic APM. If you need more detailed information on the
> overall usage of the Elastic APM Agent, you should take a look at it.

To showcase an end-to-end scenario including distributed tracing we'll instrument this sample
weather application that comprises two Android UI fragments and a simple local backend
service based on Spring Boot.

The first Fragment will have a dropdown list with some city names and also a button that takes you
to the second one, where you’ll see the selected city’s current temperature. If you pick a
non-European city on the first screen, you’ll get an error from the (local) backend when you head to
the second screen. This is to demonstrate how network and backend errors are captured and correlated
in Elastic APM.

## How to run

### Launching the local backend service

As part of our sample app, we’re going to launch a simple local backend service that will handle our
app’s HTTP requests. The backend service is instrumented with
the [Elastic APM Java agent](https://www.elastic.co/guide/en/apm/agent/java/current/index.html) to
collect
and send its own APM data over to Elastic APM, allowing it to correlate the mobile interactions with
the processing of the backend requests.

In order to configure the local server, we need to set our Elastic APM endpoint and secret token (
the same used for our Android app in the previous step) into the
backend/src/main/resources/elasticapm.properties file:

```properties
service_name=weather-backend
application_packages=co.elastic.apm.android.sample
server_url=YOUR_ELASTIC_APM_URL
secret_token=YOUR_ELASTIC_APM_SECRET_TOKEN
```

After the backend configuration is done, we can proceed to start the server by running the following
command in a terminal located in the root directory of our sample project: `./gradlew bootRun` (or
`gradlew.bat bootRun` if you’re on Windows). Alternatively, you can start the backend service from
Android Studio.

### Using the app

Launch the sample app in an Android emulator (from Android Studio). Once everything is running, we
need to navigate around in the app to generate some load that we would like to observe in Elastic
APM. So, select a city, click Next and repeat it multiple times. Please, also make sure to select
New York at least once. You will see that the weather forecast won’t work for New York as the city.
Below, we will use Elastic APM to find out what’s going wrong when selecting New York.

### Analyzing the data

After launching the app and navigating through it, you should be able to start seeing telemetry data
coming into your configured Kibana instance. For a more detailed overview of what to see there, you
should take a look
at [this blog post](https://www.elastic.co/blog/monitoring-android-applications-elastic-apm) on
Monitoring Android applications with Elastic APM.
59 changes: 59 additions & 0 deletions sample-app/app/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'co.elastic.apm.android' version '0.13.0'
}

android {
namespace 'co.elastic.apm.android.sample'
compileSdk 32

defaultConfig {
applicationId "co.elastic.apm.android.sample"
minSdk 26
targetSdk 32
versionCode 1
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
viewBinding true
}
}

elasticApm {
serviceName = "weather-sample-app"
serverUrl = "http://10.0.2.2:8200" // Your Elastic APM server endpoint.
// secretToken = "my-apm-secret-token" // Uncomment and set it if this is your preferred auth method.
}

dependencies {
def lifecycle_version = "2.4.0"
def retrofit_version = "2.9.0"
implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'com.google.android.material:material:1.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.4.1'
implementation 'androidx.navigation:navigation-ui-ktx:2.4.1'
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
testImplementation 'junit:junit:4.13.2'
}
36 changes: 36 additions & 0 deletions sample-app/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".MyApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SampleWeatherApp"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.SampleWeatherApp.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package co.elastic.apm.android.sample

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import co.elastic.apm.android.sample.databinding.FragmentFirstBinding

class FirstFragment : Fragment() {

private var _binding: FragmentFirstBinding? = null
private val binding get() = _binding!!

override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentFirstBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

binding.buttonFirst.setOnClickListener {
val bundle = bundleOf("city" to binding.citySpinner.selectedItem.toString())
findNavController().navigate(R.id.action_FirstFragment_to_SecondFragment, bundle)
}

ArrayAdapter.createFromResource(
view.context,
R.array.city_array,
android.R.layout.simple_spinner_item
).also { adapter ->
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.citySpinner.adapter = adapter
}
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package co.elastic.apm.android.sample

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupActionBarWithNavController
import co.elastic.apm.android.sample.databinding.ActivityMainBinding
import com.google.android.material.snackbar.Snackbar

class MainActivity : AppCompatActivity() {

private lateinit var appBarConfiguration: AppBarConfiguration
private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

setSupportActionBar(binding.toolbar)

val navController = findNavController(R.id.nav_host_fragment_content_main)
appBarConfiguration = AppBarConfiguration(navController.graph)
setupActionBarWithNavController(navController, appBarConfiguration)

binding.fab.setOnClickListener { view ->
Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
.setAction("Action", null).show()
}
}

override fun onSupportNavigateUp(): Boolean {
val navController = findNavController(R.id.nav_host_fragment_content_main)
return navController.navigateUp() || super.onSupportNavigateUp()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package co.elastic.apm.android.sample

import android.app.Application
import co.elastic.apm.android.sdk.ElasticApmAgent

class MyApp : Application() {

override fun onCreate() {
super.onCreate()
ElasticApmAgent.initialize(this)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package co.elastic.apm.android.sample

import android.os.Bundle
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import co.elastic.apm.android.sample.databinding.FragmentSecondBinding
import co.elastic.apm.android.sample.network.WeatherRestManager
import co.elastic.apm.android.sample.network.data.ForecastResponse
import kotlinx.coroutines.launch

class SecondFragment : Fragment() {

private var _binding: FragmentSecondBinding? = null
private val binding get() = _binding!!

override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentSecondBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
lifecycleScope.launch {
try {
val city = arguments?.getString("city") ?: "Berlin"
binding.temperatureTitle.text = getString(
R.string.temperature_title,
city
)
updateTemperature(WeatherRestManager.getCurrentCityWeather(city))
showApiNotice()
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(requireContext(), R.string.unknown_error_message, Toast.LENGTH_SHORT)
.show()
}
}

binding.buttonSecond.setOnClickListener {
findNavController().navigate(R.id.action_SecondFragment_to_FirstFragment)
}
}

private fun showApiNotice() {
binding.txtApiNotice.movementMethod = LinkMovementMethod.getInstance()
binding.txtApiNotice.text = Html.fromHtml(getString(R.string.weather_api_notice_message))
}

private fun updateTemperature(response: ForecastResponse) {
binding.txtDegreesCelsius.text = getString(
R.string.temperature_in_celsius,
response.currentWeather.temperature
)
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package co.elastic.apm.android.sample.network

import co.elastic.apm.android.sample.network.data.ForecastResponse
import retrofit2.http.GET
import retrofit2.http.Query

interface CityWeatherService {
@GET("forecast")
suspend fun getCurrentWeather(
@Query("city") city: String
): ForecastResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package co.elastic.apm.android.sample.network

import co.elastic.apm.android.sample.network.data.ForecastResponse
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object WeatherRestManager {

private val service: CityWeatherService by lazy {
val retrofit = Retrofit.Builder()
.baseUrl("http://10.0.2.2:8080/v1/")
.addConverterFactory(GsonConverterFactory.create())
.build()
retrofit.create(CityWeatherService::class.java)
}

suspend fun getCurrentCityWeather(city: String): ForecastResponse {
return service.getCurrentWeather(city)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package co.elastic.apm.android.sample.network.data

data class CurrentWeatherResponse(val temperature: Double)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package co.elastic.apm.android.sample.network.data

import com.google.gson.annotations.SerializedName

data class ForecastResponse(
@SerializedName("current_weather")
val currentWeather: CurrentWeatherResponse
)
Loading