Skip to content

Commit 12bf4f0

Browse files
authored
[andr] Augment http logs with extra fields for gql requests (#141)
* Augment http logs with extra fields for gql requests * lint * fix * add test coverage * add check for span name * remove
1 parent 31eb8c1 commit 12bf4f0

File tree

5 files changed

+158
-86
lines changed

5 files changed

+158
-86
lines changed

platform/jvm/capture-apollo3/src/main/kotlin/io/bitdrift/capture/apollo3/CaptureApollo3Interceptor.kt

-71
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// capture-sdk - bitdrift's client SDK
2+
// Copyright Bitdrift, Inc. All rights reserved.
3+
//
4+
// Use of this source code is governed by a source available license that can be found in the
5+
// LICENSE file or at:
6+
// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt
7+
8+
package io.bitdrift.capture.apollo3
9+
10+
import com.apollographql.apollo3.api.ApolloRequest
11+
import com.apollographql.apollo3.api.ApolloResponse
12+
import com.apollographql.apollo3.api.Mutation
13+
import com.apollographql.apollo3.api.Operation
14+
import com.apollographql.apollo3.api.Query
15+
import com.apollographql.apollo3.api.Subscription
16+
import com.apollographql.apollo3.interceptor.ApolloInterceptor
17+
import com.apollographql.apollo3.interceptor.ApolloInterceptorChain
18+
import io.bitdrift.capture.Capture
19+
import kotlinx.coroutines.flow.Flow
20+
21+
/**
22+
* An [ApolloInterceptor] that logs request and response events to the [Capture.Logger].
23+
*/
24+
class CaptureApolloInterceptor: ApolloInterceptor {
25+
26+
override fun <D : Operation.Data> intercept(request: ApolloRequest<D>, chain: ApolloInterceptorChain): Flow<ApolloResponse<D>> {
27+
// Use special header format that is recognized by the CaptureOkHttpEventListener to be transformed into a span
28+
val requestBuilder = request.newBuilder()
29+
.addHttpHeader("x-capture-span-key", "gql")
30+
.addHttpHeader("x-capture-span-gql-name", "graphql")
31+
.addHttpHeader("x-capture-span-gql-field-operation-name", request.operation.name())
32+
.addHttpHeader("x-capture-span-gql-field-operation-id", request.operation.id())
33+
.addHttpHeader("x-capture-span-gql-field-operation-type", request.operation.type())
34+
// TODO(murki): Augment request logs with
35+
// request.executionContext[CustomScalarAdapters]?.let {
36+
// addHttpHeader("x-capture-span-gql-field-operation-variables", request.operation.variables(it).valueMap.toString())
37+
// }
38+
39+
val modifiedRequest = requestBuilder.build()
40+
41+
// TODO(murki): Augment response logs with response.errors
42+
return chain.proceed(modifiedRequest)
43+
}
44+
45+
private fun <D : Operation.Data> Operation<D>.type(): String {
46+
return when (this) {
47+
is Query -> "query"
48+
is Mutation -> "mutation"
49+
is Subscription -> "subscription"
50+
else -> this.javaClass.simpleName
51+
}
52+
}
53+
}

platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/network/HttpRequestInfo.kt

+30-2
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@ data class HttpRequestInfo @JvmOverloads constructor(
4545
}
4646

4747
internal val commonFields: InternalFieldsMap by lazy {
48-
extraFields.toFields() + buildMap {
48+
buildMap {
49+
putAll(extraFields.toFields())
50+
putOptionalHeaderSpanFields(headers)
4951
put(SpanField.Key.ID, FieldValue.StringField(spanId.toString()))
50-
put(SpanField.Key.NAME, FieldValue.StringField("_http"))
5152
put(SpanField.Key.TYPE, FieldValue.StringField(SpanField.Value.TYPE_START))
5253
put("_method", FieldValue.StringField(method))
5354
putOptional(HttpFieldKey.HOST, host)
@@ -62,4 +63,31 @@ data class HttpRequestInfo @JvmOverloads constructor(
6263
}
6364

6465
internal val matchingFields: InternalFieldsMap = headers?.let { HTTPHeaders.normalizeHeaders(it) }.toFields()
66+
67+
/**
68+
* Adds optional fields to the mutable map based on the provided headers.
69+
*
70+
* This function checks for the presence of the "x-capture-span-key" header.
71+
* If the header is present, it constructs a span name and additional fields from other headers
72+
* and adds them to the map. If the header is not present, it adds a default span name.
73+
*
74+
* @param headers The map of headers from which fields are extracted.
75+
*/
76+
private fun MutableMap<String, FieldValue>.putOptionalHeaderSpanFields(headers: Map<String, String>?) {
77+
headers?.get("x-capture-span-key")?.let { spanKey ->
78+
val prefix = "x-capture-span-$spanKey"
79+
val spanName = "_" + headers["$prefix-name"]
80+
put(SpanField.Key.NAME, FieldValue.StringField(spanName))
81+
val fieldPrefix = "$prefix-field"
82+
headers.forEach { (key, value) ->
83+
if (key.startsWith(fieldPrefix)) {
84+
val fieldKey = key.removePrefix(fieldPrefix).replace('-', '_')
85+
put(fieldKey, FieldValue.StringField(value))
86+
}
87+
}
88+
} ?: run {
89+
// Default span name is simply http
90+
put(SpanField.Key.NAME, FieldValue.StringField("_http"))
91+
}
92+
}
6593
}

platform/jvm/capture/src/test/kotlin/io/bitdrift/capture/CaptureOkHttpEventListenerFactoryTest.kt

+63
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import io.bitdrift.capture.network.HttpRequestInfo
1717
import io.bitdrift.capture.network.HttpResponseInfo
1818
import io.bitdrift.capture.network.okhttp.CaptureOkHttpEventListenerFactory
1919
import okhttp3.Call
20+
import okhttp3.Headers.Companion.toHeaders
2021
import okhttp3.Protocol
2122
import okhttp3.Request
2223
import okhttp3.RequestBody.Companion.toRequestBody
@@ -85,6 +86,7 @@ class CaptureOkHttpEventListenerFactoryTest {
8586
val httpRequestInfo = httpRequestInfoCapture.firstValue
8687
val httpResponseInfo = httpResponseInfoCapture.firstValue
8788
// common request fields
89+
assertThat(httpRequestInfo.fields["_span_name"].toString()).isEqualTo("_http")
8890
assertThat(httpRequestInfo.fields["_host"].toString()).isEqualTo("api.bitdrift.io")
8991
assertThat(httpRequestInfo.fields["_host"].toString())
9092
.isEqualTo(httpResponseInfo.fields["_host"].toString())
@@ -336,4 +338,65 @@ class CaptureOkHttpEventListenerFactoryTest {
336338
assertThat(httpResponseInfo.fields["_error_type"].toString()).isEqualTo(err::javaClass.get().simpleName)
337339
assertThat(httpResponseInfo.fields["_error_message"].toString()).isEqualTo(errorMessage)
338340
}
341+
342+
@Test
343+
fun testExtraHeadersSendCustomSpans() {
344+
// ARRANGE
345+
val headerFields = mapOf(
346+
"x-capture-span-key" to "gql",
347+
"x-capture-span-gql-name" to "mySpanName",
348+
"x-capture-span-gql-field-operation-name" to "myOperationName",
349+
"x-capture-span-gql-field-operation-id" to "myOperationId",
350+
"x-capture-span-gql-field-operation-type" to "query",
351+
)
352+
val expectedSpanName = "_mySpanName"
353+
val expectedFields = mapOf(
354+
"_operation_name" to "myOperationName",
355+
"_operation_id" to "myOperationId",
356+
"_operation_type" to "query",
357+
)
358+
359+
val request = Request.Builder()
360+
.url(endpoint)
361+
.post("test".toRequestBody())
362+
.headers(headerFields.toHeaders())
363+
.build()
364+
365+
val response = Response.Builder()
366+
.request(request)
367+
.protocol(Protocol.HTTP_2)
368+
.code(200)
369+
.message("message")
370+
.header("response_header", "response_header_value")
371+
.build()
372+
373+
val call: Call = mock()
374+
whenever(call.request()).thenReturn(request)
375+
376+
// ACT
377+
val factory = CaptureOkHttpEventListenerFactory(null, logger, clock)
378+
val listener = factory.create(call)
379+
380+
listener.callStart(call)
381+
382+
listener.responseHeadersEnd(call, response)
383+
listener.responseBodyEnd(call, 234)
384+
385+
listener.callEnd(call)
386+
387+
// ASSERT
388+
val httpRequestInfoCapture = argumentCaptor<HttpRequestInfo>()
389+
verify(logger).log(httpRequestInfoCapture.capture())
390+
val httpResponseInfoCapture = argumentCaptor<HttpResponseInfo>()
391+
verify(logger).log(httpResponseInfoCapture.capture())
392+
393+
val httpRequestInfo = httpRequestInfoCapture.firstValue
394+
val httpResponseInfo = httpResponseInfoCapture.firstValue
395+
396+
assertThat(httpRequestInfo.fields["_span_name"].toString()).isEqualTo(expectedSpanName)
397+
// validate all the extra headers are present as properly formatted fields
398+
assertThat(httpRequestInfo.fields.mapValues { it.value.toString() }.entries.containsAll(expectedFields.entries)).isTrue()
399+
// validate all request fields are present in response
400+
assertThat(httpResponseInfo.fields.mapValues { it.value.toString() }.entries.containsAll(expectedFields.entries)).isTrue()
401+
}
339402
}

platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/FirstFragment.kt

+12-13
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import androidx.compose.ui.platform.ViewCompositionStrategy
2727
import androidx.fragment.app.Fragment
2828
import androidx.navigation.fragment.findNavController
2929
import com.apollographql.apollo3.ApolloClient
30+
import com.apollographql.apollo3.network.okHttpClient
3031
import com.example.rocketreserver.LaunchListQuery
3132
import com.github.michaelbull.result.onFailure
3233
import com.github.michaelbull.result.onSuccess
@@ -36,7 +37,7 @@ import io.bitdrift.capture.Capture.Logger
3637
import io.bitdrift.capture.CaptureJniLibrary
3738
import io.bitdrift.capture.LogLevel
3839
import io.bitdrift.capture.LoggerImpl
39-
import io.bitdrift.capture.apollo3.CaptureApollo3Interceptor
40+
import io.bitdrift.capture.apollo3.CaptureApolloInterceptor
4041
import io.bitdrift.capture.network.okhttp.CaptureOkHttpEventListenerFactory
4142
import io.bitdrift.gradletestapp.databinding.FragmentFirstBinding
4243
import kotlinx.coroutines.MainScope
@@ -141,20 +142,14 @@ class FirstFragment : Fragment() {
141142
AppExitReason.entries
142143
)
143144

144-
okHttpClient = provideOkHttpClient()
145-
apolloClient = provideApolloClient()
146-
}
147-
148-
private fun provideOkHttpClient(): OkHttpClient {
149-
return OkHttpClient.Builder()
145+
okHttpClient = OkHttpClient.Builder()
150146
.eventListenerFactory(CaptureOkHttpEventListenerFactory())
151147
.build()
152-
}
153148

154-
private fun provideApolloClient(): ApolloClient {
155-
return ApolloClient.Builder()
149+
apolloClient = ApolloClient.Builder()
156150
.serverUrl("https://apollo-fullstack-tutorial.herokuapp.com/graphql")
157-
.addInterceptor(CaptureApollo3Interceptor())
151+
.okHttpClient(okHttpClient)
152+
.addInterceptor(CaptureApolloInterceptor())
158153
.build()
159154
}
160155

@@ -223,8 +218,12 @@ class FirstFragment : Fragment() {
223218

224219
private fun performGraphQlRequest(view: View) {
225220
MainScope().launch {
226-
val response = apolloClient.query(LaunchListQuery()).execute()
227-
Logger.logDebug(mapOf("response_data" to response.data.toString())) { "GraphQL response data received" }
221+
try {
222+
val response = apolloClient.query(LaunchListQuery()).execute()
223+
Logger.logDebug(mapOf("response_data" to response.data.toString())) { "GraphQL response data received" }
224+
} catch (e: Exception) {
225+
Timber.e(e, "GraphQL request failed")
226+
}
228227
}
229228

230229
}

0 commit comments

Comments
 (0)