diff --git a/CHANGELOG.md b/CHANGELOG.md index 64bb70ad35..f5933e3154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Add `CheckInUtils.withCheckIn` which abstracts away some of the manual check-ins complexity ([#2959](https://github.com/getsentry/sentry-java/pull/2959)) +### Fixes + +- Always attach OkHttp errors and Http Client Errors only to call root span ([#2961](https://github.com/getsentry/sentry-java/pull/2961)) + ## 6.30.0 ### Features diff --git a/sentry-android-okhttp/api/sentry-android-okhttp.api b/sentry-android-okhttp/api/sentry-android-okhttp.api index 6c1ab571f7..3c2a7bcf02 100644 --- a/sentry-android-okhttp/api/sentry-android-okhttp.api +++ b/sentry-android-okhttp/api/sentry-android-okhttp.api @@ -64,3 +64,8 @@ public abstract interface class io/sentry/android/okhttp/SentryOkHttpInterceptor public abstract fun execute (Lio/sentry/ISpan;Lokhttp3/Request;Lokhttp3/Response;)Lio/sentry/ISpan; } +public final class io/sentry/android/okhttp/SentryOkHttpUtils { + public static final field INSTANCE Lio/sentry/android/okhttp/SentryOkHttpUtils; + public final fun captureClientError (Lio/sentry/IHub;Lokhttp3/Request;Lokhttp3/Response;)V +} + 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 index 5b2d2ace25..74b8086077 100644 --- 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 @@ -29,11 +29,13 @@ private const val ERROR_MESSAGE_KEY = "error_message" private const val RESPONSE_BODY_TIMEOUT_MILLIS = 500L internal const val TRACE_ORIGIN = "auto.http.okhttp" +@Suppress("TooManyFunctions") internal class SentryOkHttpEvent(private val hub: IHub, private val request: Request) { private val eventSpans: MutableMap = ConcurrentHashMap() private val breadcrumb: Breadcrumb internal val callRootSpan: ISpan? private var response: Response? = null + private var clientErrorResponse: Response? = null private val isReadingResponseBody = AtomicBoolean(false) init { @@ -93,6 +95,10 @@ internal class SentryOkHttpEvent(private val hub: IHub, private val request: Req } } + fun setClientErrorResponse(response: Response) { + this.clientErrorResponse = response + } + /** Sets the [errorMessage] if not null. */ fun setError(errorMessage: String?) { if (errorMessage != null) { @@ -119,8 +125,10 @@ internal class SentryOkHttpEvent(private val hub: IHub, private val request: Req val span = eventSpans[event] ?: return null val parentSpan = findParentSpan(event) beforeFinish?.invoke(span) + moveThrowableToRootSpan(span) if (parentSpan != null && parentSpan != callRootSpan) { beforeFinish?.invoke(parentSpan) + moveThrowableToRootSpan(parentSpan) } callRootSpan?.let { beforeFinish?.invoke(it) } span.finish() @@ -134,9 +142,16 @@ internal class SentryOkHttpEvent(private val hub: IHub, private val request: Req // We forcefully finish all spans, even if they should already have been finished through finishSpan() eventSpans.values.filter { !it.isFinished }.forEach { // If a status was set on the span, we use that, otherwise we set its status as error. - it.finish(it.status ?: SpanStatus.INTERNAL_ERROR) + it.status = it.status ?: SpanStatus.INTERNAL_ERROR + moveThrowableToRootSpan(it) + it.finish() } beforeFinish?.invoke(callRootSpan) + // We report the client error here, after all sub-spans finished, so that it will be bound + // to the root call span. + clientErrorResponse?.let { + SentryOkHttpUtils.captureClientError(hub, it.request, it) + } if (finishDate != null) { callRootSpan.finish(callRootSpan.status, finishDate) } else { @@ -152,6 +167,15 @@ internal class SentryOkHttpEvent(private val hub: IHub, private val request: Req return } + /** Move any throwable from an inner span to the call root span. */ + private fun moveThrowableToRootSpan(span: ISpan) { + if (span != callRootSpan && span.throwable != null && span.status != null) { + callRootSpan?.throwable = span.throwable + callRootSpan?.status = span.status + span.throwable = null + } + } + private fun findParentSpan(event: String): ISpan? = when (event) { // PROXY_SELECT, DNS, CONNECT and CONNECTION are not children of one another SECURE_CONNECT_EVENT -> eventSpans[CONNECT_EVENT] 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 a5815b163b..849add2398 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 @@ -8,21 +8,15 @@ import io.sentry.HubAdapter import io.sentry.IHub import io.sentry.ISpan import io.sentry.IntegrationName -import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TypeCheckHint.OKHTTP_REQUEST import io.sentry.TypeCheckHint.OKHTTP_RESPONSE -import io.sentry.exception.ExceptionMechanismException -import io.sentry.exception.SentryHttpClientException -import io.sentry.protocol.Mechanism -import io.sentry.util.HttpUtils import io.sentry.util.PropagationTargetsUtils import io.sentry.util.TracingUtils import io.sentry.util.UrlUtils -import okhttp3.Headers import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response @@ -70,25 +64,26 @@ class SentryOkHttpInterceptor( val method = request.method val span: ISpan? - val isFromEventListener: Boolean + val okHttpEvent: SentryOkHttpEvent? if (SentryOkHttpEventListener.eventMap.containsKey(chain.call())) { // read the span from the event listener - span = SentryOkHttpEventListener.eventMap[chain.call()]?.callRootSpan - isFromEventListener = true + okHttpEvent = SentryOkHttpEventListener.eventMap[chain.call()] + span = okHttpEvent?.callRootSpan } else { // read the span from the bound scope + okHttpEvent = null span = hub.span?.startChild("http.client", "$method $url") - isFromEventListener = false } span?.spanContext?.origin = TRACE_ORIGIN urlDetails.applyToSpan(span) + val isFromEventListener = okHttpEvent != null var response: Response? = null - var code: Int? = null + try { val requestBuilder = request.newBuilder() @@ -114,7 +109,17 @@ class SentryOkHttpInterceptor( // OkHttp errors (4xx, 5xx) don't throw, so it's safe to call within this block. // breadcrumbs are added on the finally block because we'd like to know if the device // had an unstable connection or something similar - captureEvent(request, response) + if (shouldCaptureClientError(request, response)) { + // If we capture the client error directly, it could be associated with the + // currently running span by the backend. In case the listener is in use, that is + // an inner span. So, if the listener is in use, we let it capture the client + // error, to shown it in the http root call span in the dashboard. + if (isFromEventListener && okHttpEvent != null) { + okHttpEvent.setClientErrorResponse(response) + } else { + SentryOkHttpUtils.captureClientError(hub, request, response) + } + } return response } catch (e: IOException) { @@ -180,65 +185,18 @@ class SentryOkHttpInterceptor( } } - private fun captureEvent(request: Request, response: Response) { + private fun shouldCaptureClientError(request: Request, response: Response): Boolean { // return if the feature is disabled or its not within the range if (!captureFailedRequests || !containsStatusCode(response.code)) { - return + return false } - // not possible to get a parameterized url, but we remove at least the - // query string and the fragment. - // url example: https://api.github.com/users/getsentry/repos/#fragment?query=query - // url will be: https://api.github.com/users/getsentry/repos/ - // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ - // but that's not possible - val urlDetails = UrlUtils.parse(request.url.toString()) - // return if its not a target match if (!PropagationTargetsUtils.contain(failedRequestTargets, request.url.toString())) { - return - } - - val mechanism = Mechanism().apply { - type = "SentryOkHttpInterceptor" - } - val exception = SentryHttpClientException( - "HTTP Client Error with status code: ${response.code}" - ) - val mechanismException = ExceptionMechanismException(mechanism, exception, Thread.currentThread(), true) - val event = SentryEvent(mechanismException) - - val hint = Hint() - hint.set(OKHTTP_REQUEST, request) - hint.set(OKHTTP_RESPONSE, response) - - val sentryRequest = io.sentry.protocol.Request().apply { - urlDetails.applyToRequest(this) - // Cookie is only sent if isSendDefaultPii is enabled - cookies = if (hub.options.isSendDefaultPii) request.headers["Cookie"] else null - method = request.method - headers = getHeaders(request.headers) - - request.body?.contentLength().ifHasValidLength { - bodySize = it - } + return false } - val sentryResponse = io.sentry.protocol.Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = if (hub.options.isSendDefaultPii) response.headers["Set-Cookie"] else null - headers = getHeaders(response.headers) - statusCode = response.code - - response.body?.contentLength().ifHasValidLength { - bodySize = it - } - } - - event.request = sentryRequest - event.contexts.setResponse(sentryResponse) - - hub.captureEvent(event, hint) + return true } private fun containsStatusCode(statusCode: Int): Boolean { @@ -250,28 +208,6 @@ class SentryOkHttpInterceptor( return false } - private fun getHeaders(requestHeaders: Headers): MutableMap? { - // Headers are only sent if isSendDefaultPii is enabled due to PII - if (!hub.options.isSendDefaultPii) { - return null - } - - val headers = mutableMapOf() - - for (i in 0 until requestHeaders.size) { - val name = requestHeaders.name(i) - - // header is only sent if isn't sensitive - if (HttpUtils.containsSensitiveHeader(name)) { - continue - } - - val value = requestHeaders.value(i) - headers[name] = value - } - return headers - } - /** * The BeforeSpan callback */ diff --git a/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpUtils.kt b/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpUtils.kt new file mode 100644 index 0000000000..8d9a24edbc --- /dev/null +++ b/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpUtils.kt @@ -0,0 +1,96 @@ +package io.sentry.android.okhttp + +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.SentryEvent +import io.sentry.TypeCheckHint +import io.sentry.exception.ExceptionMechanismException +import io.sentry.exception.SentryHttpClientException +import io.sentry.protocol.Mechanism +import io.sentry.util.HttpUtils +import io.sentry.util.UrlUtils +import okhttp3.Headers +import okhttp3.Request +import okhttp3.Response + +object SentryOkHttpUtils { + + fun captureClientError(hub: IHub, request: Request, response: Response) { + // not possible to get a parameterized url, but we remove at least the + // query string and the fragment. + // url example: https://api.github.com/users/getsentry/repos/#fragment?query=query + // url will be: https://api.github.com/users/getsentry/repos/ + // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ + // but that's not possible + val urlDetails = UrlUtils.parse(request.url.toString()) + + val mechanism = Mechanism().apply { + type = "SentryOkHttpInterceptor" + } + val exception = SentryHttpClientException( + "HTTP Client Error with status code: ${response.code}" + ) + val mechanismException = ExceptionMechanismException(mechanism, exception, Thread.currentThread(), true) + val event = SentryEvent(mechanismException) + + val hint = Hint() + hint.set(TypeCheckHint.OKHTTP_REQUEST, request) + hint.set(TypeCheckHint.OKHTTP_RESPONSE, response) + + val sentryRequest = io.sentry.protocol.Request().apply { + urlDetails.applyToRequest(this) + // Cookie is only sent if isSendDefaultPii is enabled + cookies = if (hub.options.isSendDefaultPii) request.headers["Cookie"] else null + method = request.method + headers = getHeaders(hub, request.headers) + + request.body?.contentLength().ifHasValidLength { + bodySize = it + } + } + + val sentryResponse = io.sentry.protocol.Response().apply { + // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII + cookies = if (hub.options.isSendDefaultPii) response.headers["Set-Cookie"] else null + headers = getHeaders(hub, response.headers) + statusCode = response.code + + response.body?.contentLength().ifHasValidLength { + bodySize = it + } + } + + event.request = sentryRequest + event.contexts.setResponse(sentryResponse) + + hub.captureEvent(event, hint) + } + + private fun Long?.ifHasValidLength(fn: (Long) -> Unit) { + if (this != null && this != -1L) { + fn.invoke(this) + } + } + + private fun getHeaders(hub: IHub, requestHeaders: Headers): MutableMap? { + // Headers are only sent if isSendDefaultPii is enabled due to PII + if (!hub.options.isSendDefaultPii) { + return null + } + + val headers = mutableMapOf() + + for (i in 0 until requestHeaders.size) { + val name = requestHeaders.name(i) + + // header is only sent if isn't sensitive + if (HttpUtils.containsSensitiveHeader(name)) { + continue + } + + val value = requestHeaders.value(i) + headers[name] = value + } + return headers + } +} 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 index 2998588a8d..9fab3b475c 100644 --- 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 @@ -1,6 +1,7 @@ package io.sentry.android.okhttp import io.sentry.Breadcrumb +import io.sentry.Hint import io.sentry.IHub import io.sentry.ISentryExecutorService import io.sentry.ISpan @@ -21,6 +22,7 @@ import io.sentry.android.okhttp.SentryOkHttpEventListener.Companion.REQUEST_HEAD 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.exception.SentryHttpClientException import io.sentry.test.getProperty import okhttp3.Protocol import okhttp3.Request @@ -28,6 +30,7 @@ import okhttp3.Response import okhttp3.mockwebserver.MockWebServer import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.check import org.mockito.kotlin.eq @@ -38,6 +41,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.util.concurrent.Future import java.util.concurrent.RejectedExecutionException +import kotlin.RuntimeException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -482,6 +486,7 @@ class SentryOkHttpEventTest { sut.finishSpan(REQUEST_HEADERS_EVENT) { it.status = SpanStatus.INTERNAL_ERROR } sut.finishSpan("random event") { it.status = SpanStatus.DEADLINE_EXCEEDED } sut.finishSpan(CONNECTION_EVENT) + sut.finishEvent() val spans = sut.getEventSpans() val connectionSpan = spans[CONNECTION_EVENT] as Span? val requestHeadersSpan = spans[REQUEST_HEADERS_EVENT] as Span? @@ -499,6 +504,34 @@ class SentryOkHttpEventTest { assertEquals(SpanStatus.DEADLINE_EXCEEDED, sut.callRootSpan!!.status) } + @Test + fun `finishEvent moves throwables from inner span to call root span`() { + val sut = fixture.getSut() + val throwable = RuntimeException() + sut.startSpan(CONNECTION_EVENT) + sut.startSpan("random event") + sut.finishSpan("random event") { it.status = SpanStatus.DEADLINE_EXCEEDED } + sut.finishSpan(CONNECTION_EVENT) { + it.status = SpanStatus.INTERNAL_ERROR + it.throwable = throwable + } + sut.finishEvent() + val spans = sut.getEventSpans() + val connectionSpan = spans[CONNECTION_EVENT] as Span? + val randomEventSpan = spans["random event"] as Span? + assertNotNull(connectionSpan) + assertNotNull(randomEventSpan) + // randomEventSpan was finished with DEADLINE_EXCEEDED + assertEquals(SpanStatus.DEADLINE_EXCEEDED, randomEventSpan.status) + // connectionSpan was finished with INTERNAL_ERROR + assertEquals(SpanStatus.INTERNAL_ERROR, connectionSpan.status) + + // connectionSpan was finished last with INTERNAL_ERROR and a throwable, and it's moved to the root call + assertEquals(SpanStatus.INTERNAL_ERROR, sut.callRootSpan!!.status) + assertEquals(throwable, sut.callRootSpan.throwable) + assertNull(connectionSpan.throwable) + } + @Test fun `scheduleFinish schedules finishEvent`() { val mockExecutor = mock() @@ -531,6 +564,33 @@ class SentryOkHttpEventTest { sut.scheduleFinish(mock()) } + @Test + fun `setClientErrorResponse will capture the client error on finishEvent`() { + val sut = fixture.getSut() + val clientErrorResponse = mock() + whenever(clientErrorResponse.request).thenReturn(fixture.mockRequest) + sut.setClientErrorResponse(clientErrorResponse) + verify(fixture.hub, never()).captureEvent(any(), any()) + sut.finishEvent() + verify(fixture.hub).captureEvent( + argThat { + throwable is SentryHttpClientException && + throwable!!.message!!.startsWith("HTTP Client Error with status code: ") + }, + argThat { + get(TypeCheckHint.OKHTTP_REQUEST) != null && + get(TypeCheckHint.OKHTTP_RESPONSE) != null + } + ) + } + + @Test + fun `when setClientErrorResponse is not called, no client error is captured`() { + val sut = fixture.getSut() + sut.finishEvent() + verify(fixture.hub, never()).captureEvent(any(), any()) + } + /** 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 c989fef077..643399907b 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 @@ -545,4 +545,21 @@ class SentryOkHttpInterceptorTest { assertNull(httpClientSpan) verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) } + + @Test + fun `when a call is not captured by SentryOkHttpEventListener, client error is reported`() { + val sut = fixture.getSut(captureFailedRequests = true, httpStatusCode = 500) + val call = sut.newCall(getRequest()) + call.execute() + verify(fixture.hub).captureEvent(any(), any()) + } + + @Test + fun `when a call is captured by SentryOkHttpEventListener no client error is reported`() { + val sut = fixture.getSut(captureFailedRequests = true, httpStatusCode = 500) + val call = sut.newCall(getRequest()) + SentryOkHttpEventListener.eventMap[call] = mock() + call.execute() + verify(fixture.hub, never()).captureEvent(any(), any()) + } } diff --git a/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpUtilsTest.kt b/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpUtilsTest.kt new file mode 100644 index 0000000000..6f9ea500a7 --- /dev/null +++ b/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpUtilsTest.kt @@ -0,0 +1,147 @@ +package io.sentry.android.okhttp + +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import io.sentry.TypeCheckHint +import io.sentry.exception.SentryHttpClientException +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SentryOkHttpUtilsTest { + + class Fixture { + val hub = mock() + val server = MockWebServer() + + fun getSut( + httpStatusCode: Int = 500, + responseBody: String = "success", + socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + sendDefaultPii: Boolean = false + ): OkHttpClient { + val options = SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + setTracePropagationTargets(listOf(server.hostName)) + isSendDefaultPii = sendDefaultPii + } + whenever(hub.options).thenReturn(options) + + val sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + + whenever(hub.span).thenReturn(sentryTracer) + + server.enqueue( + MockResponse() + .setBody(responseBody) + .addHeader("myResponseHeader", "myValue") + .addHeader("Set-Cookie", "setCookie") + .setSocketPolicy(socketPolicy) + .setResponseCode(httpStatusCode) + ) + return OkHttpClient.Builder().build() + } + } + + private val fixture = Fixture() + + private fun getRequest(url: String = "/hello"): Request { + return Request.Builder() + .addHeader("myHeader", "myValue") + .addHeader("Cookie", "cookie") + .get() + .url(fixture.server.url(url)) + .build() + } + + @Test + fun `captureClientError captures a client error`() { + val sut = fixture.getSut() + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.hub, request, response) + verify(fixture.hub).captureEvent( + check { + val req = it.request + val resp = it.contexts.response + assertIs(it.throwable) + assertTrue(it.throwable!!.message!!.startsWith("HTTP Client Error with status code: ")) + + assertEquals(req!!.method, request.method) + + assertEquals(resp!!.statusCode, response.code) + }, + argThat { + get(TypeCheckHint.OKHTTP_REQUEST) != null && + get(TypeCheckHint.OKHTTP_RESPONSE) != null + } + ) + } + + @Test + fun `captureClientError with sendDefaultPii sends headers`() { + val sut = fixture.getSut(sendDefaultPii = true) + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.hub, request, response) + verify(fixture.hub).captureEvent( + check { + val req = it.request + val resp = it.contexts.response + + assertIs(req) + assertTrue(req.headers!!.isNotEmpty()) + assertNotNull(req.cookies) + + assertIs(resp) + assertTrue(resp.headers!!.isNotEmpty()) + assertNotNull(resp.cookies) + }, + any() + ) + } + + @Test + fun `captureClientError without sendDefaultPii does not send headers`() { + val sut = fixture.getSut(sendDefaultPii = false) + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.hub, request, response) + verify(fixture.hub).captureEvent( + check { + val req = it.request + val resp = it.contexts.response + + assertIs(req) + assertNull(req.headers) + assertNull(req.cookies) + + assertIs(resp) + assertNull(resp.headers) + assertNull(resp.cookies) + }, + any() + ) + } +}