Skip to content

Commit dbfff5f

Browse files
committed
RUM-14902: Track GraphQL errors
1 parent 61409f2 commit dbfff5f

18 files changed

Lines changed: 1071 additions & 101 deletions

File tree

dd-sdk-android-core/src/testFixtures/kotlin/com/datadog/android/tests/elmyr/ForgeExt.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
package com.datadog.android.tests.elmyr
88

9+
import com.datadog.android.api.instrumentation.network.HttpRequestInfo
10+
import com.datadog.android.api.instrumentation.network.MutableHttpRequestInfo
911
import fr.xgouchet.elmyr.Case
1012
import fr.xgouchet.elmyr.Forge
1113
import org.json.JSONArray
@@ -65,3 +67,10 @@ fun Forge.aHostName(): String {
6567
}
6668

6769
fun Forge.anUrlString(): String = aStringMatching(URL_FORGERY_PATTERN)
70+
71+
fun Forge.anHttpRequestInfo(headers: Map<String, String>): HttpRequestInfo {
72+
return (getForgery<HttpRequestInfo>() as MutableHttpRequestInfo)
73+
.newBuilder()
74+
.apply { headers.forEach { (key, value) -> addHeader(key, value) } }
75+
.build()
76+
}

dd-sdk-android-internal/api/apiSurface

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,8 @@ fun <T> allowThreadDiskReads(() -> T): T
239239
fun <T> allowThreadDiskWrites(() -> T): T
240240
fun StringBuilder.appendIfNotEmpty(String)
241241
fun StringBuilder.appendIfNotEmpty(Char)
242+
fun String.toBase64(): String
243+
fun String.fromBase64(): String?
242244
fun Thread.safeGetThreadId(): Long
243245
fun Thread.State.asString(): String
244246
fun Array<StackTraceElement>.loggableStackTrace(): String

dd-sdk-android-internal/api/dd-sdk-android-internal.api

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,11 @@ public final class com/datadog/android/internal/utils/StringBuilderExtKt {
553553
public static final fun appendIfNotEmpty (Ljava/lang/StringBuilder;Ljava/lang/String;)Ljava/lang/StringBuilder;
554554
}
555555

