Skip to content

Commit 90cbf43

Browse files
committed
Add GraphQL errors support to RUM resource events
1 parent 7eda91f commit 90cbf43

5 files changed

Lines changed: 282 additions & 3 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ object com.datadog.android.rum.RumAttributes
3131
const val GRAPHQL_OPERATION_NAME: String
3232
const val GRAPHQL_PAYLOAD: String
3333
const val GRAPHQL_VARIABLES: String
34+
const val GRAPHQL_ERRORS: String
3435
const val ERROR_RESOURCE_METHOD: String
3536
const val ERROR_RESOURCE_STATUS_CODE: String
3637
const val ERROR_RESOURCE_URL: String

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public final class com/datadog/android/rum/RumAttributes {
6262
public static final field ERROR_RESOURCE_STATUS_CODE Ljava/lang/String;
6363
public static final field ERROR_RESOURCE_URL Ljava/lang/String;
6464
public static final field FLUTTER_FIRST_BUILD_COMPLETE Ljava/lang/String;
65+
public static final field GRAPHQL_ERRORS Ljava/lang/String;
6566
public static final field GRAPHQL_OPERATION_NAME Ljava/lang/String;
6667
public static final field GRAPHQL_OPERATION_TYPE Ljava/lang/String;
6768
public static final field GRAPHQL_PAYLOAD Ljava/lang/String;

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumAttributes.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ object RumAttributes {
126126
*/
127127
const val GRAPHQL_VARIABLES: String = "_dd.graphql.variables"
128128

129+
/**
130+
* JSON representation of GraphQL errors (String).
131+
*/
132+
const val GRAPHQL_ERRORS: String = "_dd.graphql.errors"
133+
129134
// endregion
130135

131136
// region Error

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScope.kt

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ internal class RumResourceScope(
250250
val graphqlOperationName = resourceAttributes.remove(RumAttributes.GRAPHQL_OPERATION_NAME) as? String
251251
val graphqlOperationType = resourceAttributes.remove(RumAttributes.GRAPHQL_OPERATION_TYPE) as? String
252252
val graphqlVariables = resourceAttributes.remove(RumAttributes.GRAPHQL_VARIABLES) as? String
253+
val graphqlErrors = resourceAttributes.remove(RumAttributes.GRAPHQL_ERRORS) as? String
253254

254255
// The decision whether to send payloads is determined by a DatadogApolloInterceptor parameter
255256
val rawPayload = resourceAttributes.remove(RumAttributes.GRAPHQL_PAYLOAD) as? String
@@ -259,7 +260,8 @@ internal class RumResourceScope(
259260
operationType = graphqlOperationType,
260261
operationName = graphqlOperationName,
261262
variables = graphqlVariables,
262-
payload = graphqlPayload
263+
payload = graphqlPayload,
264+
errors = graphqlErrors
263265
)
264266

265267
sdkCore.newRumEventWriteOperation(datadogContext, writeScope, writer) {
@@ -551,20 +553,84 @@ internal class RumResourceScope(
551553
operationType: String?,
552554
operationName: String?,
553555
variables: String?,
554-
payload: String?
556+
payload: String?,
557+
errors: String?
555558
): ResourceEvent.Graphql? {
556559
operationType?.toOperationType(sdkCore.internalLogger)?.let {
560+
val parsedErrors = errors?.let { parseGraphQLErrors(it) }
561+
557562
return ResourceEvent.Graphql(
558563
operationType = it,
559564
operationName = operationName,
560565
variables = variables,
561-
payload = payload
566+
payload = payload,
567+
errorCount = parsedErrors?.size?.toLong(),
568+
errors = parsedErrors
562569
)
563570
}
564571

565572
return null
566573
}
567574

575+
@Suppress("ReturnCount", "TooGenericExceptionCaught", "SwallowedException")
576+
private fun parseGraphQLErrors(errorsJson: String): List<ResourceEvent.Error>? {
577+
try {
578+
val jsonElement = com.google.gson.JsonParser.parseString(errorsJson)
579+
580+
// Handle both formats:
581+
// 1. Direct array: [{"message": "...", ...}]
582+
// 2. Wrapped in object: {"errors": [{"message": "...", ...}]}
583+
val jsonArray = when {
584+
jsonElement.isJsonArray -> jsonElement.asJsonArray
585+
jsonElement.isJsonObject -> {
586+
val obj = jsonElement.asJsonObject
587+
obj.getAsJsonArray("errors") ?: run {
588+
sdkCore.internalLogger.log(
589+
level = InternalLogger.Level.WARN,
590+
target = InternalLogger.Target.USER,
591+
messageBuilder = { "GraphQL errors JSON is an object but has no 'errors' array" }
592+
)
593+
return null
594+
}
595+
}
596+
else -> {
597+
sdkCore.internalLogger.log(
598+
level = InternalLogger.Level.WARN,
599+
target = InternalLogger.Target.USER,
600+
messageBuilder = { "GraphQL errors JSON is neither an array nor an object" }
601+
)
602+
return null
603+
}
604+
}
605+
606+
val errors = mutableListOf<ResourceEvent.Error>()
607+
608+
jsonArray.forEach { errorElement ->
609+
try {
610+
val error = ResourceEvent.Error.fromJsonObject(errorElement.asJsonObject)
611+
errors.add(error)
612+
} catch (e: Exception) {
613+
sdkCore.internalLogger.log(
614+
level = InternalLogger.Level.WARN,
615+
target = InternalLogger.Target.USER,
616+
messageBuilder = { "Failed to parse GraphQL error: ${e.message}" },
617+
throwable = e
618+
)
619+
}
620+
}
621+
622+
return errors.ifEmpty { null }
623+
} catch (e: Exception) {
624+
sdkCore.internalLogger.log(
625+
level = InternalLogger.Level.WARN,
626+
target = InternalLogger.Target.USER,
627+
messageBuilder = { "Failed to parse GraphQL errors JSON: ${e.message}" },
628+
throwable = e
629+
)
630+
return null
631+
}
632+
}
633+
568634
@Suppress("ReturnCount", "SwallowedException")
569635
private fun String.truncateToUtf8Bytes(maxBytes: Int): String {
570636
val encoder =

features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScopeTest.kt

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3631,6 +3631,212 @@ internal class RumResourceScopeTest {
36313631
}
36323632
}
36333633

3634+
@Test
3635+
fun `M parse GraphQL errors W handleEvent { wrapped errors format }`(
3636+
@Forgery kind: RumResourceKind,
3637+
@LongForgery(200, 600) statusCode: Long,
3638+
@LongForgery(0, 1024) size: Long,
3639+
forge: Forge
3640+
) {
3641+
// Given
3642+
val operationType = forge.aValueFrom(ResourceEvent.OperationType::class.java)
3643+
val operationName = forge.aNullable { aString() }
3644+
val variables = forge.aNullable { aString() }
3645+
3646+
// Errors wrapped in an object
3647+
val errorsJson = """
3648+
{
3649+
"errors": [
3650+
{
3651+
"message": "User not found",
3652+
"code": "NOT_FOUND",
3653+
"locations": [{"line": 3, "column": 5}],
3654+
"path": ["user", "profile"]
3655+
},
3656+
{
3657+
"message": "Validation failed"
3658+
}
3659+
]
3660+
}
3661+
""".trimIndent()
3662+
3663+
val attributes = forge.exhaustiveAttributes(excludedKeys = fakeResourceAttributes.keys) +
3664+
mapOf(
3665+
RumAttributes.GRAPHQL_OPERATION_TYPE to operationType.toString(),
3666+
RumAttributes.GRAPHQL_OPERATION_NAME to operationName,
3667+
RumAttributes.GRAPHQL_VARIABLES to variables,
3668+
RumAttributes.GRAPHQL_ERRORS to errorsJson
3669+
)
3670+
3671+
mockEvent = RumRawEvent.StopResource(fakeKey, statusCode, size, kind, attributes)
3672+
3673+
// When
3674+
testedScope.handleEvent(mockEvent, fakeDatadogContext, mockEventWriteScope, mockWriter)
3675+
3676+
// Then
3677+
argumentCaptor<ResourceEvent> {
3678+
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
3679+
val graphql = firstValue.resource.graphql
3680+
assertThat(graphql).isNotNull()
3681+
assertThat(graphql?.errorCount).isEqualTo(2)
3682+
assertThat(graphql?.errors).hasSize(2)
3683+
assertThat(graphql?.errors?.get(0)?.message).isEqualTo("User not found")
3684+
assertThat(graphql?.errors?.get(0)?.code).isEqualTo("NOT_FOUND")
3685+
assertThat(graphql?.errors?.get(1)?.message).isEqualTo("Validation failed")
3686+
}
3687+
}
3688+
3689+
@Test
3690+
fun `M parse GraphQL errors W handleEvent { direct array format }`(
3691+
@Forgery kind: RumResourceKind,
3692+
@LongForgery(200, 600) statusCode: Long,
3693+
@LongForgery(0, 1024) size: Long,
3694+
forge: Forge
3695+
) {
3696+
// Given
3697+
val operationType = forge.aValueFrom(ResourceEvent.OperationType::class.java)
3698+
val operationName = forge.aNullable { aString() }
3699+
3700+
// Direct array format
3701+
val errorsJson = """
3702+
[
3703+
{
3704+
"message": "Field does not exist",
3705+
"code": "GRAPHQL_VALIDATION_FAILED"
3706+
}
3707+
]
3708+
""".trimIndent()
3709+
3710+
val attributes = forge.exhaustiveAttributes(excludedKeys = fakeResourceAttributes.keys) +
3711+
mapOf(
3712+
RumAttributes.GRAPHQL_OPERATION_TYPE to operationType.toString(),
3713+
RumAttributes.GRAPHQL_OPERATION_NAME to operationName,
3714+
RumAttributes.GRAPHQL_ERRORS to errorsJson
3715+
)
3716+
3717+
mockEvent = RumRawEvent.StopResource(fakeKey, statusCode, size, kind, attributes)
3718+
3719+
// When
3720+
testedScope.handleEvent(mockEvent, fakeDatadogContext, mockEventWriteScope, mockWriter)
3721+
3722+
// Then
3723+
argumentCaptor<ResourceEvent> {
3724+
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
3725+
val graphql = firstValue.resource.graphql
3726+
assertThat(graphql?.errorCount).isEqualTo(1)
3727+
assertThat(graphql?.errors).hasSize(1)
3728+
assertThat(graphql?.errors?.get(0)?.message).isEqualTo("Field does not exist")
3729+
assertThat(graphql?.errors?.get(0)?.code).isEqualTo("GRAPHQL_VALIDATION_FAILED")
3730+
}
3731+
}
3732+
3733+
@Test
3734+
fun `M handle null errors W handleEvent { no errors in GraphQL response }`(
3735+
@Forgery kind: RumResourceKind,
3736+
@LongForgery(200, 600) statusCode: Long,
3737+
@LongForgery(0, 1024) size: Long,
3738+
forge: Forge
3739+
) {
3740+
// Given
3741+
val operationType = forge.aValueFrom(ResourceEvent.OperationType::class.java)
3742+
val operationName = forge.aNullable { aString() }
3743+
3744+
val attributes = forge.exhaustiveAttributes(excludedKeys = fakeResourceAttributes.keys) +
3745+
mapOf(
3746+
RumAttributes.GRAPHQL_OPERATION_TYPE to operationType.toString(),
3747+
RumAttributes.GRAPHQL_OPERATION_NAME to operationName
3748+
// No GRAPHQL_ERRORS attribute
3749+
)
3750+
3751+
mockEvent = RumRawEvent.StopResource(fakeKey, statusCode, size, kind, attributes)
3752+
3753+
// When
3754+
testedScope.handleEvent(mockEvent, fakeDatadogContext, mockEventWriteScope, mockWriter)
3755+
3756+
// Then
3757+
argumentCaptor<ResourceEvent> {
3758+
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
3759+
val graphql = firstValue.resource.graphql
3760+
assertThat(graphql?.errorCount).isNull()
3761+
assertThat(graphql?.errors).isNull()
3762+
}
3763+
}
3764+
3765+
@Test
3766+
fun `M handle invalid JSON W handleEvent { malformed GraphQL errors JSON }`(
3767+
@Forgery kind: RumResourceKind,
3768+
@LongForgery(200, 600) statusCode: Long,
3769+
@LongForgery(0, 1024) size: Long,
3770+
forge: Forge
3771+
) {
3772+
// Given
3773+
val operationType = forge.aValueFrom(ResourceEvent.OperationType::class.java)
3774+
val operationName = forge.aNullable { aString() }
3775+
3776+
val invalidErrorsJson = "{ invalid json }"
3777+
3778+
val attributes = forge.exhaustiveAttributes(excludedKeys = fakeResourceAttributes.keys) +
3779+
mapOf(
3780+
RumAttributes.GRAPHQL_OPERATION_TYPE to operationType.toString(),
3781+
RumAttributes.GRAPHQL_OPERATION_NAME to operationName,
3782+
RumAttributes.GRAPHQL_ERRORS to invalidErrorsJson
3783+
)
3784+
3785+
mockEvent = RumRawEvent.StopResource(fakeKey, statusCode, size, kind, attributes)
3786+
3787+
// When
3788+
testedScope.handleEvent(mockEvent, fakeDatadogContext, mockEventWriteScope, mockWriter)
3789+
3790+
// Then
3791+
argumentCaptor<ResourceEvent> {
3792+
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
3793+
val graphql = firstValue.resource.graphql
3794+
// Should handle gracefully with null errors
3795+
assertThat(graphql?.errorCount).isNull()
3796+
assertThat(graphql?.errors).isNull()
3797+
}
3798+
mockInternalLogger.verifyLog(
3799+
InternalLogger.Level.WARN,
3800+
InternalLogger.Target.USER,
3801+
"Failed to parse GraphQL errors JSON"
3802+
)
3803+
}
3804+
3805+
@Test
3806+
fun `M handle empty errors array W handleEvent { GraphQL errors is empty array }`(
3807+
@Forgery kind: RumResourceKind,
3808+
@LongForgery(200, 600) statusCode: Long,
3809+
@LongForgery(0, 1024) size: Long,
3810+
forge: Forge
3811+
) {
3812+
// Given
3813+
val operationType = forge.aValueFrom(ResourceEvent.OperationType::class.java)
3814+
val operationName = forge.aNullable { aString() }
3815+
3816+
val emptyErrorsJson = """{"errors": []}"""
3817+
3818+
val attributes = forge.exhaustiveAttributes(excludedKeys = fakeResourceAttributes.keys) +
3819+
mapOf(
3820+
RumAttributes.GRAPHQL_OPERATION_TYPE to operationType.toString(),
3821+
RumAttributes.GRAPHQL_OPERATION_NAME to operationName,
3822+
RumAttributes.GRAPHQL_ERRORS to emptyErrorsJson
3823+
)
3824+
3825+
mockEvent = RumRawEvent.StopResource(fakeKey, statusCode, size, kind, attributes)
3826+
3827+
// When
3828+
testedScope.handleEvent(mockEvent, fakeDatadogContext, mockEventWriteScope, mockWriter)
3829+
3830+
// Then
3831+
argumentCaptor<ResourceEvent> {
3832+
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
3833+
val graphql = firstValue.resource.graphql
3834+
// Empty errors should result in null
3835+
assertThat(graphql?.errorCount).isNull()
3836+
assertThat(graphql?.errors).isNull()
3837+
}
3838+
}
3839+
36343840
// endregion
36353841

36363842
companion object {

0 commit comments

Comments
 (0)