Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Unreleased
- [BUGFIX] Fix tracing header injection for sampled out requests. See [#2473][]

- [FEATURE] Support errors for GraphQL requests. See [#2552]

# 3.2.0 / 30-10-2025

- [FIX] Fix Logger race condition. See [#2514][]
Expand Down Expand Up @@ -981,6 +983,7 @@ Release `2.0` introduces breaking changes. Follow the [Migration Guide](MIGRATIO
[#2538]: https://github.com/DataDog/dd-sdk-ios/pull/2538
[#2532]: https://github.com/DataDog/dd-sdk-ios/pull/2532
[#2514]: https://github.com/DataDog/dd-sdk-ios/pull/2514
[#2552]: https://github.com/DataDog/dd-sdk-ios/pull/2552

[@00fa9a]: https://github.com/00FA9A
[@britton-earnin]: https://github.com/Britton-Earnin
Expand Down Expand Up @@ -1016,4 +1019,4 @@ Release `2.0` introduces breaking changes. Follow the [Migration Guide](MIGRATIO
[@Hengyu]: https://github.com/Hengyu
[@naftaly]: https://github.com/naftaly
[@jbluntz]: https://github.com/jbluntz
[@tdr-alays]: https://github.com/tdr-alays
[@tdr-alays]: https://github.com/tdr-alays
6 changes: 6 additions & 0 deletions Datadog/Datadog.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,8 @@
866CA4A92E02EE0100E0CD03 /* BrightnessLevelPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866CA4A72E02EE0100E0CD03 /* BrightnessLevelPublisherTests.swift */; };
86B43EC72DE464ED00FDDD29 /* LocaleInfoPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86B43EC62DE464ED00FDDD29 /* LocaleInfoPublisher.swift */; };
86B43EC82DE464ED00FDDD29 /* LocaleInfoPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86B43EC62DE464ED00FDDD29 /* LocaleInfoPublisher.swift */; };
86DE9C5F2EB22417006DADE7 /* GraphQLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86DE9C5E2EB22417006DADE7 /* GraphQLResponse.swift */; };
86DE9C602EB22417006DADE7 /* GraphQLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86DE9C5E2EB22417006DADE7 /* GraphQLResponse.swift */; };
8DADC3B62E7CCF0D0060F558 /* FlagsEvaluationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DADC3B32E7CCF0D0060F558 /* FlagsEvaluationContext.swift */; };
8DADC3B92E7CCF0D0060F558 /* FlagsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DADC3AE2E7CCF0D0060F558 /* FlagsClient.swift */; };
8DADC3BC2E7CCF0D0060F558 /* FlagsEvaluationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DADC3B32E7CCF0D0060F558 /* FlagsEvaluationContext.swift */; };
Expand Down Expand Up @@ -3205,6 +3207,7 @@
864A707E2DE092AD00AC0619 /* AccessibilityReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityReaderTests.swift; sourceTree = "<group>"; };
866CA4A72E02EE0100E0CD03 /* BrightnessLevelPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessLevelPublisherTests.swift; sourceTree = "<group>"; };
86B43EC62DE464ED00FDDD29 /* LocaleInfoPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocaleInfoPublisher.swift; sourceTree = "<group>"; };
86DE9C5E2EB22417006DADE7 /* GraphQLResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLResponse.swift; sourceTree = "<group>"; };
8DADC3AE2E7CCF0D0060F558 /* FlagsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagsClient.swift; sourceTree = "<group>"; };
8DADC3B32E7CCF0D0060F558 /* FlagsEvaluationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagsEvaluationContext.swift; sourceTree = "<group>"; };
8DADC3C02E7CCF190060F558 /* FlagsClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagsClientTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6142,6 +6145,7 @@
61E5333224B7A504003D6C4E /* DataModels */ = {
isa = PBXGroup;
children = (
86DE9C5E2EB22417006DADE7 /* GraphQLResponse.swift */,
11F55FD82DCE183500DE4944 /* RUMDataModels+objc.swift */,
864A70782DDF742A00AC0619 /* AccessibilityInfo.swift */,
618715F824DC13A100FC0F69 /* RUMDataModelsMapping.swift */,
Expand Down Expand Up @@ -9635,6 +9639,7 @@
D23F8E5C29DDCD28001CFAE8 /* RUMApplicationScope.swift in Sources */,
3CFF4F982C09E64C006F191D /* WatchdogTerminationMonitor.swift in Sources */,
61193AAF2CB54C7300C3CDF5 /* RUMActionsHandler.swift in Sources */,
86DE9C5F2EB22417006DADE7 /* GraphQLResponse.swift in Sources */,
114FFDE82E0031BA00330C91 /* SwiftUIRUMViewsPredicate+objc.swift in Sources */,
D23F8E5D29DDCD28001CFAE8 /* SwiftUIViewModifier.swift in Sources */,
D23F8E5E29DDCD28001CFAE8 /* VitalInfo.swift in Sources */,
Expand Down Expand Up @@ -10111,6 +10116,7 @@
D29A9F7329DD85BB005C54A4 /* RUMApplicationScope.swift in Sources */,
3CFF4F972C09E64C006F191D /* WatchdogTerminationMonitor.swift in Sources */,
61193AAE2CB54C7300C3CDF5 /* RUMActionsHandler.swift in Sources */,
86DE9C602EB22417006DADE7 /* GraphQLResponse.swift in Sources */,
114FFDE92E0031BA00330C91 /* SwiftUIRUMViewsPredicate+objc.swift in Sources */,
D29A9F6A29DD85BB005C54A4 /* SwiftUIViewModifier.swift in Sources */,
D29A9F6429DD85BB005C54A4 /* VitalInfo.swift in Sources */,
Expand Down
5 changes: 5 additions & 0 deletions DatadogInternal/Sources/Attributes/Attributes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ public struct CrossPlatformAttributes {
/// Expects `String` value.
public static let graphqlVariables = "_dd.graphql.variables"

/// Custom attribute passed when completing GraphQL RUM resources that contain errors in the response.
/// It sets the GraphQL errors from the response body as JSON data.
/// Expects `Data` value.
public static let graphqlErrors = "_dd.graphql.errors"

/// Override the `source_type` of errors reported by the native crash handler. This is used on
/// platforms that can supply extra steps or information on a native crash (such as Unity's IL2CPP)
public static let nativeSourceType = "_dd.native_source_type"
Expand Down
2 changes: 1 addition & 1 deletion DatadogInternal/Sources/Models/RUM/RUMDataModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11333,4 +11333,4 @@ extension TelemetryUsageEvent.Telemetry {
}
}

// Generated from https://github.com/DataDog/rum-events-format/tree/fe242fe9a02cc373e61127d7a2ef629991a5c28f
// Generated from https://github.com/DataDog/rum-events-format/tree/72236fa673663c8cfed3ccb27d9f6f8e41f7c05f
86 changes: 86 additions & 0 deletions DatadogRUM/Sources/DataModels/GraphQLResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2019-Present Datadog, Inc.
*/

import Foundation

// MARK: - GraphQL Response Models

/// Lightweight decoder to check if a GraphQL response contains errors.
/// Only checks for the presence of the "errors" key without decoding the entire array.
internal struct GraphQLResponseHasErrors: Decodable {
let hasErrors: Bool
private enum CodingKeys: String, CodingKey { case errors }

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
hasErrors = container.contains(.errors)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

smart!

}
}

/// Full decoder for GraphQL response with errors array.
/// Used when we need to extract the actual error details.
internal struct GraphQLResponse: Decodable {
let errors: [GraphQLResponseError]?
}

/// Represents a GraphQL error in the response.
///
/// Note: Some GraphQL implementations may include `code` at the error level (legacy pattern)
/// instead of within `extensions.code`. Both locations are supported for compatibility.
/// Reference: https://spec.graphql.org/September2025/#note-5c13b
internal struct GraphQLResponseError: Decodable {
let message: String
let locations: [GraphQLResponseErrorLocation]?
let path: [GraphQLResponsePathElement]?
let extensions: Extensions?

/// Error code extracted from either `extensions.code` (preferred) or top-level `code` (legacy).
var code: String? {
return extensions?.code ?? legacyCode
}

/// Legacy code field that some implementations put at the error level instead of in extensions.
private let legacyCode: String?

private enum CodingKeys: String, CodingKey {
case message
case locations
case path
case extensions
case legacyCode = "code"
}

/// GraphQL error extensions. Only the `code` field is extracted as it's the most commonly used.
/// The GraphQL spec allows any additional fields in extensions, but we focus on error codes.
struct Extensions: Decodable {
let code: String?
}
}

/// Represents a location in a GraphQL query where an error occurred.
internal struct GraphQLResponseErrorLocation: Decodable {
let line: Int
let column: Int
}

/// Represents an element in the path to a field that caused an error.
internal enum GraphQLResponsePathElement: Decodable {
case string(String)
case int(Int)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intValue = try? container.decode(Int.self) {
self = .int(intValue)
} else if let stringValue = try? container.decode(String.self) {
self = .string(stringValue)
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Path element must be string or int"
)
}
}
}
2 changes: 1 addition & 1 deletion DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11879,4 +11879,4 @@ public class objc_TelemetryErrorEventView: NSObject {

// swiftlint:enable force_unwrapping

// Generated from https://github.com/DataDog/rum-events-format/tree/fe242fe9a02cc373e61127d7a2ef629991a5c28f
// Generated from https://github.com/DataDog/rum-events-format/tree/72236fa673663c8cfed3ccb27d9f6f8e41f7c05f
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ internal final class URLSessionRUMResourcesHandler: DatadogURLSessionHandler, RU
}
}

