Skip to content

Commit

Permalink
Augment http logs with extra fields for gql requests
Browse files Browse the repository at this point in the history
  • Loading branch information
murki committed Dec 6, 2024
1 parent 19eeac8 commit b7b4bc1
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 76 deletions.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// capture-sdk - bitdrift's client SDK
// Copyright Bitdrift, Inc. All rights reserved.
//
// Use of this source code is governed by a source available license that can be found in the
// LICENSE file or at:
// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt

package io.bitdrift.capture.apollo3

import com.apollographql.apollo3.api.ApolloRequest
import com.apollographql.apollo3.api.ApolloResponse
import com.apollographql.apollo3.api.Mutation
import com.apollographql.apollo3.api.Operation
import com.apollographql.apollo3.api.Query
import com.apollographql.apollo3.api.Subscription
import com.apollographql.apollo3.interceptor.ApolloInterceptor
import com.apollographql.apollo3.interceptor.ApolloInterceptorChain
import io.bitdrift.capture.Capture
import kotlinx.coroutines.flow.Flow

/**
* An [ApolloInterceptor] that logs request and response events to the [Capture.Logger].
*/
class CaptureApolloInterceptor: ApolloInterceptor {

override fun <D : Operation.Data> intercept(request: ApolloRequest<D>, chain: ApolloInterceptorChain): Flow<ApolloResponse<D>> {
// Use special header format that is recognized by the CaptureOkHttpEventListener to be transformed into a span
val requestBuilder = request.newBuilder()
.addHttpHeader("x-capture-span-key", "gql")
.addHttpHeader("x-capture-span-gql-name", "graphql")
.addHttpHeader("x-capture-span-gql-field-operation-name", request.operation.name())
.addHttpHeader("x-capture-span-gql-field-operation-id", request.operation.id())
.addHttpHeader("x-capture-span-gql-field-operation-type", request.operation.type())
// TODO(murki): Augment with
// request.executionContext[CustomScalarAdapters]?.let {
// addHttpHeader("x-capture-span-gql-field-operation-variables", request.operation.variables(it).valueMap.toString())
// }

val modifiedRequest = requestBuilder.build()

// TODO(murki): Augment response logs with response.errors
return chain.proceed(modifiedRequest)
}

private fun <D : Operation.Data> Operation<D>.type(): String {
return when (this) {
is Query -> "query"
is Mutation -> "mutation"
is Subscription -> "subscription"
else -> this.javaClass.simpleName
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ data class HttpRequestInfo @JvmOverloads constructor(

internal val commonFields: InternalFieldsMap by lazy {
extraFields.toFields() + buildMap {
putOptionalHeaderSpanFields(headers)
put(SpanField.Key.ID, FieldValue.StringField(spanId.toString()))
put(SpanField.Key.NAME, FieldValue.StringField("_http"))
put(SpanField.Key.TYPE, FieldValue.StringField(SpanField.Value.TYPE_START))
put("_method", FieldValue.StringField(method))
putOptional(HttpFieldKey.HOST, host)
Expand All @@ -62,4 +62,31 @@ data class HttpRequestInfo @JvmOverloads constructor(
}

internal val matchingFields: InternalFieldsMap = headers?.let { HTTPHeaders.normalizeHeaders(it) }.toFields()

/**
* Adds optional fields to the mutable map based on the provided headers.
*
* This function checks for the presence of the "x-capture-span-key" header.
* If the header is present, it constructs a span name and additional fields from other headers
* and adds them to the map. If the header is not present, it adds a default span name.
*
* @param headers The map of headers from which fields are extracted.
*/
private fun MutableMap<String, FieldValue>.putOptionalHeaderSpanFields(headers: Map<String, String>?) {
headers?.get("x-capture-span-key")?.let { spanKey ->
val prefix = "x-capture-span-$spanKey"
val spanName = headers["$prefix-name"] ?: ""
put(SpanField.Key.NAME, FieldValue.StringField(spanName))
val fieldPrefix = "$prefix-field"
headers.forEach { (key, value) ->
if (key.startsWith(fieldPrefix)) {
val fieldKey = key.removePrefix(fieldPrefix).replace('-', '_')
put(fieldKey, FieldValue.StringField(value))
}
}
} ?: run {
// Default span name is simply http
put(SpanField.Key.NAME, FieldValue.StringField("_http"))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
package io.bitdrift.capture

import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.argThat
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.eq
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
Expand All @@ -17,6 +19,7 @@ import io.bitdrift.capture.network.HttpRequestInfo
import io.bitdrift.capture.network.HttpResponseInfo
import io.bitdrift.capture.network.okhttp.CaptureOkHttpEventListenerFactory
import okhttp3.Call
import okhttp3.Headers.Companion.toHeaders
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
Expand Down Expand Up @@ -336,4 +339,58 @@ class CaptureOkHttpEventListenerFactoryTest {
assertThat(httpResponseInfo.fields["_error_type"].toString()).isEqualTo(err::javaClass.get().simpleName)
assertThat(httpResponseInfo.fields["_error_message"].toString()).isEqualTo(errorMessage)
}

@Test
fun testExtraHeadersSendCustomSpans() {
// ARRANGE
val headerFields = mapOf(
"x-capture-span-key" to "gql",
"x-capture-span-gql-name" to "mySpanName",
"x-capture-span-gql-field-operation-name" to "myOperationName",
"x-capture-span-gql-field-operation-id" to "myOperationId",
"x-capture-span-gql-field-operation-type" to "query",
)
// val expectedSpanName = "_mySpanName"
// val expectedFields = mapOf(
// "_operation_name" to "myOperationName",
// "_operation_id" to "myOperationId",
// "_operation_type" to "query",
// )

val request = Request.Builder()
.url(endpoint)
.post("test".toRequestBody())
.headers(headerFields.toHeaders())
.build()
val call: Call = mock()
whenever(call.request()).thenReturn(request)

// ACT
val factory = CaptureOkHttpEventListenerFactory(null, logger, clock)
val listener = factory.create(call)

listener.callStart(call)

// listener.requestHeadersEnd(call, request)
// listener.requestBodyEnd(call, 4)
//
// listener.responseHeadersEnd(call, response)
// listener.responseBodyEnd(call, 234)
//
// listener.callEnd(call)

// ASSERT
// verify(logger).startSpan(eq(expectedSpanName), eq(LogLevel.DEBUG), argThat { this.entries.containsAll(expectedFields.entries) })
val httpRequestInfoCapture = argumentCaptor<HttpRequestInfo>()
verify(logger).log(httpRequestInfoCapture.capture())
// val httpResponseInfoCapture = argumentCaptor<HttpResponseInfo>()
// verify(logger).log(httpResponseInfoCapture.capture())

val httpRequestInfo = httpRequestInfoCapture.firstValue
// val httpResponseInfo = httpResponseInfoCapture.firstValue

assertThat(httpRequestInfo.fields["_operation_name"].toString()).isEqualTo("myOperationName")
// assertThat(httpResponseInfo.fields["_path"].toString())
// .isEqualTo(httpRequestInfo.fields["_path"].toString())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import io.bitdrift.capture.Capture.Logger
import io.bitdrift.capture.CaptureJniLibrary
import io.bitdrift.capture.LogLevel
import io.bitdrift.capture.LoggerImpl
import io.bitdrift.capture.apollo3.CaptureApollo3Interceptor
import io.bitdrift.capture.apollo3.CaptureApolloInterceptor
import io.bitdrift.capture.network.okhttp.CaptureOkHttpEventListenerFactory
import io.bitdrift.gradletestapp.databinding.FragmentFirstBinding
import kotlinx.coroutines.MainScope
Expand Down Expand Up @@ -154,7 +154,7 @@ class FirstFragment : Fragment() {
private fun provideApolloClient(): ApolloClient {
return ApolloClient.Builder()
.serverUrl("https://apollo-fullstack-tutorial.herokuapp.com/graphql")
.addInterceptor(CaptureApollo3Interceptor())
.addInterceptor(CaptureApolloInterceptor())
.build()
}

Expand Down Expand Up @@ -223,8 +223,13 @@ class FirstFragment : Fragment() {

private fun performGraphQlRequest(view: View) {
MainScope().launch {
val response = apolloClient.query(LaunchListQuery()).execute()
Logger.logDebug(mapOf("response_data" to response.data.toString())) { "GraphQL response data received" }
try {
val response = apolloClient.query(LaunchListQuery()).execute()
// Logger.logDebug(mapOf("response_headers" to response.executionContext[HttpInfo]?.headers.toString())) { "GraphQL response headers received" }
Logger.logDebug(mapOf("response_data" to response.data.toString())) { "GraphQL response data received" }
} catch (e: Exception) {
Timber.e(e, "GraphQL request failed")
}
}

}
Expand Down

0 comments on commit b7b4bc1

Please sign in to comment.