diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f37fd007d..4b12c480b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ ### Features +- More granular http requests instrumentation with a new SentryOkHttpEventListener ([#2659](https://github.com/getsentry/sentry-java/pull/2659)) + - Create spans for time spent on: + - Proxy selection + - DNS resolution + - HTTPS setup + - Connection + - Requesting headers + - Receiving response + - You can attach the event listener to your OkHttpClient through `client.eventListener(new SentryOkHttpEventListener()).addInterceptor(new SentryOkHttpInterceptor()).build();` + - In case you already have an event listener you can use the SentryOkHttpEventListener as well through `client.eventListener(new SentryOkHttpEventListener(myListener)).addInterceptor(new SentryOkHttpInterceptor()).build();` - Add Screenshot and ViewHierarchy to integrations list ([#2698](https://github.com/getsentry/sentry-java/pull/2698)) - New ANR detection based on [ApplicationExitInfo API](https://developer.android.com/reference/android/app/ApplicationExitInfo) ([#2697](https://github.com/getsentry/sentry-java/pull/2697)) - This implementation completely replaces the old one (based on a watchdog) on devices running Android 11 and above: diff --git a/sentry-android-okhttp/api/sentry-android-okhttp.api b/sentry-android-okhttp/api/sentry-android-okhttp.api index 897755948a4..ec5a385f949 100644 --- a/sentry-android-okhttp/api/sentry-android-okhttp.api +++ b/sentry-android-okhttp/api/sentry-android-okhttp.api @@ -6,6 +6,44 @@ public final class io/sentry/android/okhttp/BuildConfig { public fun ()V } +public final class io/sentry/android/okhttp/SentryOkHttpEventListener : okhttp3/EventListener { + public static final field Companion Lio/sentry/android/okhttp/SentryOkHttpEventListener$Companion; + public fun ()V + public fun (Lio/sentry/IHub;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lio/sentry/IHub;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/IHub;Lokhttp3/EventListener$Factory;)V + public synthetic fun (Lio/sentry/IHub;Lokhttp3/EventListener$Factory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/IHub;Lokhttp3/EventListener;)V + public synthetic fun (Lio/sentry/IHub;Lokhttp3/EventListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun callEnd (Lokhttp3/Call;)V + public fun callFailed (Lokhttp3/Call;Ljava/io/IOException;)V + public fun callStart (Lokhttp3/Call;)V + public fun connectEnd (Lokhttp3/Call;Ljava/net/InetSocketAddress;Ljava/net/Proxy;Lokhttp3/Protocol;)V + public fun connectFailed (Lokhttp3/Call;Ljava/net/InetSocketAddress;Ljava/net/Proxy;Lokhttp3/Protocol;Ljava/io/IOException;)V + public fun connectStart (Lokhttp3/Call;Ljava/net/InetSocketAddress;Ljava/net/Proxy;)V + public fun connectionAcquired (Lokhttp3/Call;Lokhttp3/Connection;)V + public fun connectionReleased (Lokhttp3/Call;Lokhttp3/Connection;)V + public fun dnsEnd (Lokhttp3/Call;Ljava/lang/String;Ljava/util/List;)V + public fun dnsStart (Lokhttp3/Call;Ljava/lang/String;)V + public fun proxySelectEnd (Lokhttp3/Call;Lokhttp3/HttpUrl;Ljava/util/List;)V + public fun proxySelectStart (Lokhttp3/Call;Lokhttp3/HttpUrl;)V + public fun requestBodyEnd (Lokhttp3/Call;J)V + public fun requestBodyStart (Lokhttp3/Call;)V + public fun requestFailed (Lokhttp3/Call;Ljava/io/IOException;)V + public fun requestHeadersEnd (Lokhttp3/Call;Lokhttp3/Request;)V + public fun requestHeadersStart (Lokhttp3/Call;)V + public fun responseBodyEnd (Lokhttp3/Call;J)V + public fun responseBodyStart (Lokhttp3/Call;)V + public fun responseFailed (Lokhttp3/Call;Ljava/io/IOException;)V + public fun responseHeadersEnd (Lokhttp3/Call;Lokhttp3/Response;)V + public fun responseHeadersStart (Lokhttp3/Call;)V + public fun secureConnectEnd (Lokhttp3/Call;Lokhttp3/Handshake;)V + public fun secureConnectStart (Lokhttp3/Call;)V +} + +public final class io/sentry/android/okhttp/SentryOkHttpEventListener$Companion { +} + public final class io/sentry/android/okhttp/SentryOkHttpInterceptor : io/sentry/IntegrationName, okhttp3/Interceptor { public fun ()V public fun (Lio/sentry/IHub;)V diff --git a/sentry-android-okhttp/build.gradle.kts b/sentry-android-okhttp/build.gradle.kts index 5d566922f32..ae80ad068ef 100644 --- a/sentry-android-okhttp/build.gradle.kts +++ b/sentry-android-okhttp/build.gradle.kts @@ -74,6 +74,7 @@ dependencies { implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) // tests + testImplementation(projects.sentryTestSupport) testImplementation(Config.Libs.okhttp) testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation(Config.TestLibs.androidxJunit) diff --git a/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpEvent.kt b/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpEvent.kt new file mode 100644 index 00000000000..43ba274de97 --- /dev/null +++ b/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpEvent.kt @@ -0,0 +1,142 @@ +package io.sentry.android.okhttp + +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.ISpan +import io.sentry.SpanStatus +import io.sentry.TypeCheckHint +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.CONNECTION_EVENT +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.CONNECT_EVENT +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.REQUEST_BODY_EVENT +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.REQUEST_HEADERS_EVENT +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.RESPONSE_BODY_EVENT +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.RESPONSE_HEADERS_EVENT +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.SECURE_CONNECT_EVENT +import io.sentry.util.UrlUtils +import okhttp3.Request +import okhttp3.Response + +internal class SentryOkHttpEvent(private val hub: IHub, private val request: Request) { + private val eventSpans: MutableMap = HashMap() + private val breadcrumb: Breadcrumb + internal val callRootSpan: ISpan? + private var response: Response? = null + + init { + val urlDetails = UrlUtils.parse(request.url.toString()) + val url = urlDetails.urlOrFallback + val host: String = request.url.host + val encodedPath: String = request.url.encodedPath + val method: String = request.method + + // We start the call span that will contain all the others + callRootSpan = hub.span?.startChild("http.client", "$method $url") + + urlDetails.applyToSpan(callRootSpan) + + // We setup a breadcrumb with all meaningful data + breadcrumb = Breadcrumb.http(url, method) + breadcrumb.setData("url", url) + breadcrumb.setData("host", host) + breadcrumb.setData("path", encodedPath) + breadcrumb.setData("method", method) + breadcrumb.setData("success", true) + + // We add the same data to the root call span + callRootSpan?.setData("url", url) + callRootSpan?.setData("host", host) + callRootSpan?.setData("path", encodedPath) + callRootSpan?.setData("http.method", method) + callRootSpan?.setData("success", true) + } + + /** + * Sets the [Response] that will be sent in the breadcrumb [Hint]. + * Also, it sets the protocol and status code in the breadcrumb and the call root span. + */ + fun setResponse(response: Response) { + this.response = response + breadcrumb.setData("protocol", response.protocol.name) + breadcrumb.setData("status_code", response.code) + callRootSpan?.setData("protocol", response.protocol.name) + callRootSpan?.setData("status_code", response.code) + callRootSpan?.status = SpanStatus.fromHttpStatusCode(response.code) + } + + fun setProtocol(protocolName: String?) { + if (protocolName != null) { + breadcrumb.setData("protocol", protocolName) + callRootSpan?.setData("protocol", protocolName) + } + } + + fun setRequestBodySize(byteCount: Long) { + if (byteCount > -1) { + breadcrumb.setData("request_content_length", byteCount) + callRootSpan?.setData("http.request_content_length", byteCount) + } + } + + fun setResponseBodySize(byteCount: Long) { + if (byteCount > -1) { + breadcrumb.setData("response_content_length", byteCount) + callRootSpan?.setData("http.response_content_length", byteCount) + } + } + + /** + * Sets the success flag in the breadcrumb and the call root span to false. + * Also sets the [errorMessage] if not null. + */ + fun setError(errorMessage: String?) { + breadcrumb.setData("success", false) + callRootSpan?.setData("success", false) + if (errorMessage != null) { + breadcrumb.setData("error_message", errorMessage) + callRootSpan?.setData("error_message", errorMessage) + } + } + + /** Starts a span, if the callRootSpan is not null. */ + fun startSpan(event: String) { + // Find the parent of the span being created. E.g. secureConnect is child of connect + val parentSpan = when (event) { + // PROXY_SELECT, DNS, CONNECT and CONNECTION are not children of one another + SECURE_CONNECT_EVENT -> eventSpans[CONNECT_EVENT] + REQUEST_HEADERS_EVENT -> eventSpans[CONNECTION_EVENT] + REQUEST_BODY_EVENT -> eventSpans[CONNECTION_EVENT] + RESPONSE_HEADERS_EVENT -> eventSpans[CONNECTION_EVENT] + RESPONSE_BODY_EVENT -> eventSpans[CONNECTION_EVENT] + else -> callRootSpan + } ?: callRootSpan + val span = parentSpan?.startChild("http.client.details", event) ?: return + eventSpans[event] = span + } + + /** Finishes a previously started span, and runs [beforeFinish] on it and on the call root span. */ + fun finishSpan(event: String, beforeFinish: ((span: ISpan) -> Unit)? = null) { + val span = eventSpans[event] ?: return + beforeFinish?.invoke(span) + callRootSpan?.let { beforeFinish?.invoke(it) } + span.finish() + } + + /** Finishes the call root span, and runs [beforeFinish] on it. Then a breadcrumb is sent. */ + fun finishEvent(beforeFinish: ((span: ISpan) -> Unit)? = null) { + callRootSpan ?: return + + // We forcefully finish all spans, even if they should already have been finished through finishSpan() + eventSpans.values.filter { !it.isFinished }.forEach { it.finish(SpanStatus.DEADLINE_EXCEEDED) } + beforeFinish?.invoke(callRootSpan) + callRootSpan.finish() + + // We put data in the hint and send a breadcrumb + val hint = Hint() + hint.set(TypeCheckHint.OKHTTP_REQUEST, request) + response?.let { hint.set(TypeCheckHint.OKHTTP_RESPONSE, it) } + + hub.addBreadcrumb(breadcrumb, hint) + return + } +} diff --git a/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpEventListener.kt b/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpEventListener.kt new file mode 100644 index 00000000000..686cd16ae20 --- /dev/null +++ b/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpEventListener.kt @@ -0,0 +1,370 @@ +package io.sentry.android.okhttp + +import io.sentry.HubAdapter +import io.sentry.IHub +import io.sentry.SpanStatus +import okhttp3.Call +import okhttp3.Connection +import okhttp3.EventListener +import okhttp3.Handshake +import okhttp3.HttpUrl +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Proxy + +/** + * Logs network performance event metrics to Sentry + * + * Usage - add instance of [SentryOkHttpEventListener] in [OkHttpClient.eventListener] + * + * ``` + * val client = OkHttpClient.Builder() + * .eventListener(SentryOkHttpEventListener()) + * .addInterceptor(SentryOkHttpInterceptor()) + * .build() + * ``` + * + * If you already use a [OkHttpClient.eventListener], you can pass it in the constructor. + * + * ``` + * val client = OkHttpClient.Builder() + * .eventListener(SentryOkHttpEventListener(myEventListener)) + * .addInterceptor(SentryOkHttpInterceptor()) + * .build() + * ``` + */ +@Suppress("TooManyFunctions") +class SentryOkHttpEventListener( + private val hub: IHub = HubAdapter.getInstance(), + private val originalEventListenerCreator: ((call: Call) -> EventListener)? = null +) : EventListener() { + + private var originalEventListener: EventListener? = null + + companion object { + internal const val PROXY_SELECT_EVENT = "proxySelect" + internal const val DNS_EVENT = "dns" + internal const val SECURE_CONNECT_EVENT = "secureConnect" + internal const val CONNECT_EVENT = "connect" + internal const val CONNECTION_EVENT = "connection" + internal const val REQUEST_HEADERS_EVENT = "requestHeaders" + internal const val REQUEST_BODY_EVENT = "requestBody" + internal const val RESPONSE_HEADERS_EVENT = "responseHeaders" + internal const val RESPONSE_BODY_EVENT = "responseBody" + + internal val eventMap: MutableMap = HashMap() + } + + constructor(hub: IHub = HubAdapter.getInstance(), originalEventListener: EventListener) : this( + hub, + originalEventListenerCreator = { originalEventListener } + ) + + constructor(hub: IHub = HubAdapter.getInstance(), originalEventListenerFactory: Factory) : this( + hub, + originalEventListenerCreator = { originalEventListenerFactory.create(it) } + ) + + override fun callStart(call: Call) { + originalEventListener = originalEventListenerCreator?.invoke(call) + originalEventListener?.callStart(call) + // If the wrapped EventListener is ours, we can just delegate the calls, + // without creating other events that would create duplicates + if (canCreateEventSpan()) { + eventMap[call] = SentryOkHttpEvent(hub, call.request()) + } + } + + override fun proxySelectStart(call: Call, url: HttpUrl) { + originalEventListener?.proxySelectStart(call, url) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.startSpan(PROXY_SELECT_EVENT) + } + + override fun proxySelectEnd( + call: Call, + url: HttpUrl, + proxies: List + ) { + originalEventListener?.proxySelectEnd(call, url, proxies) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.finishSpan(PROXY_SELECT_EVENT) { + if (proxies.isNotEmpty()) { + it.setData("proxies", proxies.joinToString { proxy -> proxy.toString() }) + } + } + } + + override fun dnsStart(call: Call, domainName: String) { + originalEventListener?.dnsStart(call, domainName) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.startSpan(DNS_EVENT) + } + + override fun dnsEnd( + call: Call, + domainName: String, + inetAddressList: List + ) { + originalEventListener?.dnsEnd(call, domainName, inetAddressList) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.finishSpan(DNS_EVENT) { + it.setData("domain_name", domainName) + if (inetAddressList.isNotEmpty()) { + it.setData("dns_addresses", inetAddressList.joinToString { address -> address.toString() }) + } + } + } + + override fun connectStart( + call: Call, + inetSocketAddress: InetSocketAddress, + proxy: Proxy + ) { + originalEventListener?.connectStart(call, inetSocketAddress, proxy) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.startSpan(CONNECT_EVENT) + } + + override fun secureConnectStart(call: Call) { + originalEventListener?.secureConnectStart(call) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.startSpan(SECURE_CONNECT_EVENT) + } + + override fun secureConnectEnd(call: Call, handshake: Handshake?) { + originalEventListener?.secureConnectEnd(call, handshake) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.finishSpan(SECURE_CONNECT_EVENT) + } + + override fun connectEnd( + call: Call, + inetSocketAddress: InetSocketAddress, + proxy: Proxy, + protocol: Protocol? + ) { + originalEventListener?.connectEnd(call, inetSocketAddress, proxy, protocol) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.setProtocol(protocol?.name) + okHttpEvent.finishSpan(CONNECT_EVENT) + } + + override fun connectFailed( + call: Call, + inetSocketAddress: InetSocketAddress, + proxy: Proxy, + protocol: Protocol?, + ioe: IOException + ) { + originalEventListener?.connectFailed(call, inetSocketAddress, proxy, protocol, ioe) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.setProtocol(protocol?.name) + okHttpEvent.setError(ioe.message) + okHttpEvent.finishSpan(CONNECT_EVENT) { + it.throwable = ioe + it.status = SpanStatus.INTERNAL_ERROR + } + } + + override fun connectionAcquired(call: Call, connection: Connection) { + originalEventListener?.connectionAcquired(call, connection) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.startSpan(CONNECTION_EVENT) + } + + override fun connectionReleased(call: Call, connection: Connection) { + originalEventListener?.connectionReleased(call, connection) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.finishSpan(CONNECTION_EVENT) + } + + override fun requestHeadersStart(call: Call) { + originalEventListener?.requestHeadersStart(call) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.startSpan(REQUEST_HEADERS_EVENT) + } + + override fun requestHeadersEnd(call: Call, request: Request) { + originalEventListener?.requestHeadersEnd(call, request) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.finishSpan(REQUEST_HEADERS_EVENT) + } + + override fun requestBodyStart(call: Call) { + originalEventListener?.requestBodyStart(call) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.startSpan(REQUEST_BODY_EVENT) + } + + override fun requestBodyEnd(call: Call, byteCount: Long) { + originalEventListener?.requestBodyEnd(call, byteCount) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.finishSpan(REQUEST_BODY_EVENT) { + if (byteCount > 0) { + it.setData("http.request_content_length", byteCount) + } + } + okHttpEvent.setRequestBodySize(byteCount) + } + + override fun requestFailed(call: Call, ioe: IOException) { + originalEventListener?.requestFailed(call, ioe) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.setError(ioe.message) + // requestFailed can happen after requestHeaders or requestBody. + // If requestHeaders already finished, we don't change its status. + okHttpEvent.finishSpan(REQUEST_HEADERS_EVENT) { + if (!it.isFinished) { + it.status = SpanStatus.INTERNAL_ERROR + it.throwable = ioe + } + } + okHttpEvent.finishSpan(REQUEST_BODY_EVENT) { + it.status = SpanStatus.INTERNAL_ERROR + it.throwable = ioe + } + } + + override fun responseHeadersStart(call: Call) { + originalEventListener?.responseHeadersStart(call) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.startSpan(RESPONSE_HEADERS_EVENT) + } + + override fun responseHeadersEnd(call: Call, response: Response) { + originalEventListener?.responseHeadersEnd(call, response) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.setResponse(response) + okHttpEvent.finishSpan(RESPONSE_HEADERS_EVENT) { + it.setData("status_code", response.code) + it.status = SpanStatus.fromHttpStatusCode(response.code) + } + } + + override fun responseBodyStart(call: Call) { + originalEventListener?.responseBodyStart(call) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.startSpan(RESPONSE_BODY_EVENT) + } + + override fun responseBodyEnd(call: Call, byteCount: Long) { + originalEventListener?.responseBodyEnd(call, byteCount) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.setResponseBodySize(byteCount) + okHttpEvent.finishSpan(RESPONSE_BODY_EVENT) { + if (byteCount > 0) { + it.setData("http.response_content_length", byteCount) + } + } + } + + override fun responseFailed(call: Call, ioe: IOException) { + originalEventListener?.responseFailed(call, ioe) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return + okHttpEvent.setError(ioe.message) + // responseFailed can happen after responseHeaders or responseBody. + // If responseHeaders already finished, we don't change its status. + okHttpEvent.finishSpan(RESPONSE_HEADERS_EVENT) { + if (!it.isFinished) { + it.status = SpanStatus.INTERNAL_ERROR + it.throwable = ioe + } + } + okHttpEvent.finishSpan(RESPONSE_BODY_EVENT) { + it.status = SpanStatus.INTERNAL_ERROR + it.throwable = ioe + } + } + + override fun callEnd(call: Call) { + originalEventListener?.callEnd(call) + val okHttpEvent: SentryOkHttpEvent = eventMap.remove(call) ?: return + okHttpEvent.finishEvent() + } + + override fun callFailed(call: Call, ioe: IOException) { + originalEventListener?.callFailed(call, ioe) + if (!canCreateEventSpan()) { + return + } + val okHttpEvent: SentryOkHttpEvent = eventMap.remove(call) ?: return + okHttpEvent.setError(ioe.message) + okHttpEvent.finishEvent { + it.status = SpanStatus.INTERNAL_ERROR + it.throwable = ioe + } + } + + private fun canCreateEventSpan(): Boolean { + // If the wrapped EventListener is ours, we shouldn't create spans, as the originalEventListener already did it + return originalEventListener !is SentryOkHttpEventListener + } +} diff --git a/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpInterceptor.kt b/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpInterceptor.kt index bbe6364375f..f434764c695 100644 --- a/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpInterceptor.kt @@ -66,8 +66,18 @@ class SentryOkHttpInterceptor( val url = urlDetails.urlOrFallback val method = request.method - // read transaction from the bound scope - val span = hub.span?.startChild("http.client", "$method $url") + val span: ISpan? + val isFromEventListener: Boolean + + if (SentryOkHttpEventListener.eventMap.containsKey(chain.call())) { + // read the span from the event listener + span = SentryOkHttpEventListener.eventMap[chain.call()]?.callRootSpan + isFromEventListener = true + } else { + // read the span from the bound scope + span = hub.span?.startChild("http.client", "$method $url") + isFromEventListener = false + } urlDetails.applyToSpan(span) var response: Response? = null @@ -105,38 +115,51 @@ class SentryOkHttpInterceptor( } throw e } finally { - finishSpan(span, request, response) + finishSpan(span, request, response, isFromEventListener) - val breadcrumb = Breadcrumb.http(request.url.toString(), request.method, code) - request.body?.contentLength().ifHasValidLength { - breadcrumb.setData("request_body_size", it) + // The SentryOkHttpEventListener will send the breadcrumb itself if used for this call + if (!isFromEventListener) { + sendBreadcrumb(request, code, response) } + } + } - val hint = Hint() - .also { it.set(OKHTTP_REQUEST, request) } - response?.let { - it.body?.contentLength().ifHasValidLength { responseBodySize -> - breadcrumb.setData("response_body_size", responseBodySize) - } + private fun sendBreadcrumb(request: Request, code: Int?, response: Response?) { + val breadcrumb = Breadcrumb.http(request.url.toString(), request.method, code) + request.body?.contentLength().ifHasValidLength { + breadcrumb.setData("http.request_content_length", it) + } - hint[OKHTTP_RESPONSE] = it + val hint = Hint().also { it.set(OKHTTP_REQUEST, request) } + response?.let { + it.body?.contentLength().ifHasValidLength { responseBodySize -> + breadcrumb.setData("http.response_content_length", responseBodySize) } - hub.addBreadcrumb(breadcrumb, hint) + hint[OKHTTP_RESPONSE] = it } + + hub.addBreadcrumb(breadcrumb, hint) } - private fun finishSpan(span: ISpan?, request: Request, response: Response?) { - if (span != null) { - if (beforeSpan != null) { - val result = beforeSpan.execute(span, request, response) - if (result == null) { - // span is dropped - span.spanContext.sampled = false - } else { + private fun finishSpan(span: ISpan?, request: Request, response: Response?, isFromEventListener: Boolean) { + if (span == null) { + return + } + if (beforeSpan != null) { + val result = beforeSpan.execute(span, request, response) + if (result == null) { + // span is dropped + span.spanContext.sampled = false + } else { + // The SentryOkHttpEventListener will finish the span itself if used for this call + if (!isFromEventListener) { span.finish() } - } else { + } + } else { + // The SentryOkHttpEventListener will finish the span itself if used for this call + if (!isFromEventListener) { span.finish() } } diff --git a/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpEventListenerTest.kt b/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpEventListenerTest.kt new file mode 100644 index 00000000000..51afc20c0c7 --- /dev/null +++ b/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpEventListenerTest.kt @@ -0,0 +1,356 @@ +package io.sentry.android.okhttp + +import io.sentry.BaggageHeader +import io.sentry.IHub +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import okhttp3.Call +import okhttp3.EventListener +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SentryOkHttpEventListenerTest { + + class Fixture { + val hub = mock() + val server = MockWebServer() + val mockEventListener = mock() + val mockEventListenerFactory = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + lateinit var sentryOkHttpEventListener: SentryOkHttpEventListener + + init { + whenever(mockEventListenerFactory.create(any())).thenReturn(mockEventListener) + } + + @SuppressWarnings("LongParameterList") + fun getSut( + isSpanActive: Boolean = true, + useInterceptor: Boolean = false, + httpStatusCode: Int = 201, + sendDefaultPii: Boolean = false, + eventListener: EventListener? = null, + eventListenerFactory: EventListener.Factory? = null + ): OkHttpClient { + options = SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + isSendDefaultPii = sendDefaultPii + } + whenever(hub.options).thenReturn(options) + + sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + + if (isSpanActive) { + whenever(hub.span).thenReturn(sentryTracer) + } + server.enqueue( + MockResponse() + .setBody("responseBody") + .addHeader("myResponseHeader", "myValue") + .setSocketPolicy(SocketPolicy.KEEP_OPEN) + .setResponseCode(httpStatusCode) + ) + + val builder = OkHttpClient.Builder() + if (useInterceptor) { + builder.addInterceptor(SentryOkHttpInterceptor(hub)) + } + sentryOkHttpEventListener = when { + eventListenerFactory != null -> SentryOkHttpEventListener(hub, eventListenerFactory) + eventListener != null -> SentryOkHttpEventListener(hub, eventListener) + else -> SentryOkHttpEventListener(hub) + } + return builder.eventListener(sentryOkHttpEventListener).build() + } + } + + private val fixture = Fixture() + + private fun getRequest(url: String = "/hello"): Request { + return Request.Builder() + .addHeader("myHeader", "myValue") + .get() + .url(fixture.server.url(url)) + .build() + } + + private fun postRequest(url: String = "/hello", body: String): Request { + return Request.Builder() + .addHeader("myHeader", "myValue") + .post(body.toRequestBody()) + .url(fixture.server.url(url)) + .build() + } + + @Test + fun `when there is an active span and the SentryOkHttpInterceptor, adds sentry trace headers to the request`() { + val sut = fixture.getSut(useInterceptor = true) + sut.newCall(getRequest()).execute() + val recorderRequest = fixture.server.takeRequest() + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Suppress("MaxLineLength") + @Test + fun `when there is an active span but no SentryOkHttpInterceptor, sentry trace headers are not added to the request`() { + val sut = fixture.getSut() + sut.newCall(getRequest()).execute() + val recorderRequest = fixture.server.takeRequest() + assertNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `creates a span around the request`() { + val sut = fixture.getSut() + val request = getRequest() + val call = sut.newCall(request) + val response = call.execute() + val okHttpEvent = SentryOkHttpEventListener.eventMap[call] + val callSpan = okHttpEvent?.callRootSpan + response.close() + assertNotNull(callSpan) + assertEquals(callSpan, fixture.sentryTracer.children.first()) + assertEquals("http.client", callSpan.operation) + assertEquals("GET ${request.url}", callSpan.description) + assertEquals(SpanStatus.OK, callSpan.status) + assertTrue(callSpan.isFinished) + } + + @Test + fun `creates a span for each event`() { + val sut = fixture.getSut() + val request = getRequest() + val call = sut.newCall(request) + val response = call.execute() + val okHttpEvent = SentryOkHttpEventListener.eventMap[call] + val callSpan = okHttpEvent?.callRootSpan + response.close() + assertEquals(8, fixture.sentryTracer.children.size) + fixture.sentryTracer.children.forEachIndexed { index, span -> + assertTrue(span.isFinished) + when (index) { + 0 -> { + assertEquals(callSpan, span) + assertEquals("GET ${request.url}", span.description) + assertNotNull(span.data["proxies"]) + assertNotNull(span.data["domain_name"]) + assertNotNull(span.data["dns_addresses"]) + assertEquals(201, span.data["status_code"]) + } + 1 -> { + assertEquals("proxySelect", span.description) + assertNotNull(span.data["proxies"]) + } + 2 -> { + assertEquals("dns", span.description) + assertNotNull(span.data["domain_name"]) + assertNotNull(span.data["dns_addresses"]) + } + 3 -> { + assertEquals("connect", span.description) + } + 4 -> { + assertEquals("connection", span.description) + } + 5 -> { + assertEquals("requestHeaders", span.description) + } + 6 -> { + assertEquals("responseHeaders", span.description) + assertEquals(201, span.data["status_code"]) + } + 7 -> { + assertEquals("responseBody", span.description) + } + } + } + } + + @Test + fun `has requestBody span for requests with body`() { + val sut = fixture.getSut() + val requestBody = "request body sent in the request" + val request = postRequest(body = requestBody) + val call = sut.newCall(request) + val response = call.execute() + val okHttpEvent = SentryOkHttpEventListener.eventMap[call] + val callSpan = okHttpEvent?.callRootSpan + response.close() + assertEquals(9, fixture.sentryTracer.children.size) + val requestBodySpan = fixture.sentryTracer.children.firstOrNull { it.description == "requestBody" } + assertNotNull(requestBodySpan) + assertEquals(requestBody.toByteArray().size.toLong(), requestBodySpan.data["http.request_content_length"]) + assertEquals(requestBody.toByteArray().size.toLong(), callSpan?.getData("http.request_content_length")) + } + + @Test + fun `has response_body_size data if body is consumed`() { + val sut = fixture.getSut() + val requestBody = "request body sent in the request" + val request = postRequest(body = requestBody) + val call = sut.newCall(request) + val response = call.execute() + val okHttpEvent = SentryOkHttpEventListener.eventMap[call] + val callSpan = okHttpEvent?.callRootSpan + + // Consume the response + val responseBytes = response.body?.byteStream()?.readBytes() + assertNotNull(responseBytes) + + response.close() + val requestBodySpan = fixture.sentryTracer.children.firstOrNull { it.description == "responseBody" } + assertNotNull(requestBodySpan) + assertEquals(responseBytes.size.toLong(), requestBodySpan.data["http.response_content_length"]) + assertEquals(responseBytes.size.toLong(), callSpan?.getData("http.response_content_length")) + } + + @Test + fun `root call span status depends on http status code`() { + val sut = fixture.getSut(httpStatusCode = 404) + val request = getRequest() + val call = sut.newCall(request) + val response = call.execute() + val okHttpEvent = SentryOkHttpEventListener.eventMap[call] + val callSpan = okHttpEvent?.callRootSpan + response.close() + assertNotNull(callSpan) + assertEquals(SpanStatus.fromHttpStatusCode(404), callSpan.status) + } + + @Test + fun `propagate all calls to the event listener passed in the ctor`() { + val sut = fixture.getSut(eventListener = fixture.mockEventListener, httpStatusCode = 500) + val listener = fixture.sentryOkHttpEventListener + val request = postRequest(body = "requestBody") + val call = sut.newCall(request) + val response = mock() + whenever(response.protocol).thenReturn(Protocol.HTTP_1_1) + verifyDelegation(listener, fixture.mockEventListener, call, response) + } + + @Test + fun `propagate all calls to the event listener factory passed in the ctor`() { + val sut = fixture.getSut(eventListenerFactory = fixture.mockEventListenerFactory, httpStatusCode = 500) + val listener = fixture.sentryOkHttpEventListener + val request = postRequest(body = "requestBody") + val call = sut.newCall(request) + val response = mock() + whenever(response.protocol).thenReturn(Protocol.HTTP_1_1) + verifyDelegation(listener, fixture.mockEventListener, call, response) + } + + @Test + fun `propagate all calls to the SentryOkHttpEventListener passed in the ctor`() { + val originalListener = spy(SentryOkHttpEventListener(fixture.hub, fixture.mockEventListener)) + val sut = fixture.getSut(eventListener = originalListener, httpStatusCode = 500) + val listener = fixture.sentryOkHttpEventListener + val request = postRequest(body = "requestBody") + val call = sut.newCall(request) + val response = mock() + whenever(response.protocol).thenReturn(Protocol.HTTP_1_1) + verifyDelegation(listener, originalListener, call, response) + } + + @Test + fun `propagate all calls to the SentryOkHttpEventListener factory passed in the ctor`() { + val originalListener = spy(SentryOkHttpEventListener(fixture.hub, fixture.mockEventListener)) + val sut = fixture.getSut(eventListenerFactory = { originalListener }, httpStatusCode = 500) + val listener = fixture.sentryOkHttpEventListener + val request = postRequest(body = "requestBody") + val call = sut.newCall(request) + val response = mock() + whenever(response.protocol).thenReturn(Protocol.HTTP_1_1) + verifyDelegation(listener, originalListener, call, response) + } + + @Test + fun `does not duplicated spans if an SentryOkHttpEventListener is passed in the ctor`() { + val originalListener = spy(SentryOkHttpEventListener(fixture.hub, fixture.mockEventListener)) + val sut = fixture.getSut(eventListener = originalListener) + val request = postRequest(body = "requestBody") + val call = sut.newCall(request) + val response = call.execute() + response.close() + // Spans are created by the originalListener, so the listener doesn't create duplicates + assertEquals(9, fixture.sentryTracer.children.size) + } + + private fun verifyDelegation( + listener: SentryOkHttpEventListener, + originalListener: EventListener, + call: Call, + response: Response + ) { + listener.callStart(call) + verify(originalListener).callStart(eq(call)) + listener.proxySelectStart(call, mock()) + verify(originalListener).proxySelectStart(eq(call), any()) + listener.proxySelectEnd(call, mock(), listOf(mock())) + verify(originalListener).proxySelectEnd(eq(call), any(), any()) + listener.dnsStart(call, "domainName") + verify(originalListener).dnsStart(eq(call), eq("domainName")) + listener.dnsEnd(call, "domainName", listOf(mock())) + verify(originalListener).dnsEnd(eq(call), eq("domainName"), any()) + listener.connectStart(call, mock(), mock()) + verify(originalListener).connectStart(eq(call), any(), any()) + listener.secureConnectStart(call) + verify(originalListener).secureConnectStart(eq(call)) + listener.secureConnectEnd(call, mock()) + verify(originalListener).secureConnectEnd(eq(call), any()) + listener.connectEnd(call, mock(), mock(), mock()) + verify(originalListener).connectEnd(eq(call), any(), any(), any()) + listener.connectFailed(call, mock(), mock(), mock(), mock()) + verify(originalListener).connectFailed(eq(call), any(), any(), any(), any()) + listener.connectionAcquired(call, mock()) + verify(originalListener).connectionAcquired(eq(call), any()) + listener.connectionReleased(call, mock()) + verify(originalListener).connectionReleased(eq(call), any()) + listener.requestHeadersStart(call) + verify(originalListener).requestHeadersStart(eq(call)) + listener.requestHeadersEnd(call, mock()) + verify(originalListener).requestHeadersEnd(eq(call), any()) + listener.requestBodyStart(call) + verify(originalListener).requestBodyStart(eq(call)) + listener.requestBodyEnd(call, 10) + verify(originalListener).requestBodyEnd(eq(call), eq(10)) + listener.requestFailed(call, mock()) + verify(originalListener).requestFailed(eq(call), any()) + listener.responseHeadersStart(call) + verify(originalListener).responseHeadersStart(eq(call)) + listener.responseHeadersEnd(call, response) + verify(originalListener).responseHeadersEnd(eq(call), any()) + listener.responseBodyStart(call) + verify(originalListener).responseBodyStart(eq(call)) + listener.responseBodyEnd(call, 10) + verify(originalListener).responseBodyEnd(eq(call), eq(10)) + listener.responseFailed(call, mock()) + verify(originalListener).responseFailed(eq(call), any()) + listener.callEnd(call) + verify(originalListener).callEnd(eq(call)) + listener.callFailed(call, mock()) + verify(originalListener).callFailed(eq(call), any()) + } +} diff --git a/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpEventTest.kt b/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpEventTest.kt new file mode 100644 index 00000000000..910d9e229da --- /dev/null +++ b/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpEventTest.kt @@ -0,0 +1,452 @@ +package io.sentry.android.okhttp + +import io.sentry.Breadcrumb +import io.sentry.IHub +import io.sentry.ISpan +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.Span +import io.sentry.SpanOptions +import io.sentry.TracesSamplingDecision +import io.sentry.TransactionContext +import io.sentry.TypeCheckHint +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.CONNECTION_EVENT +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.CONNECT_EVENT +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.REQUEST_BODY_EVENT +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.REQUEST_HEADERS_EVENT +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.RESPONSE_BODY_EVENT +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.RESPONSE_HEADERS_EVENT +import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.SECURE_CONNECT_EVENT +import io.sentry.test.getProperty +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.mockwebserver.MockWebServer +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SentryOkHttpEventTest { + private class Fixture { + val hub = mock() + val server = MockWebServer() + val span: ISpan + val mockRequest: Request + val response: Response + + init { + whenever(hub.options).thenReturn( + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + } + ) + + span = Span( + TransactionContext("name", "op", TracesSamplingDecision(true)), + SentryTracer(TransactionContext("name", "op", TracesSamplingDecision(true)), hub), + hub, + null, + SpanOptions() + ) + + mockRequest = Request.Builder() + .addHeader("myHeader", "myValue") + .get() + .url(server.url("/hello")) + .build() + + response = Response.Builder() + .code(200) + .message("message") + .request(mockRequest) + .protocol(Protocol.HTTP_1_1) + .build() + } + + fun getSut(currentSpan: ISpan? = span, requestUrl: String ? = null): SentryOkHttpEvent { + whenever(hub.span).thenReturn(currentSpan) + val request = if (requestUrl == null) { + mockRequest + } else { + Request.Builder() + .addHeader("myHeader", "myValue") + .get() + .url(server.url(requestUrl)) + .build() + } + return SentryOkHttpEvent(hub, request) + } + } + + private val fixture = Fixture() + + @Test + fun `when there is no active span, root span is null`() { + val sut = fixture.getSut(currentSpan = null) + assertNull(sut.callRootSpan) + } + + @Test + fun `when there is an active span, a root span is created`() { + val sut = fixture.getSut() + val callSpan = sut.callRootSpan + assertNotNull(callSpan) + assertEquals("http.client", callSpan.operation) + assertEquals("${fixture.mockRequest.method} ${fixture.mockRequest.url}", callSpan.description) + assertEquals(fixture.mockRequest.url.toString(), callSpan.getData("url")) + assertEquals(fixture.mockRequest.url.host, callSpan.getData("host")) + assertEquals(fixture.mockRequest.url.encodedPath, callSpan.getData("path")) + assertEquals(fixture.mockRequest.method, callSpan.getData("http.method")) + assertTrue(callSpan.getData("success") as Boolean) + } + + @Test + fun `when root span is null, no breadcrumb is created`() { + val sut = fixture.getSut(currentSpan = null) + assertNull(sut.callRootSpan) + sut.finishEvent() + verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) + } + + @Test + fun `when root span is null, no span is created`() { + val sut = fixture.getSut(currentSpan = null) + assertNull(sut.callRootSpan) + sut.startSpan("span") + assertTrue(sut.getEventSpans().isEmpty()) + } + + @Test + fun `when event is finished, root span is finished`() { + val sut = fixture.getSut() + val rootSpan = sut.callRootSpan + assertNotNull(rootSpan) + assertFalse(rootSpan.isFinished) + sut.finishEvent() + assertTrue(rootSpan.isFinished) + } + + @Test + fun `when startSpan, a new span is started`() { + val sut = fixture.getSut() + assertTrue(sut.getEventSpans().isEmpty()) + sut.startSpan("span") + val spans = sut.getEventSpans() + assertEquals(1, spans.size) + val span = spans["span"] + assertNotNull(span) + assertTrue(spans.containsKey("span")) + assertEquals("http.client.details", span.operation) + assertFalse(span.isFinished) + } + + @Test + fun `when finishSpan, a span is finished if previously started`() { + val sut = fixture.getSut() + assertTrue(sut.getEventSpans().isEmpty()) + sut.startSpan("span") + val spans = sut.getEventSpans() + assertFalse(spans["span"]!!.isFinished) + sut.finishSpan("span") + assertTrue(spans["span"]!!.isFinished) + } + + @Test + fun `when finishSpan, a callback is called before the span is finished`() { + val sut = fixture.getSut() + var called = false + assertTrue(sut.getEventSpans().isEmpty()) + sut.startSpan("span") + val spans = sut.getEventSpans() + assertFalse(spans["span"]!!.isFinished) + sut.finishSpan("span") { + called = true + assertFalse(it.isFinished) + } + assertTrue(spans["span"]!!.isFinished) + assertTrue(called) + } + + @Test + fun `when finishSpan, a callback is called with the current span and the root call span is finished`() { + val sut = fixture.getSut() + var called = 0 + sut.startSpan("span") + sut.finishSpan("span") { + if (called == 0) { + assertEquals("span", it.description) + } else { + assertEquals(sut.callRootSpan, it) + } + called++ + assertFalse(it.isFinished) + } + assertEquals(2, called) + } + + @Test + fun `finishSpan is ignored if the span was not previously started`() { + val sut = fixture.getSut() + var called = false + assertTrue(sut.getEventSpans().isEmpty()) + sut.finishSpan("span") { called = true } + assertTrue(sut.getEventSpans().isEmpty()) + assertFalse(called) + } + + @Test + fun `when finishEvent, a callback is called with the call root span before it is finished`() { + val sut = fixture.getSut() + var called = false + sut.finishEvent { + called = true + assertEquals(sut.callRootSpan, it) + } + assertTrue(called) + } + + @Test + fun `when finishEvent, all running spans are finished`() { + val sut = fixture.getSut() + sut.startSpan("span") + val spans = sut.getEventSpans() + assertFalse(spans["span"]!!.isFinished) + sut.finishEvent() + assertTrue(spans["span"]!!.isFinished) + } + + @Test + fun `when finishEvent, a breadcrumb is captured with request in the hint`() { + val sut = fixture.getSut() + sut.finishEvent() + verify(fixture.hub).addBreadcrumb( + check { + assertEquals(fixture.mockRequest.url.toString(), it.data["url"]) + assertEquals(fixture.mockRequest.url.host, it.data["host"]) + assertEquals(fixture.mockRequest.url.encodedPath, it.data["path"]) + assertEquals(fixture.mockRequest.method, it.data["method"]) + assertTrue(it.data["success"] as Boolean) + }, + check { + assertEquals(fixture.mockRequest, it[TypeCheckHint.OKHTTP_REQUEST]) + } + ) + } + + @Test + fun `setResponse set protocol and code in the breadcrumb and root span, and response in the hint`() { + val sut = fixture.getSut() + sut.setResponse(fixture.response) + + assertEquals(fixture.response.protocol.name, sut.callRootSpan?.getData("protocol")) + assertEquals(fixture.response.code, sut.callRootSpan?.getData("status_code")) + sut.finishEvent() + + verify(fixture.hub).addBreadcrumb( + check { + assertEquals(fixture.response.protocol.name, it.data["protocol"]) + assertEquals(fixture.response.code, it.data["status_code"]) + }, + check { + assertEquals(fixture.response, it[TypeCheckHint.OKHTTP_RESPONSE]) + } + ) + } + + @Test + fun `setProtocol set protocol in the breadcrumb and in the root span`() { + val sut = fixture.getSut() + sut.setProtocol("protocol") + assertEquals("protocol", sut.callRootSpan?.getData("protocol")) + sut.finishEvent() + verify(fixture.hub).addBreadcrumb( + check { + assertEquals("protocol", it.data["protocol"]) + }, + any() + ) + } + + @Test + fun `setProtocol is ignored if protocol is null`() { + val sut = fixture.getSut() + sut.setProtocol(null) + assertNull(sut.callRootSpan?.getData("protocol")) + sut.finishEvent() + verify(fixture.hub).addBreadcrumb( + check { + assertNull(it.data["protocol"]) + }, + any() + ) + } + + @Test + fun `setRequestBodySize set RequestBodySize in the breadcrumb and in the root span`() { + val sut = fixture.getSut() + sut.setRequestBodySize(10) + assertEquals(10L, sut.callRootSpan?.getData("http.request_content_length")) + sut.finishEvent() + verify(fixture.hub).addBreadcrumb( + check { + assertEquals(10L, it.data["request_content_length"]) + }, + any() + ) + } + + @Test + fun `setRequestBodySize is ignored if RequestBodySize is negative`() { + val sut = fixture.getSut() + sut.setRequestBodySize(-1) + assertNull(sut.callRootSpan?.getData("http.request_content_length")) + sut.finishEvent() + verify(fixture.hub).addBreadcrumb( + check { + assertNull(it.data["request_content_length"]) + }, + any() + ) + } + + @Test + fun `setResponseBodySize set ResponseBodySize in the breadcrumb and in the root span`() { + val sut = fixture.getSut() + sut.setResponseBodySize(10) + assertEquals(10L, sut.callRootSpan?.getData("http.response_content_length")) + sut.finishEvent() + verify(fixture.hub).addBreadcrumb( + check { + assertEquals(10L, it.data["response_content_length"]) + }, + any() + ) + } + + @Test + fun `setResponseBodySize is ignored if ResponseBodySize is negative`() { + val sut = fixture.getSut() + sut.setResponseBodySize(-1) + assertNull(sut.callRootSpan?.getData("http.response_content_length")) + sut.finishEvent() + verify(fixture.hub).addBreadcrumb( + check { + assertNull(it.data["response_content_length"]) + }, + any() + ) + } + + @Test + fun `setError set success to false and errorMessage in the breadcrumb and in the root span`() { + val sut = fixture.getSut() + sut.setError("errorMessage") + assertFalse(sut.callRootSpan?.getData("success") as Boolean) + assertEquals("errorMessage", sut.callRootSpan.getData("error_message")) + sut.finishEvent() + verify(fixture.hub).addBreadcrumb( + check { + assertFalse(it.data["success"] as Boolean) + assertEquals("errorMessage", it.data["error_message"]) + }, + any() + ) + } + + @Test + fun `setError sets success to false in the breadcrumb and in the root span even if errorMessage is null`() { + val sut = fixture.getSut() + sut.setError(null) + assertFalse(sut.callRootSpan?.getData("success") as Boolean) + assertNull(sut.callRootSpan.getData("error_message")) + sut.finishEvent() + verify(fixture.hub).addBreadcrumb( + check { + assertFalse(it.data["success"] as Boolean) + assertNull(it.data["error_message"]) + }, + any() + ) + } + + @Test + fun `secureConnect span is child of connect span`() { + val sut = fixture.getSut() + sut.startSpan(CONNECT_EVENT) + sut.startSpan(SECURE_CONNECT_EVENT) + val spans = sut.getEventSpans() + val secureConnectSpan = spans[SECURE_CONNECT_EVENT] as Span? + val connectSpan = spans[CONNECT_EVENT] as Span? + assertNotNull(secureConnectSpan) + assertNotNull(connectSpan) + assertEquals(connectSpan.spanId, secureConnectSpan.parentSpanId) + } + + @Test + fun `secureConnect span is child of root span if connect span is not available`() { + val sut = fixture.getSut() + sut.startSpan(SECURE_CONNECT_EVENT) + val spans = sut.getEventSpans() + val rootSpan = sut.callRootSpan as Span? + val secureConnectSpan = spans[SECURE_CONNECT_EVENT] as Span? + assertNotNull(secureConnectSpan) + assertNotNull(rootSpan) + assertEquals(rootSpan.spanId, secureConnectSpan.parentSpanId) + } + + @Test + fun `request and response spans are children of connection span`() { + val sut = fixture.getSut() + sut.startSpan(CONNECTION_EVENT) + sut.startSpan(REQUEST_HEADERS_EVENT) + sut.startSpan(REQUEST_BODY_EVENT) + sut.startSpan(RESPONSE_HEADERS_EVENT) + sut.startSpan(RESPONSE_BODY_EVENT) + val spans = sut.getEventSpans() + val connectionSpan = spans[CONNECTION_EVENT] as Span? + val requestHeadersSpan = spans[REQUEST_HEADERS_EVENT] as Span? + val requestBodySpan = spans[REQUEST_BODY_EVENT] as Span? + val responseHeadersSpan = spans[RESPONSE_HEADERS_EVENT] as Span? + val responseBodySpan = spans[RESPONSE_BODY_EVENT] as Span? + assertNotNull(connectionSpan) + assertEquals(connectionSpan.spanId, requestHeadersSpan?.parentSpanId) + assertEquals(connectionSpan.spanId, requestBodySpan?.parentSpanId) + assertEquals(connectionSpan.spanId, responseHeadersSpan?.parentSpanId) + assertEquals(connectionSpan.spanId, responseBodySpan?.parentSpanId) + } + + @Test + fun `request and response spans are children of root span if connection span is not available`() { + val sut = fixture.getSut() + sut.startSpan(REQUEST_HEADERS_EVENT) + sut.startSpan(REQUEST_BODY_EVENT) + sut.startSpan(RESPONSE_HEADERS_EVENT) + sut.startSpan(RESPONSE_BODY_EVENT) + val spans = sut.getEventSpans() + val connectionSpan = spans[CONNECTION_EVENT] as Span? + val requestHeadersSpan = spans[REQUEST_HEADERS_EVENT] as Span? + val requestBodySpan = spans[REQUEST_BODY_EVENT] as Span? + val responseHeadersSpan = spans[RESPONSE_HEADERS_EVENT] as Span? + val responseBodySpan = spans[RESPONSE_BODY_EVENT] as Span? + val rootSpan = sut.callRootSpan as Span? + assertNotNull(rootSpan) + assertNull(connectionSpan) + assertEquals(rootSpan.spanId, requestHeadersSpan?.parentSpanId) + assertEquals(rootSpan.spanId, requestBodySpan?.parentSpanId) + assertEquals(rootSpan.spanId, responseHeadersSpan?.parentSpanId) + assertEquals(rootSpan.spanId, responseBodySpan?.parentSpanId) + } + + /** Retrieve all the spans started in the event using reflection. */ + private fun SentryOkHttpEvent.getEventSpans() = getProperty>("eventSpans") +} diff --git a/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpInterceptorTest.kt index eed10ea8c6b..6550af9c5cb 100644 --- a/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpInterceptorTest.kt @@ -253,8 +253,8 @@ class SentryOkHttpInterceptorTest { verify(fixture.hub).addBreadcrumb( check { assertEquals("http", it.type) - assertEquals(13L, it.data["response_body_size"]) - assertEquals(12L, it.data["request_body_size"]) + assertEquals(13L, it.data["http.response_content_length"]) + assertEquals(12L, it.data["http.request_content_length"]) }, anyOrNull() ) @@ -510,4 +510,15 @@ class SentryOkHttpInterceptorTest { } verify(fixture.hub, never()).captureEvent(any(), any()) } + + @Test + fun `when a call is captured by SentryOkHttpEventListener no span nor breadcrumb is created`() { + val sut = fixture.getSut(responseBody = "response body") + val call = sut.newCall(postRequest()) + SentryOkHttpEventListener.eventMap[call] = mock() + call.execute() + val httpClientSpan = fixture.sentryTracer.children.firstOrNull() + assertNull(httpClientSpan) + verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) + } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GithubAPI.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GithubAPI.kt index aef2b64a0e6..09ad9a2d07d 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GithubAPI.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GithubAPI.kt @@ -1,6 +1,7 @@ package io.sentry.samples.android import io.sentry.HttpStatusCodeRange +import io.sentry.android.okhttp.SentryOkHttpEventListener import io.sentry.android.okhttp.SentryOkHttpInterceptor import okhttp3.OkHttpClient import retrofit2.Retrofit @@ -8,14 +9,15 @@ import retrofit2.converter.gson.GsonConverterFactory object GithubAPI { - private val client = OkHttpClient.Builder().addInterceptor( - SentryOkHttpInterceptor( - captureFailedRequests = true, - failedRequestStatusCodes = listOf( - HttpStatusCodeRange(400, 599) + private val client = OkHttpClient.Builder().eventListener(SentryOkHttpEventListener()) + .addInterceptor( + SentryOkHttpInterceptor( + captureFailedRequests = true, + failedRequestStatusCodes = listOf( + HttpStatusCodeRange(400, 599) + ) ) - ) - ).build() + ).build() private val retrofit = Retrofit.Builder() .baseUrl("https://api.github.com/")