Skip to content

Commit

Permalink
Feat: Add OkHttp client application interceptor (#1330)
Browse files Browse the repository at this point in the history
Co-authored-by: Maciej Walkowiak <walkowiak.maciej@yahoo.com>
  • Loading branch information
marandaneto and maciejwalkowiak committed Mar 18, 2021
1 parent 35560d1 commit fa1b8b7
Show file tree
Hide file tree
Showing 20 changed files with 384 additions and 45 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -5,6 +5,7 @@
* Feat: Set SDK version on Transactions (#1307)
* Fix: Use logger set on SentryOptions in GsonSerializer (#1308)
* Fix: Use the bindToScope correctly
* Feat: Add OkHttp client application interceptor (#1330)

# 4.3.0

Expand Down
4 changes: 4 additions & 0 deletions buildSrc/src/main/java/Config.kt
Expand Up @@ -24,6 +24,7 @@ object Config {
private val sdkVersion = 30

val minSdkVersion = 14
val minSdkVersionOkHttp = 21
val minSdkVersionNdk = 16
val targetSdkVersion = sdkVersion
val compileSdkVersion = sdkVersion
Expand All @@ -34,6 +35,8 @@ object Config {
object Libs {
val appCompat = "androidx.appcompat:appcompat:1.2.0"
val timber = "com.jakewharton.timber:timber:4.7.1"
val okhttpBom = "com.squareup.okhttp3:okhttp-bom:4.9.0"
val okhttp = "com.squareup.okhttp3:okhttp"
// only bump gson if https://github.com/google/gson/issues/1597 is fixed
val gson = "com.google.code.gson:gson:2.8.5"
val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.5"
Expand Down Expand Up @@ -88,6 +91,7 @@ object Config {
val mockitoKotlin = "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
val mockitoInline = "org.mockito:mockito-inline:3.6.0"
val awaitility = "org.awaitility:awaitility-kotlin:4.0.3"
val mockWebserver = "com.squareup.okhttp3:mockwebserver:4.9.0"
}

object QualityPlugins {
Expand Down
15 changes: 15 additions & 0 deletions sentry-android-okhttp/api/sentry-android-okhttp.api
@@ -0,0 +1,15 @@
public final class io/sentry/android/okhttp/BuildConfig {
public static final field BUILD_TYPE Ljava/lang/String;
public static final field DEBUG Z
public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String;
public static final field VERSION_NAME Ljava/lang/String;
public fun <init> ()V
}

public final class io/sentry/android/okhttp/SentryOkHttpInterceptor : okhttp3/Interceptor {
public fun <init> ()V
public fun <init> (Lio/sentry/IHub;)V
public synthetic fun <init> (Lio/sentry/IHub;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response;
}

94 changes: 94 additions & 0 deletions sentry-android-okhttp/build.gradle.kts
@@ -0,0 +1,94 @@
import io.gitlab.arturbosch.detekt.Detekt
import io.gitlab.arturbosch.detekt.extensions.DetektExtension
import org.jetbrains.kotlin.config.KotlinCompilerVersion

plugins {
id("com.android.library")
kotlin("android")
jacoco
id(Config.QualityPlugins.gradleVersions)
id(Config.QualityPlugins.detektPlugin)
}

android {
compileSdkVersion(Config.Android.compileSdkVersion)

defaultConfig {
targetSdkVersion(Config.Android.targetSdkVersion)
minSdkVersion(Config.Android.minSdkVersionOkHttp)

versionName = project.version.toString()
versionCode = project.properties[Config.Sentry.buildVersionCodeProp].toString().toInt()

// for AGP 4.1
buildConfigField("String", "VERSION_NAME", "\"$versionName\"")
}

buildTypes {
getByName("debug")
getByName("release") {
consumerProguardFiles("proguard-rules.pro")
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}

kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}

testOptions {
animationsDisabled = true
unitTests.apply {
isReturnDefaultValues = true
isIncludeAndroidResources = true
}
}

lintOptions {
isWarningsAsErrors = true
isCheckDependencies = true

// We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks.
isCheckReleaseBuilds = false
}
}

tasks.withType<Test> {
configure<JacocoTaskExtension> {
isIncludeNoLocationClasses = false
}
}

kotlin {
explicitApi()
}

dependencies {
api(project(":sentry"))

implementation(Config.Libs.okhttpBom)
implementation(Config.Libs.okhttp)

implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION))

// tests
testImplementation(Config.TestLibs.kotlinTestJunit)
testImplementation(Config.TestLibs.androidxJunit)
testImplementation(Config.TestLibs.mockitoKotlin)
testImplementation(Config.TestLibs.mockitoInline)
testImplementation(Config.TestLibs.mockWebserver)
}

tasks.withType<Detekt> {
// Target version of the generated JVM bytecode. It is used for type resolution.
jvmTarget = JavaVersion.VERSION_1_8.toString()
}

configure<DetektExtension> {
failFast = true
buildUponDefaultConfig = true
}
23 changes: 23 additions & 0 deletions sentry-android-okhttp/proguard-rules.pro
@@ -0,0 +1,23 @@
##---------------Begin: proguard configuration for OkHttp ----------

-keep class io.sentry.android.okhttp.** { *; }

# To ensure that stack traces is unambiguous
# https://developer.android.com/studio/build/shrink-code#decode-stack-trace
-keepattributes LineNumberTable,SourceFile

# https://square.github.io/okhttp/r8_proguard/
# JSR 305 annotations are for embedding nullability information.
-dontwarn javax.annotation.**

# A resource is loaded with a relative path so the package of this class must be preserved.
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase

# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
-dontwarn org.codehaus.mojo.animal_sniffer.*

# OkHttp platform used only on JVM and when Conscrypt dependency is available.
-dontwarn okhttp3.internal.platform.ConscryptPlatform
-dontwarn org.conscrypt.ConscryptHostnameVerifier

##---------------End: proguard configuration for OkHttp ----------
2 changes: 2 additions & 0 deletions sentry-android-okhttp/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="io.sentry.android.okhttp" />
@@ -0,0 +1,56 @@
package io.sentry.android.okhttp

import io.sentry.Breadcrumb
import io.sentry.HubAdapter
import io.sentry.IHub
import io.sentry.SpanStatus
import java.io.IOException
import okhttp3.Interceptor
import okhttp3.Response

class SentryOkHttpInterceptor(
private val hub: IHub = HubAdapter.getInstance()
) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()

val url = request.url.toString()
val method = request.method

// read transaction from the bound scope
val span = hub.span?.startChild("http.client", "$method $url")

var response: Response? = null

var code: Int? = null
try {
span?.toSentryTrace()?.let {
request = request.newBuilder().addHeader(it.name, it.value).build()
}
response = chain.proceed(request)
code = response.code
return response
} catch (e: IOException) {
span?.throwable = e
throw e
} finally {
span?.finish(SpanStatus.fromHttpStatusCode(code, SpanStatus.INTERNAL_ERROR))

val breadcrumb = Breadcrumb.http(request.url.toString(), request.method, code)
request.body?.contentLength().ifHasValidLength {
breadcrumb.setData("requestBodySize", it)
}
response?.body?.contentLength().ifHasValidLength {
breadcrumb.setData("responseBodySize", it)
}
hub.addBreadcrumb(breadcrumb)
}
}

private fun Long?.ifHasValidLength(fn: (Long) -> Unit) {
if (this != null && this != -1L) {
fn.invoke(this)
}
}
}
@@ -0,0 +1,118 @@
package io.sentry.android.okhttp

import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.check
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import io.sentry.Breadcrumb
import io.sentry.IHub
import io.sentry.SentryTraceHeader
import io.sentry.SentryTracer
import io.sentry.SpanStatus
import io.sentry.TransactionContext
import java.io.IOException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.fail
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer

class SentryOkHttpInterceptorTest {

class Fixture {
val hub = mock<IHub>()
val interceptor = SentryOkHttpInterceptor(hub)
val server = MockWebServer()
val sentryTracer = SentryTracer(TransactionContext("name", "op"), hub)

fun getSut(isSpanActive: Boolean = true, httpStatusCode: Int = 201, responseBody: String = "success"): OkHttpClient {
if (isSpanActive) {
whenever(hub.span).thenReturn(sentryTracer)
}
server.enqueue(MockResponse().setBody(responseBody).setResponseCode(httpStatusCode))
server.start()
return OkHttpClient.Builder().addInterceptor(interceptor).build()
}
}

val fixture = Fixture()

@Test
fun `when there is an active span, adds sentry trace header to the request`() {
val sut = fixture.getSut()
sut.newCall(Request.Builder().get().url(fixture.server.url("/hello")).build()).execute()
val recorderRequest = fixture.server.takeRequest()
assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER])
}

@Test
fun `when there is no active span, does not add sentry trace header to the request`() {
val sut = fixture.getSut(isSpanActive = false)
sut.newCall(Request.Builder().get().url(fixture.server.url("/hello")).build()).execute()
val recorderRequest = fixture.server.takeRequest()
assertNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER])
}

@Test
fun `does not overwrite response body`() {
val sut = fixture.getSut()
val response = sut.newCall(Request.Builder().get().url(fixture.server.url("/hello")).build()).execute()
assertEquals("success", response.body?.string())
}

@Test
fun `creates a span around the request`() {
val sut = fixture.getSut()
val url = fixture.server.url("/hello")
sut.newCall(Request.Builder().get().url(url).build()).execute()
assertEquals(1, fixture.sentryTracer.children.size)
val httpClientSpan = fixture.sentryTracer.children.first()
assertEquals("http.client", httpClientSpan.operation)
assertEquals("GET $url", httpClientSpan.description)
assertEquals(SpanStatus.OK, httpClientSpan.status)
}

@Test
fun `maps http status code to SpanStatus`() {
val sut = fixture.getSut(httpStatusCode = 400)
val url = fixture.server.url("/hello")
sut.newCall(Request.Builder().get().url(url).build()).execute()
val httpClientSpan = fixture.sentryTracer.children.first()
assertEquals(SpanStatus.INVALID_ARGUMENT, httpClientSpan.status)
}

@Test
fun `adds breadcrumb when http calls succeeds`() {
val sut = fixture.getSut(responseBody = "response body")
sut.newCall(Request.Builder().post("request-body".toRequestBody("text/plain".toMediaType())).url(fixture.server.url("/hello")).build()).execute()
verify(fixture.hub).addBreadcrumb(check<Breadcrumb> {
assertEquals("http", it.type)
assertEquals(13L, it.data["responseBodySize"])
assertEquals(12L, it.data["requestBodySize"])
})
}

@Test
fun `adds breadcrumb when http calls results in exception`() {
val chain = mock<Interceptor.Chain>()
whenever(chain.proceed(any())).thenThrow(IOException())
whenever(chain.request()).thenReturn(Request.Builder().get().url(fixture.server.url("/hello")).build())

try {
fixture.interceptor.intercept(chain)
fail()
} catch (e: IOException) {}

verify(fixture.hub).addBreadcrumb(check<Breadcrumb> {
assertEquals("http", it.type)
})
}
}
@@ -0,0 +1 @@
mock-maker-inline
Expand Up @@ -14,6 +14,7 @@ public abstract interface class io/sentry/samples/android/GitHubService {
public class io/sentry/samples/android/MainActivity : androidx/appcompat/app/AppCompatActivity {
public fun <init> ()V
protected fun onCreate (Landroid/os/Bundle;)V
protected fun onResume ()V
}

public class io/sentry/samples/android/MyApplication : android/app/Application {
Expand All @@ -27,11 +28,6 @@ public class io/sentry/samples/android/NativeSample {
public static fun message ()V
}

public final class io/sentry/samples/android/NetworkInterceptor : okhttp3/Interceptor {
public fun <init> ()V
public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response;
}

public final class io/sentry/samples/android/Repo {
public fun <init> ()V
}
Expand Down
3 changes: 2 additions & 1 deletion sentry-samples/sentry-samples-android/build.gradle.kts
Expand Up @@ -8,7 +8,7 @@ android {

defaultConfig {
applicationId = "io.sentry.samples.android"
minSdkVersion(Config.Android.minSdkVersionNdk)
minSdkVersion(Config.Android.minSdkVersionOkHttp)
targetSdkVersion(Config.Android.targetSdkVersion)
versionCode = 2
versionName = "1.1.0"
Expand Down Expand Up @@ -96,6 +96,7 @@ dependencies {
implementation(kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION))

implementation(project(":sentry-android"))
implementation(project(":sentry-android-okhttp"))

// how to exclude androidx if release health feature is disabled
// implementation(project(":sentry-android")) {
Expand Down

0 comments on commit fa1b8b7

Please sign in to comment.