556+
public final class com/datadog/android/internal/utils/StringExtKt {
557+
public static final fun fromBase64 (Ljava/lang/String;)Ljava/lang/String;
558+
public static final fun toBase64 (Ljava/lang/String;)Ljava/lang/String;
559+
}
560+
556561
public final class com/datadog/android/internal/utils/ThreadExtKt {
557562
public static final fun asString (Ljava/lang/Thread$State;)Ljava/lang/String;
558563
public static final fun loggableStackTrace ([Ljava/lang/StackTraceElement;)Ljava/lang/String;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.internal.utils
8+
9+
import android.util.Base64
10+
11+
/**
12+
* Encodes this string to Base64 using UTF-8 encoding.
13+
*
14+
* @return the Base64-encoded representation of this string, without line wrapping.
15+
*/
16+
fun String.toBase64(): String {
17+
val bytes = this.toByteArray(Charsets.UTF_8)
18+
@Suppress("UnsafeThirdPartyFunctionCall") // cannot throw UnsupportedEncodingException
19+
return Base64.encodeToString(bytes, Base64.NO_WRAP)
20+
}
21+
22+
/**
23+
* Decodes this Base64-encoded string back to its original UTF-8 representation.
24+
*
25+
* @return the decoded string, or `null` if the input is not valid Base64.
26+
*/
27+
fun String.fromBase64(): String? {
28+
return try {
29+
val decodedBytes = Base64.decode(this, Base64.NO_WRAP)
30+
decodedBytes?.toString(Charsets.UTF_8)
31+
} catch (_: IllegalArgumentException) {
32+
null
33+
}
34+
}

detekt_custom_safe_calls.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,14 +1362,18 @@ datadog:
13621362
- "okio.Buffer.constructor()"
13631363
# endregion
13641364
# region org.json
1365+
- "org.json.JSONArray.constructor()"
13651366
- "org.json.JSONArray.length()"
1367+
- "org.json.JSONArray.put(kotlin.Any?)"
13661368
- "org.json.JSONArray.toJsonArray()"
13671369
- "org.json.JSONObject.constructor()"
1370+
- "org.json.JSONObject.has(kotlin.String?)"
13681371
- "org.json.JSONObject.keys()"
1369-
- "org.json.JSONObject.optString(kotlin.String?, kotlin.String?)"
1370-
- "org.json.JSONObject.toJsonObject()"
13711372
- "org.json.JSONObject.length()"
13721373
- "org.json.JSONObject.opt(kotlin.String?)"
1374+
- "org.json.JSONObject.optJSONObject(kotlin.String?)"
1375+
- "org.json.JSONObject.optString(kotlin.String?, kotlin.String?)"
1376+
- "org.json.JSONObject.toJsonObject()"
13731377
# endregion
13741378
# region OpenFeature
13751379
- "dev.openfeature.kotlin.sdk.EvaluationContext.asMap()"

detekt_custom_unsafe_calls.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,10 @@ datadog:
250250
# endregion
251251
# region org.json
252252
- "org.json.JSONArray.get(kotlin.Int):org.json.JSONException"
253+
- "org.json.JSONArray.getJSONObject(kotlin.Int):org.json.JSONException"
253254
- "org.json.JSONObject.constructor(kotlin.String?):org.json.JSONException"
254255
- "org.json.JSONObject.get(kotlin.String):org.json.JSONException"
256+
- "org.json.JSONObject.getJSONArray(kotlin.String?):org.json.JSONException"
255257
- "org.json.JSONObject.getString(kotlin.String?):org.json.JSONException"
256258
- "org.json.JSONObject.put(kotlin.String?, kotlin.Any?):org.json.JSONException"
257259
- "org.json.JSONObject.put(kotlin.String?, kotlin.Long):org.json.JSONException"

features/dd-sdk-android-rum/api/apiSurface

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,11 @@ interface com.datadog.android.rum.internal.monitor.AdvancedNetworkRumMonitor : c
231231
fun stopResource(com.datadog.android.rum.resource.ResourceId, Int?, Long?, com.datadog.android.rum.RumResourceKind, Map<String, Any?> = emptyMap())
232232
fun stopResourceWithError(com.datadog.android.rum.resource.ResourceId, Int?, String, com.datadog.android.rum.RumErrorSource, Throwable, Map<String, Any?> = emptyMap())
233233
fun stopResourceWithError(com.datadog.android.rum.resource.ResourceId, Int?, String, com.datadog.android.rum.RumErrorSource, String, String?, Map<String, Any?> = emptyMap())
234+
class com.datadog.android.rum.internal.net.GraphQLExtractor
235+
fun extractGraphQLAttributes(com.datadog.android.api.instrumentation.network.HttpRequestInfo): Map<String, Any?>
236+
fun extractGraphQLErrors(String?, String?, com.datadog.android.api.InternalLogger): String?
237+
companion object
238+
const val MAX_GRAPHQL_BODY_PEEK: Long
234239
class com.datadog.android.rum.internal.net.RumNetworkInstrumentation
235240
fun sendWaitForResourceTimingEvent(com.datadog.android.api.instrumentation.network.HttpRequestInfo)
236241
fun sendTiming(com.datadog.android.api.instrumentation.network.HttpRequestInfo, com.datadog.android.rum.internal.domain.event.ResourceTiming)

features/dd-sdk-android-rum/api/dd-sdk-android-rum.api

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,17 @@ public final class com/datadog/android/rum/internal/monitor/AdvancedNetworkRumMo
406406
public static synthetic fun stopResourceWithError$default (Lcom/datadog/android/rum/internal/monitor/AdvancedNetworkRumMonitor;Lcom/datadog/android/rum/resource/ResourceId;Ljava/lang/Integer;Ljava/lang/String;Lcom/datadog/android/rum/RumErrorSource;Ljava/lang/Throwable;Ljava/util/Map;ILjava/lang/Object;)V
407407
}
408408

409+
public final class com/datadog/android/rum/internal/net/GraphQLExtractor {
410+
public static final field Companion Lcom/datadog/android/rum/internal/net/GraphQLExtractor$Companion;
411+
public static final field MAX_GRAPHQL_BODY_PEEK J
412+
public fun <init> ()V
413+
public final fun extractGraphQLAttributes (Lcom/datadog/android/api/instrumentation/network/HttpRequestInfo;)Ljava/util/Map;
414+
public final fun extractGraphQLErrors (Ljava/lang/String;Ljava/lang/String;Lcom/datadog/android/api/InternalLogger;)Ljava/lang/String;
415+
}
416+
417+
public final class com/datadog/android/rum/internal/net/GraphQLExtractor$Companion {
418+
}
419+
409420
public final class com/datadog/android/rum/internal/net/RumNetworkInstrumentation {
410421
public static final field Companion Lcom/datadog/android/rum/internal/net/RumNetworkInstrumentation$Companion;
411422
public final fun reportInstrumentationError (Lkotlin/jvm/functions/Function0;)V
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.rum.internal.net
8+
9+
import com.datadog.android.api.InternalLogger
10+
import com.datadog.android.api.instrumentation.network.HttpRequestInfo
11+
import com.datadog.android.internal.network.GraphQLHeaders
12+
import com.datadog.android.internal.utils.fromBase64
13+
import com.datadog.android.lint.InternalApi
14+
import com.datadog.android.rum.RumAttributes
15+
import org.json.JSONArray
16+
import org.json.JSONException
17+
import org.json.JSONObject
18+
19+
/**
20+
* Extracts GraphQL-related attributes and errors from network requests and responses.
21+
*/
22+
@InternalApi
23+
class GraphQLExtractor {
24+
25+
/**
26+
* Extracts GraphQL attributes (operation name, type, variables, payload) from the request headers.
27+
* @param requestInfo the HTTP request info containing headers
28+
* @return a map of GraphQL attributes decoded from Base64 header values
29+
*/
30+
fun extractGraphQLAttributes(requestInfo: HttpRequestInfo): Map<String, Any?> {
31+
return buildMap {
32+
GRAPHQL_HEADER_TO_ATTRIBUTE.forEach { (header, attribute) ->
33+
requestInfo.headers[header.headerValue]?.firstOrNull()?.let {
34+
put(attribute, it.fromBase64())
35+
}
36+
}
37+
}
38+
}
39+
40+
/**
41+
* Extracts and normalizes GraphQL errors from a JSON response body.
42+
* @param contentType the content type of the response
43+
* @param body the response body string
44+
* @param internalLogger the logger for reporting parsing failures
45+
* @return a JSON array string of normalized errors, or null if none found
46+
*/
47+
fun extractGraphQLErrors(
48+
contentType: String?,
49+
body: String?,
50+
internalLogger: InternalLogger
51+
): String? {
52+
if (body == null || contentType !in JSON_CONTENT_TYPES) return null
53+
54+
return try {
55+
val json = JSONObject(body)
56+
val errorsArray = if (json.has(ERRORS_KEY)) json.getJSONArray(ERRORS_KEY) else null
57+
58+
if (errorsArray == null || errorsArray.length() == 0) {
59+
null
60+
} else {
61+
val normalizedErrors = JSONArray()
62+
for (i in 0 until errorsArray.length()) {
63+
@Suppress("UnsafeThirdPartyFunctionCall") // JSONException is caught and we are in array bounds
64+
val error = errorsArray.getJSONObject(i)
65+
normalizeErrorCode(error)
66+
normalizedErrors.put(error)
67+
}
68+
normalizedErrors.toString()
69+
}
70+
} catch (e: JSONException) {
71+
internalLogger.log(
72+
InternalLogger.Level.WARN,
73+
InternalLogger.Target.MAINTAINER,
74+
{ ERROR_EXTRACT_GRAPHQL_ERRORS },
75+
e
76+
)
77+
null
78+
}
79+
}
80+
81+
private fun normalizeErrorCode(error: JSONObject) {
82+
if (!error.has(CODE_KEY)) {
83+
val code = error.optJSONObject(EXTENSIONS_KEY)?.opt(CODE_KEY)
84+
if (code != null) {
85+
@Suppress("UnsafeThirdPartyFunctionCall") // caught by caller's catch blocks
86+
error.put(CODE_KEY, code)
87+
}
88+
}
89+
}
90+
91+
companion object {
92+
93+
/** Maximum number of bytes to peek from a response body when extracting GraphQL errors. */
94+
const val MAX_GRAPHQL_BODY_PEEK: Long = 512L * 1024L
95+
96+
internal const val ERROR_EXTRACT_GRAPHQL_ERRORS =
97+
"Failed to extract GraphQL errors from response body."
98+
99+
private const val ERRORS_KEY = "errors"
100+
private const val EXTENSIONS_KEY = "extensions"
101+
private const val CODE_KEY = "code"
102+
103+
private val GRAPHQL_HEADER_TO_ATTRIBUTE = mapOf(
104+
GraphQLHeaders.DD_GRAPHQL_NAME_HEADER to RumAttributes.GRAPHQL_OPERATION_NAME,
105+
GraphQLHeaders.DD_GRAPHQL_TYPE_HEADER to RumAttributes.GRAPHQL_OPERATION_TYPE,
106+
GraphQLHeaders.DD_GRAPHQL_VARIABLES_HEADER to RumAttributes.GRAPHQL_VARIABLES,
107+
GraphQLHeaders.DD_GRAPHQL_PAYLOAD_HEADER to RumAttributes.GRAPHQL_PAYLOAD
108+
)
109+
110+
internal val JSON_CONTENT_TYPES = setOf(
111+
"application/json",
112+
"application/graphql+json",
113+
"application/graphql-response+json"
114+
)
115+
}
116+
}

0 commit comments

Comments
 (0)