@@ -3,23 +3,50 @@ package io.sentry.android.okhttp
33import io.sentry.BaggageHeader
44import io.sentry.Breadcrumb
55import io.sentry.Hint
6+ import io.sentry.HttpStatusCodeRange
67import io.sentry.HubAdapter
78import io.sentry.IHub
89import io.sentry.ISpan
10+ import io.sentry.SentryEvent
911import io.sentry.SpanStatus
10- import io.sentry.TracePropagationTargets
1112import io.sentry.TypeCheckHint.OKHTTP_REQUEST
1213import io.sentry.TypeCheckHint.OKHTTP_RESPONSE
14+ import io.sentry.exception.ExceptionMechanismException
15+ import io.sentry.exception.SentryHttpClientException
16+ import io.sentry.protocol.Mechanism
17+ import io.sentry.util.HttpUtils
18+ import io.sentry.util.PropagationTargetsUtils
19+ import okhttp3.Headers
1320import okhttp3.Interceptor
1421import okhttp3.Request
1522import okhttp3.Response
1623import java.io.IOException
1724
25+ /* *
26+ * The Sentry's [SentryOkHttpInterceptor], it will automatically add a breadcrumb and start a span
27+ * out of the active span bound to the scope for each HTTP Request.
28+ * If [captureFailedRequests] is enabled, the SDK will capture HTTP Client errors as well.
29+ *
30+ * @param hub The [IHub], internal and only used for testing.
31+ * @param beforeSpan The [ISpan] can be customized or dropped with the [BeforeSpanCallback].
32+ * @param captureFailedRequests The SDK will only capture HTTP Client errors if it is enabled,
33+ * Defaults to false.
34+ * @param failedRequestStatusCodes The SDK will only capture HTTP Client errors if the HTTP Response
35+ * status code is within the defined ranges.
36+ * @param failedRequestTargets The SDK will only capture HTTP Client errors if the HTTP Request URL
37+ * is a match for any of the defined targets.
38+ */
1839class SentryOkHttpInterceptor (
1940 private val hub : IHub = HubAdapter .getInstance(),
20- private val beforeSpan : BeforeSpanCallback ? = null
41+ private val beforeSpan : BeforeSpanCallback ? = null ,
42+ private val captureFailedRequests : Boolean = false ,
43+ private val failedRequestStatusCodes : List <HttpStatusCodeRange > = listOf(
44+ HttpStatusCodeRange (HttpStatusCodeRange .DEFAULT_MIN , HttpStatusCodeRange .DEFAULT_MAX )
45+ ),
46+ private val failedRequestTargets : List <String > = listOf(".*")
2147) : Interceptor {
2248
49+ constructor () : this (HubAdapter .getInstance())
2350 constructor (hub: IHub ) : this (hub, null )
2451 constructor (beforeSpan: BeforeSpanCallback ) : this (HubAdapter .getInstance(), beforeSpan)
2552
@@ -38,7 +65,7 @@ class SentryOkHttpInterceptor(
3865 try {
3966 val requestBuilder = request.newBuilder()
4067 if (span != null &&
41- TracePropagationTargets .contain(hub.options.tracePropagationTargets, request.url.toString())
68+ PropagationTargetsUtils .contain(hub.options.tracePropagationTargets, request.url.toString())
4269 ) {
4370 span.toSentryTrace().let {
4471 requestBuilder.addHeader(it.name, it.value)
@@ -53,6 +80,12 @@ class SentryOkHttpInterceptor(
5380 response = chain.proceed(request)
5481 code = response.code
5582 span?.status = SpanStatus .fromHttpStatusCode(code)
83+
84+ // OkHttp errors (4xx, 5xx) don't throw, so it's safe to call within this block.
85+ // breadcrumbs are added on the finally block because we'd like to know if the device
86+ // had an unstable connection or something similar
87+ captureEvent(request, response)
88+
5689 return response
5790 } catch (e: IOException ) {
5891 span?.apply {
@@ -104,6 +137,110 @@ class SentryOkHttpInterceptor(
104137 }
105138 }
106139
140+ private fun captureEvent (request : Request , response : Response ) {
141+ // return if the feature is disabled or its not within the range
142+ if (! captureFailedRequests || ! containsStatusCode(response.code)) {
143+ return
144+ }
145+
146+ // not possible to get a parameterized url, but we remove at least the
147+ // query string and the fragment.
148+ // url example: https://api.github.com/users/getsentry/repos/#fragment?query=query
149+ // url will be: https://api.github.com/users/getsentry/repos/
150+ // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/
151+ // but that's not possible
152+ var requestUrl = request.url.toString()
153+
154+ val query = request.url.query
155+ if (! query.isNullOrEmpty()) {
156+ requestUrl = requestUrl.replace(" ?$query " , " " )
157+ }
158+
159+ val urlFragment = request.url.fragment
160+ if (! urlFragment.isNullOrEmpty()) {
161+ requestUrl = requestUrl.replace(" #$urlFragment " , " " )
162+ }
163+
164+ // return if its not a target match
165+ if (! PropagationTargetsUtils .contain(failedRequestTargets, requestUrl)) {
166+ return
167+ }
168+
169+ val mechanism = Mechanism ().apply {
170+ type = " SentryOkHttpInterceptor"
171+ }
172+ val exception = SentryHttpClientException (
173+ " HTTP Client Error with status code: ${response.code} "
174+ )
175+ val mechanismException = ExceptionMechanismException (mechanism, exception, Thread .currentThread(), true )
176+ val event = SentryEvent (mechanismException)
177+
178+ val hint = Hint ()
179+ hint.set(OKHTTP_REQUEST , request)
180+ hint.set(OKHTTP_RESPONSE , response)
181+
182+ val sentryRequest = io.sentry.protocol.Request ().apply {
183+ url = requestUrl
184+ // Cookie is only sent if isSendDefaultPii is enabled
185+ cookies = if (hub.options.isSendDefaultPii) request.headers[" Cookie" ] else null
186+ method = request.method
187+ queryString = query
188+ headers = getHeaders(request.headers)
189+ fragment = urlFragment
190+
191+ request.body?.contentLength().ifHasValidLength {
192+ bodySize = it
193+ }
194+ }
195+
196+ val sentryResponse = io.sentry.protocol.Response ().apply {
197+ // Cookie is only sent if isSendDefaultPii is enabled due to PII
198+ cookies = if (hub.options.isSendDefaultPii) response.headers[" Cookie" ] else null
199+ headers = getHeaders(response.headers)
200+ statusCode = response.code
201+
202+ response.body?.contentLength().ifHasValidLength {
203+ bodySize = it
204+ }
205+ }
206+
207+ event.request = sentryRequest
208+ event.contexts.setResponse(sentryResponse)
209+
210+ hub.captureEvent(event, hint)
211+ }
212+
213+ private fun containsStatusCode (statusCode : Int ): Boolean {
214+ for (item in failedRequestStatusCodes) {
215+ if (item.isInRange(statusCode)) {
216+ return true
217+ }
218+ }
219+ return false
220+ }
221+
222+ private fun getHeaders (requestHeaders : Headers ): MutableMap <String , String >? {
223+ // Headers are only sent if isSendDefaultPii is enabled due to PII
224+ if (! hub.options.isSendDefaultPii) {
225+ return null
226+ }
227+
228+ val headers = mutableMapOf<String , String >()
229+
230+ for (i in 0 until requestHeaders.size) {
231+ val name = requestHeaders.name(i)
232+
233+ // header is only sent if isn't sensitive
234+ if (HttpUtils .containsSensitiveHeader(name)) {
235+ continue
236+ }
237+
238+ val value = requestHeaders.value(i)
239+ headers[name] = value
240+ }
241+ return headers
242+ }
243+
107244 /* *
108245 * The BeforeSpan callback
109246 */
0 commit comments