Skip to content

Commit

Permalink
feat: Send GraphQL "operationName" in HTTP breadcrumbs (#3931)
Browse files Browse the repository at this point in the history
This PR attempts to support sending GraphQL operation names with existing HTTP breadcrumbs.


Co-authored-by: Max Chuquimia <>
Co-authored-by: Philipp Hofmann <philipp.hofmann@sentry.io>
  • Loading branch information
maxchuquimia and philipphofmann committed May 7, 2024
1 parent 99ab5d0 commit cbd7725
Show file tree
Hide file tree
Showing 12 changed files with 198 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Features

- Add option to use own NSURLSession for transport (#3811)
- Support sending GraphQL operation names in HTTP breadcrumbs (#3931)

### Fixes

Expand Down
9 changes: 9 additions & 0 deletions Sentry.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@
15E0A8F22411A45A00F044E3 /* SentrySession.m in Sources */ = {isa = PBXBuildFile; fileRef = 15E0A8F12411A45A00F044E3 /* SentrySession.m */; };
33042A0D29DAF79A00C60085 /* SentryExtraContextProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 33042A0C29DAF79A00C60085 /* SentryExtraContextProvider.m */; };
33042A1729DC2C4300C60085 /* SentryExtraContextProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33042A1629DC2C4300C60085 /* SentryExtraContextProviderTests.swift */; };
51B15F7E2BE88A7C0026A2F2 /* URLSessionTaskExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B15F7D2BE88A7C0026A2F2 /* URLSessionTaskExtensions.swift */; };
51B15F802BE88D510026A2F2 /* URLSessionTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B15F7F2BE88D510026A2F2 /* URLSessionTaskTests.swift */; };
620379DB2AFE1415005AC0C1 /* SentryBuildAppStartSpans.h in Headers */ = {isa = PBXBuildFile; fileRef = 620379DA2AFE1415005AC0C1 /* SentryBuildAppStartSpans.h */; };
620379DD2AFE1432005AC0C1 /* SentryBuildAppStartSpans.m in Sources */ = {isa = PBXBuildFile; fileRef = 620379DC2AFE1432005AC0C1 /* SentryBuildAppStartSpans.m */; };
621D9F2F2B9B0320003D94DE /* SentryCurrentDateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621D9F2E2B9B0320003D94DE /* SentryCurrentDateProvider.swift */; };
Expand Down Expand Up @@ -1026,6 +1028,8 @@
33042A0B29DAF5F400C60085 /* SentryExtraContextProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SentryExtraContextProvider.h; sourceTree = "<group>"; };
33042A0C29DAF79A00C60085 /* SentryExtraContextProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryExtraContextProvider.m; sourceTree = "<group>"; };
33042A1629DC2C4300C60085 /* SentryExtraContextProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryExtraContextProviderTests.swift; sourceTree = "<group>"; };
51B15F7D2BE88A7C0026A2F2 /* URLSessionTaskExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionTaskExtensions.swift; sourceTree = "<group>"; };
51B15F7F2BE88D510026A2F2 /* URLSessionTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTaskTests.swift; sourceTree = "<group>"; };
620379DA2AFE1415005AC0C1 /* SentryBuildAppStartSpans.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryBuildAppStartSpans.h; path = include/SentryBuildAppStartSpans.h; sourceTree = "<group>"; };
620379DC2AFE1432005AC0C1 /* SentryBuildAppStartSpans.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryBuildAppStartSpans.m; sourceTree = "<group>"; };
621D9F2E2B9B0320003D94DE /* SentryCurrentDateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryCurrentDateProvider.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2085,6 +2089,7 @@
isa = PBXGroup;
children = (
62872B622BA1B86100A4FA7D /* NSLockTests.swift */,
51B15F7F2BE88D510026A2F2 /* URLSessionTaskTests.swift */,
);
path = Extensions;
sourceTree = "<group>";
Expand Down Expand Up @@ -3783,6 +3788,7 @@
children = (
D8F016B52B962548007B9AFB /* StringExtensions.swift */,
62872B5E2BA1B7F300A4FA7D /* NSLock.swift */,
51B15F7D2BE88A7C0026A2F2 /* URLSessionTaskExtensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
Expand Down Expand Up @@ -4043,6 +4049,7 @@
639FCFA81EBC80CC00778193 /* SentryFrame.h in Headers */,
D8BFE37229A3782F002E73F3 /* SentryTimeToDisplayTracker.h in Headers */,
8E8C57A625EEFC43001CEEFA /* SentrySampling.h in Headers */,
8E8C57A625EEFC43001CEEFA /* SentrySampling.h in Headers */,
7B634599280EB9D100CFA05A /* SentryUIEventTrackingIntegration.h in Headers */,
63FE716D20DA4C1100CDBAE8 /* SentryCrashSysCtl.h in Headers */,
639889BB1EDED18400EA7442 /* SentrySwizzle.h in Headers */,
Expand Down Expand Up @@ -4387,6 +4394,7 @@
7B3B473825D6CC7E00D01640 /* SentryNSError.m in Sources */,
D8ACE3C82762187200F5A213 /* SentryNSDataTracker.m in Sources */,
7BE3C77D2446112C00A38442 /* SentryRateLimitParser.m in Sources */,
51B15F7E2BE88A7C0026A2F2 /* URLSessionTaskExtensions.swift in Sources */,
D8B088B729C9E3FF00213258 /* SentryTracerConfiguration.m in Sources */,
8ECC674A25C23A20000E2BF6 /* SentryTransactionContext.mm in Sources */,
03BCC38C27E1C01A003232C7 /* SentryTime.mm in Sources */,
Expand Down Expand Up @@ -4722,6 +4730,7 @@
63FE721420DA66EC00CDBAE8 /* SentryCrashMemory_Tests.m in Sources */,
62885DA729E946B100554F38 /* TestConncurrentModifications.swift in Sources */,
63FE720520DA66EC00CDBAE8 /* FileBasedTestCase.m in Sources */,
51B15F802BE88D510026A2F2 /* URLSessionTaskTests.swift in Sources */,
63EED6C32237989300E02400 /* SentryOptionsTest.m in Sources */,
7BBD18B22451804C00427C76 /* SentryRetryAfterHeaderParserTests.swift in Sources */,
7BD337E424A356180050DB6E /* SentryCrashIntegrationTests.swift in Sources */,
Expand Down
6 changes: 6 additions & 0 deletions Sources/Sentry/Public/SentryOptions.h
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ NS_SWIFT_NAME(Options)
*/
@property (nonatomic, assign) BOOL enableAutoSessionTracking;

/**
* Whether to attach the top level `operationName` node of HTTP json requests to HTTP breadcrumbs
* @note Default is @c NO.
*/
@property (nonatomic, assign) BOOL enableGraphQLOperationTracking;

/**
* Whether to enable Watchdog Termination tracking or not.
* @note This feature requires the @c SentryCrashIntegration being enabled, otherwise it would
Expand Down
19 changes: 19 additions & 0 deletions Sources/Sentry/SentryNetworkTracker.m
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
@property (nonatomic, assign) BOOL isNetworkTrackingEnabled;
@property (nonatomic, assign) BOOL isNetworkBreadcrumbEnabled;
@property (nonatomic, assign) BOOL isCaptureFailedRequestsEnabled;
@property (nonatomic, assign) BOOL isGraphQLOperationTrackingEnabled;

@end

Expand All @@ -62,6 +63,7 @@ - (instancetype)init
_isNetworkTrackingEnabled = NO;
_isNetworkBreadcrumbEnabled = NO;
_isCaptureFailedRequestsEnabled = NO;
_isGraphQLOperationTrackingEnabled = NO;
}
return self;
}
Expand All @@ -87,12 +89,20 @@ - (void)enableCaptureFailedRequests
}
}

- (void)enableGraphQLOperationTracking
{
@synchronized(self) {
_isGraphQLOperationTrackingEnabled = YES;
}
}

- (void)disable
{
@synchronized(self) {
_isNetworkBreadcrumbEnabled = NO;
_isNetworkTrackingEnabled = NO;
_isCaptureFailedRequestsEnabled = NO;
_isGraphQLOperationTrackingEnabled = NO;
}
}

Expand Down Expand Up @@ -440,6 +450,11 @@ - (void)captureFailedRequests:(NSURLSessionTask *)sessionTask
}

context[@"response"] = response;

if (self.isGraphQLOperationTrackingEnabled) {
context[@"graphql_operation_name"] = [sessionTask getGraphQLOperationName];
}

event.context = context;

[SentrySDK captureEvent:event];
Expand Down Expand Up @@ -489,6 +504,10 @@ - (void)addBreadcrumbForSessionTask:(NSURLSessionTask *)sessionTask
breadcrumbData[@"status_code"] = statusCode;
breadcrumbData[@"reason"] =
[NSHTTPURLResponse localizedStringForStatusCode:responseStatusCode];

if (self.isGraphQLOperationTrackingEnabled) {
breadcrumbData[@"graphql_operation_name"] = [sessionTask getGraphQLOperationName];
}
}

if (urlComponents.query != nil) {
Expand Down
4 changes: 4 additions & 0 deletions Sources/Sentry/SentryNetworkTrackingIntegration.m
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ - (BOOL)installWithOptions:(SentryOptions *)options
[SentryNetworkTracker.sharedInstance enableCaptureFailedRequests];
}

if (options.enableGraphQLOperationTracking) {
[SentryNetworkTracker.sharedInstance enableGraphQLOperationTracking];
}

if (shouldEnableNetworkTracking || options.enableNetworkBreadcrumbs
|| options.enableCaptureFailedRequests) {
[SentryNetworkTrackingIntegration swizzleURLSessionTask];
Expand Down
4 changes: 4 additions & 0 deletions Sources/Sentry/SentryOptions.m
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ - (instancetype)init
_integrations = SentryOptions.defaultIntegrations;
self.sampleRate = SENTRY_DEFAULT_SAMPLE_RATE;
self.enableAutoSessionTracking = YES;
self.enableGraphQLOperationTracking = NO;
self.enableWatchdogTerminationTracking = YES;
self.sessionTrackingIntervalMillis = [@30000 unsignedIntValue];
self.attachStacktrace = YES;
Expand Down Expand Up @@ -353,6 +354,9 @@ - (BOOL)validateOptions:(NSDictionary<NSString *, id> *)options
[self setBool:options[@"enableAutoSessionTracking"]
block:^(BOOL value) { self->_enableAutoSessionTracking = value; }];

[self setBool:options[@"enableGraphQLOperationTracking"]
block:^(BOOL value) { self->_enableGraphQLOperationTracking = value; }];

[self setBool:options[@"enableWatchdogTerminationTracking"]
block:^(BOOL value) { self->_enableWatchdogTerminationTracking = value; }];

Expand Down
2 changes: 2 additions & 0 deletions Sources/Sentry/include/SentryNetworkTracker.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ static NSString *const SENTRY_NETWORK_REQUEST_TRACKER_BREADCRUMB
- (void)enableNetworkTracking;
- (void)enableNetworkBreadcrumbs;
- (void)enableCaptureFailedRequests;
- (void)enableGraphQLOperationTracking;
- (BOOL)isTargetMatch:(NSURL *)URL withTargets:(NSArray *)targets;
- (void)disable;

@property (nonatomic, readonly) BOOL isNetworkTrackingEnabled;
@property (nonatomic, readonly) BOOL isNetworkBreadcrumbEnabled;
@property (nonatomic, readonly) BOOL isCaptureFailedRequestsEnabled;
@property (nonatomic, readonly) BOOL isGraphQLOperationTrackingEnabled;

@end

Expand Down
19 changes: 19 additions & 0 deletions Sources/Swift/Extensions/URLSessionTaskExtensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

public extension URLSessionTask {

@objc
func getGraphQLOperationName() -> String? {
guard originalRequest?.value(forHTTPHeaderField: "Content-Type") == "application/json" else { return nil }
guard let requestBody = originalRequest?.httpBody else { return nil }

let requestInfo = try? JSONDecoder().decode(GraphQLRequest.self, from: requestBody)

return requestInfo?.operationName
}

}

private struct GraphQLRequest: Decodable {
let operationName: String
}
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,20 @@ class SentryNetworkTrackerIntegrationTests: XCTestCase {

XCTAssertFalse(SentryNetworkTracker.sharedInstance.isCaptureFailedRequestsEnabled)
}


func testGraphQLOperationTrackingEnabled() {
fixture.options.enableGraphQLOperationTracking = true
startSDK()

XCTAssertTrue(SentryNetworkTracker.sharedInstance.isGraphQLOperationTrackingEnabled)
}

func testGraphQLOperationTrackingDisabled() {
startSDK()

XCTAssertFalse(SentryNetworkTracker.sharedInstance.isGraphQLOperationTrackingEnabled)
}

func testGetCaptureFailedRequestsEnabled() {
let expect = expectation(description: "Request completed")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class SentryNetworkTrackerTests: XCTestCase {
let dateProvider = TestCurrentDateProvider()
let options: Options
let scope: Scope
let nsUrlRequest = NSURLRequest(url: SentryNetworkTrackerTests.fullUrl)
let nsUrlRequest = NSMutableURLRequest(url: SentryNetworkTrackerTests.fullUrl)
let client: TestClient!
let hub: TestHub!
let securityHeader = [ "X-FORWARDED-FOR": "value",
Expand Down Expand Up @@ -48,6 +48,7 @@ class SentryNetworkTrackerTests: XCTestCase {
result.enableNetworkTracking()
result.enableNetworkBreadcrumbs()
result.enableCaptureFailedRequests()
result.enableGraphQLOperationTracking()
return result
}
}
Expand Down Expand Up @@ -337,8 +338,41 @@ class SentryNetworkTrackerTests: XCTestCase {
XCTAssertEqual(breadcrumb!.data!["response_body_size"] as! Int64, DATA_BYTES_RECEIVED)
XCTAssertEqual(breadcrumb!.data!["http.query"] as? String, "query=value&query2=value2")
XCTAssertEqual(breadcrumb!.data!["http.fragment"] as? String, "fragment")
XCTAssertNil(breadcrumb!.data!["graphql_operation_name"])
}


func testBreadcrumb_GraphQLEnabled() {
let body = """
{
"operationName": "someOperationName",
"variables":{"a": 1},
"query":"query someOperationName {\\n someField\\n}\\n"
}
"""
fixture.nsUrlRequest.httpBody = body.data(using: .utf8)
fixture.nsUrlRequest.setValue("application/json", forHTTPHeaderField: "content-type")
assertStatus(status: .ok, state: .completed, response: createResponse(code: 200))

let breadcrumbs = Dynamic(fixture.scope).breadcrumbArray as [Breadcrumb]?
let breadcrumb = breadcrumbs!.first
XCTAssertEqual(breadcrumb!.data!["graphql_operation_name"] as? String, "someOperationName")
}

func testBreadcrumb_GraphQLEnabledInvalidData() {
let body = """
[
{"message": "arrays are valid json"}
]
"""
fixture.nsUrlRequest.httpBody = body.data(using: .utf8)
fixture.nsUrlRequest.setValue("application/json", forHTTPHeaderField: "content-type")
assertStatus(status: .ok, state: .completed, response: createResponse(code: 200))

let breadcrumbs = Dynamic(fixture.scope).breadcrumbArray as [Breadcrumb]?
let breadcrumb = breadcrumbs!.first
XCTAssertNil(breadcrumb!.data!["graphql_operation_name"])
}

func testNoBreadcrumb_DisablingBreadcrumb() {
assertStatus(status: .ok, state: .completed, response: createResponse(code: 200)) {
$0.disable()
Expand Down Expand Up @@ -868,13 +902,15 @@ class SentryNetworkTrackerTests: XCTestCase {
let requestType = span.data["type"] as? String
let query = span.data["http.query"] as? String
let fragment = span.data["http.fragment"] as? String
let graphql = span.data["graphql_operation_name"] as? String

XCTAssertEqual(path, "https://www.domain.com/api")
XCTAssertEqual(method, task.currentRequest!.httpMethod)
XCTAssertEqual(requestType, "fetch")
XCTAssertEqual(query, "query=value&query2=value2")
XCTAssertEqual(fragment, "fragment")

XCTAssertNil(graphql)

XCTAssertEqual(span.status, status)
XCTAssertNil(task.observationInfo)
}
Expand Down Expand Up @@ -925,6 +961,11 @@ class SentryNetworkTrackerTests: XCTestCase {
func createDataTask(method: String = "GET", modifyRequest: ((URLRequest) -> (URLRequest))? = nil) -> URLSessionDataTaskMock {
var request = URLRequest(url: SentryNetworkTrackerTests.fullUrl)
request.httpMethod = method
request.httpBody = fixture.nsUrlRequest.httpBody
fixture.nsUrlRequest.allHTTPHeaderFields?.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}

if let modifyRequest = modifyRequest {
request = modifyRequest(request)
}
Expand Down
5 changes: 5 additions & 0 deletions Tests/SentryTests/SentryOptionsTest.m
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ - (void)testEnableCoreDataTracking
[self testBooleanField:@"enableCoreDataTracing" defaultValue:YES];
}

- (void)testEnableGraphQLOperationTracking
{
[self testBooleanField:@"enableGraphQLOperationTracking" defaultValue:NO];
}

- (void)testSendClientReports
{
[self testBooleanField:@"sendClientReports" defaultValue:YES];
Expand Down
71 changes: 71 additions & 0 deletions Tests/SentryTests/Swift/Extensions/URLSessionTaskTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Foundation
import Nimble
@testable import Sentry
import XCTest

final class URLSessionTaskTests: XCTestCase {

func testHTTPContentTypeInvalid() {
let task = makeTask(
headers: ["Content-Type": "image/jpeg"],
body: "8J+YiQo="
)

let operationName = task.getGraphQLOperationName()

expect(operationName) == nil
}

func testHTTPBodyDataInvalid() {
let task = makeTask(
headers: ["Content-Type": "application/json"],
body: "not json"
)

let operationName = task.getGraphQLOperationName()

expect(operationName) == nil
}

func testHTTPBodyDataMissing() {
let task = makeTask(
headers: ["Content-Type": "application/json"],
body: nil
)

let operationName = task.getGraphQLOperationName()

expect(operationName) == nil
}

func testHTTPBodyDataValidGraphQL() {
let task = makeTask(
headers: ["Content-Type": "application/json"],
body: """
{
"operationName": "MyOperation",
"variables": {
"id": "1234"
},
"query": "query MyOperation($id: ID!) { node(id: $id) { id } }"
}
"""
)

let operationName = task.getGraphQLOperationName()

expect(operationName) == "MyOperation"
}

}

private extension URLSessionTaskTests {

func makeTask(headers: [String: String], body: String?) -> URLSessionTask {
var request = URLRequest(url: URL(string: "https://anything.com")!)
request.httpBody = body?.data(using: .utf8)
request.allHTTPHeaderFields = headers
return URLSession(configuration: .ephemeral).dataTask(with: request)
}

}

0 comments on commit cbd7725

Please sign in to comment.