// Extract GraphQL errors from response if present
if let errorsData = extractGraphQLErrorsIfPresent(from: interception) {
combinedAttributes[CrossPlatformAttributes.graphqlErrors] = errorsData
}

if let resourceMetrics = interception.metrics {
subscriber.process(
command: RUMAddResourceMetricsCommand(
Expand Down Expand Up @@ -169,6 +174,25 @@ internal final class URLSessionRUMResourcesHandler: DatadogURLSessionHandler, RU
)
}
}

/// Extracts GraphQL errors from JSON response if present.
/// Only the errors array is extracted to avoid storing potentially large response data fields.
private func extractGraphQLErrorsIfPresent(from interception: URLSessionTaskInterception) -> Data? {
guard let data = interception.data,
let httpResponse = interception.completion?.httpResponse,
let mimeType = httpResponse.mimeType,
mimeType.lowercased().contains("json") else {
return nil
}

// Fast check: does the response contain an "errors" key?
guard let result = try? JSONDecoder().decode(GraphQLResponseHasErrors.self, from: data),
result.hasErrors else {
return nil
}

return data
}
}

extension DistributedTracing {
Expand Down
47 changes: 47 additions & 0 deletions DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,17 @@ internal class RUMResourceScope: RUMScope {
let graphqlOperationName: String? = attributes.removeValue(forKey: CrossPlatformAttributes.graphqlOperationName)?.dd.decode()
let graphqlPayload: String? = attributes.removeValue(forKey: CrossPlatformAttributes.graphqlPayload)?.dd.decode()
let graphqlVariables: String? = attributes.removeValue(forKey: CrossPlatformAttributes.graphqlVariables)?.dd.decode()
let graphqlErrorsData: Data? = attributes.removeValue(forKey: CrossPlatformAttributes.graphqlErrors)?.dd.decode()

// Parse GraphQL errors if present
let graphqlErrors = parseGraphQLErrors(from: graphqlErrorsData)

if
let rawGraphqlOperationType: String = attributes.removeValue(forKey: CrossPlatformAttributes.graphqlOperationType)?.dd.decode(),
let graphqlOperationType = RUMResourceEvent.Resource.Graphql.OperationType(rawValue: rawGraphqlOperationType) {
graphql = .init(
errorCount: graphqlErrors?.count.toInt64,
errors: graphqlErrors,
operationName: graphqlOperationName,
operationType: graphqlOperationType,
payload: graphqlPayload,
Expand Down Expand Up @@ -410,4 +417,44 @@ internal class RUMResourceScope: RUMScope {

return duration.toInt64Nanoseconds
}

/// Decodes GraphQL errors from JSON data and returns them as RUM event errors
private func parseGraphQLErrors(from data: Data?) -> [RUMResourceEvent.Resource.Graphql.Errors]? {
guard let data else {
return nil
}

do {
let response = try JSONDecoder().decode(GraphQLResponse.self, from: data)

guard let responseErrors = response.errors, !responseErrors.isEmpty else {
return nil
}
let parsedErrors = responseErrors.map { error in
RUMResourceEvent.Resource.Graphql.Errors(
code: error.code,
locations: error.locations?.map { location in
RUMResourceEvent.Resource.Graphql.Errors.Locations(
column: Int64(location.column),
line: Int64(location.line)
)
},
message: error.message,
path: error.path?.map { pathElement in
switch pathElement {
case .string(let value):
return .string(value: value)
case .int(let value):
return .integer(value: Int64(value))
}
}
)
}

return parsedErrors
} catch {
DD.logger.debug("Failed to decode GraphQL errors: \(error)")
return nil
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,82 @@ class URLSessionRUMResourcesHandlerTests: XCTestCase {
XCTAssertTrue(samplingDecisions.allSatisfy { $0 == firstDecision }, "All sampling decisions for the same session should be identical")
}

func testGivenGraphQLResponseBodyWithErrors_whenInterceptionCompletes_itExtractsErrors() throws {
let receiveCommand = expectation(description: "Receive RUMStopResourceCommand")
var stopResourceCommand: RUMStopResourceCommand?
commandSubscriber.onCommandReceived = { command in
if let command = command as? RUMStopResourceCommand {
stopResourceCommand = command
receiveCommand.fulfill()
}
}

// Given
var mockRequest: URLRequest = .mockWith(url: "https://graphql.example.com/api")
mockRequest.setValue("GetUser", forHTTPHeaderField: ExpectedGraphQLHeaders.operationName)

let responseWithErrors = """
{
"errors": [{"message": "Not found"}],
"data": null
}
"""

let immutableRequest = ImmutableRequest(request: mockRequest)
let taskInterception = URLSessionTaskInterception(request: immutableRequest, isFirstParty: false)
let response: HTTPURLResponse = .mockWith(statusCode: 200, mimeType: "application/json")
taskInterception.register(nextData: responseWithErrors.data(using: .utf8)!)
taskInterception.register(response: response, error: nil)

// When
handler.interceptionDidComplete(interception: taskInterception)

// Then
waitForExpectations(timeout: 0.5, handler: nil)

let attributes = try XCTUnwrap(stopResourceCommand?.attributes)
XCTAssertNotNil(attributes[CrossPlatformAttributes.graphqlErrors])
let errorsData = try XCTUnwrap(attributes[CrossPlatformAttributes.graphqlErrors] as? Data)
let errorsJSON = try XCTUnwrap(String(data: errorsData, encoding: .utf8))
XCTAssertTrue(errorsJSON.contains("Not found"))
}

func testGivenGraphQLResponseWithNonJSONContentType_whenInterceptionCompletes_itDoesNotParseErrors() throws {
let receiveCommand = expectation(description: "Receive RUMStopResourceCommand")
var stopResourceCommand: RUMStopResourceCommand?
commandSubscriber.onCommandReceived = { command in
if let command = command as? RUMStopResourceCommand {
stopResourceCommand = command
receiveCommand.fulfill()
}
}

// Given
var mockRequest: URLRequest = .mockWith(url: "https://graphql.example.com/api")
mockRequest.setValue("GetUser", forHTTPHeaderField: ExpectedGraphQLHeaders.operationName)

let responseWithErrors = """
{
"errors": [{"message": "Not found"}],
"data": null
}
"""

let immutableRequest = ImmutableRequest(request: mockRequest)
let taskInterception = URLSessionTaskInterception(request: immutableRequest, isFirstParty: false)
let nonJSONResponse: HTTPURLResponse = .mockWith(statusCode: 200, mimeType: "text/html")
taskInterception.register(nextData: responseWithErrors.data(using: .utf8)!)
taskInterception.register(response: nonJSONResponse, error: nil)

// When
handler.interceptionDidComplete(interception: taskInterception)

// Then
waitForExpectations(timeout: 0.5, handler: nil)

XCTAssertNil(stopResourceCommand?.attributes[CrossPlatformAttributes.graphqlErrors])
}

// MARK: - Helper Methods

private func extractBaggageKeyValuePairs(from header: String) -> [String: String] {
Expand Down
Loading