Skip to content

Commit cbd7725

Browse files
feat: Send GraphQL "operationName" in HTTP breadcrumbs (#3931)
This PR attempts to support sending GraphQL operation names with existing HTTP breadcrumbs. Co-authored-by: Max Chuquimia <> Co-authored-by: Philipp Hofmann <[email protected]>
1 parent 99ab5d0 commit cbd7725

12 files changed

+198
-4
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features
66

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

910
### Fixes
1011

Sentry.xcodeproj/project.pbxproj

+9
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@
7272
15E0A8F22411A45A00F044E3 /* SentrySession.m in Sources */ = {isa = PBXBuildFile; fileRef = 15E0A8F12411A45A00F044E3 /* SentrySession.m */; };
7373
33042A0D29DAF79A00C60085 /* SentryExtraContextProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 33042A0C29DAF79A00C60085 /* SentryExtraContextProvider.m */; };
7474
33042A1729DC2C4300C60085 /* SentryExtraContextProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33042A1629DC2C4300C60085 /* SentryExtraContextProviderTests.swift */; };
75+
51B15F7E2BE88A7C0026A2F2 /* URLSessionTaskExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B15F7D2BE88A7C0026A2F2 /* URLSessionTaskExtensions.swift */; };
76+
51B15F802BE88D510026A2F2 /* URLSessionTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B15F7F2BE88D510026A2F2 /* URLSessionTaskTests.swift */; };
7577
620379DB2AFE1415005AC0C1 /* SentryBuildAppStartSpans.h in Headers */ = {isa = PBXBuildFile; fileRef = 620379DA2AFE1415005AC0C1 /* SentryBuildAppStartSpans.h */; };
7678
620379DD2AFE1432005AC0C1 /* SentryBuildAppStartSpans.m in Sources */ = {isa = PBXBuildFile; fileRef = 620379DC2AFE1432005AC0C1 /* SentryBuildAppStartSpans.m */; };
7779
621D9F2F2B9B0320003D94DE /* SentryCurrentDateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621D9F2E2B9B0320003D94DE /* SentryCurrentDateProvider.swift */; };
@@ -1026,6 +1028,8 @@
10261028
33042A0B29DAF5F400C60085 /* SentryExtraContextProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SentryExtraContextProvider.h; sourceTree = "<group>"; };
10271029
33042A0C29DAF79A00C60085 /* SentryExtraContextProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryExtraContextProvider.m; sourceTree = "<group>"; };
10281030
33042A1629DC2C4300C60085 /* SentryExtraContextProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryExtraContextProviderTests.swift; sourceTree = "<group>"; };
1031+
51B15F7D2BE88A7C0026A2F2 /* URLSessionTaskExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionTaskExtensions.swift; sourceTree = "<group>"; };
1032+
51B15F7F2BE88D510026A2F2 /* URLSessionTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTaskTests.swift; sourceTree = "<group>"; };
10291033
620379DA2AFE1415005AC0C1 /* SentryBuildAppStartSpans.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryBuildAppStartSpans.h; path = include/SentryBuildAppStartSpans.h; sourceTree = "<group>"; };
10301034
620379DC2AFE1432005AC0C1 /* SentryBuildAppStartSpans.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryBuildAppStartSpans.m; sourceTree = "<group>"; };
10311035
621D9F2E2B9B0320003D94DE /* SentryCurrentDateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryCurrentDateProvider.swift; sourceTree = "<group>"; };
@@ -2085,6 +2089,7 @@
20852089
isa = PBXGroup;
20862090
children = (
20872091
62872B622BA1B86100A4FA7D /* NSLockTests.swift */,
2092+
51B15F7F2BE88D510026A2F2 /* URLSessionTaskTests.swift */,
20882093
);
20892094
path = Extensions;
20902095
sourceTree = "<group>";
@@ -3783,6 +3788,7 @@
37833788
children = (
37843789
D8F016B52B962548007B9AFB /* StringExtensions.swift */,
37853790
62872B5E2BA1B7F300A4FA7D /* NSLock.swift */,
3791+
51B15F7D2BE88A7C0026A2F2 /* URLSessionTaskExtensions.swift */,
37863792
);
37873793
path = Extensions;
37883794
sourceTree = "<group>";
@@ -4043,6 +4049,7 @@
40434049
639FCFA81EBC80CC00778193 /* SentryFrame.h in Headers */,
40444050
D8BFE37229A3782F002E73F3 /* SentryTimeToDisplayTracker.h in Headers */,
40454051
8E8C57A625EEFC43001CEEFA /* SentrySampling.h in Headers */,
4052+
8E8C57A625EEFC43001CEEFA /* SentrySampling.h in Headers */,
40464053
7B634599280EB9D100CFA05A /* SentryUIEventTrackingIntegration.h in Headers */,
40474054
63FE716D20DA4C1100CDBAE8 /* SentryCrashSysCtl.h in Headers */,
40484055
639889BB1EDED18400EA7442 /* SentrySwizzle.h in Headers */,
@@ -4387,6 +4394,7 @@
43874394
7B3B473825D6CC7E00D01640 /* SentryNSError.m in Sources */,
43884395
D8ACE3C82762187200F5A213 /* SentryNSDataTracker.m in Sources */,
43894396
7BE3C77D2446112C00A38442 /* SentryRateLimitParser.m in Sources */,
4397+
51B15F7E2BE88A7C0026A2F2 /* URLSessionTaskExtensions.swift in Sources */,
43904398
D8B088B729C9E3FF00213258 /* SentryTracerConfiguration.m in Sources */,
43914399
8ECC674A25C23A20000E2BF6 /* SentryTransactionContext.mm in Sources */,
43924400
03BCC38C27E1C01A003232C7 /* SentryTime.mm in Sources */,
@@ -4722,6 +4730,7 @@
47224730
63FE721420DA66EC00CDBAE8 /* SentryCrashMemory_Tests.m in Sources */,
47234731
62885DA729E946B100554F38 /* TestConncurrentModifications.swift in Sources */,
47244732
63FE720520DA66EC00CDBAE8 /* FileBasedTestCase.m in Sources */,
4733+
51B15F802BE88D510026A2F2 /* URLSessionTaskTests.swift in Sources */,
47254734
63EED6C32237989300E02400 /* SentryOptionsTest.m in Sources */,
47264735
7BBD18B22451804C00427C76 /* SentryRetryAfterHeaderParserTests.swift in Sources */,
47274736
7BD337E424A356180050DB6E /* SentryCrashIntegrationTests.swift in Sources */,

Sources/Sentry/Public/SentryOptions.h

+6
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ NS_SWIFT_NAME(Options)
142142
*/
143143
@property (nonatomic, assign) BOOL enableAutoSessionTracking;
144144

145+
/**
146+
* Whether to attach the top level `operationName` node of HTTP json requests to HTTP breadcrumbs
147+
* @note Default is @c NO.
148+
*/
149+
@property (nonatomic, assign) BOOL enableGraphQLOperationTracking;
150+
145151
/**
146152
* Whether to enable Watchdog Termination tracking or not.
147153
* @note This feature requires the @c SentryCrashIntegration being enabled, otherwise it would

Sources/Sentry/SentryNetworkTracker.m

+19
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
@property (nonatomic, assign) BOOL isNetworkTrackingEnabled;
4444
@property (nonatomic, assign) BOOL isNetworkBreadcrumbEnabled;
4545
@property (nonatomic, assign) BOOL isCaptureFailedRequestsEnabled;
46+
@property (nonatomic, assign) BOOL isGraphQLOperationTrackingEnabled;
4647

4748
@end
4849

@@ -62,6 +63,7 @@ - (instancetype)init
6263
_isNetworkTrackingEnabled = NO;
6364
_isNetworkBreadcrumbEnabled = NO;
6465
_isCaptureFailedRequestsEnabled = NO;
66+
_isGraphQLOperationTrackingEnabled = NO;
6567
}
6668
return self;
6769
}
@@ -87,12 +89,20 @@ - (void)enableCaptureFailedRequests
8789
}
8890
}
8991

92+
- (void)enableGraphQLOperationTracking
93+
{
94+
@synchronized(self) {
95+
_isGraphQLOperationTrackingEnabled = YES;
96+
}
97+
}
98+
9099
- (void)disable
91100
{
92101
@synchronized(self) {
93102
_isNetworkBreadcrumbEnabled = NO;
94103
_isNetworkTrackingEnabled = NO;
95104
_isCaptureFailedRequestsEnabled = NO;
105+
_isGraphQLOperationTrackingEnabled = NO;
96106
}
97107
}
98108

@@ -440,6 +450,11 @@ - (void)captureFailedRequests:(NSURLSessionTask *)sessionTask
440450
}
441451

442452
context[@"response"] = response;
453+
454+
if (self.isGraphQLOperationTrackingEnabled) {
455+
context[@"graphql_operation_name"] = [sessionTask getGraphQLOperationName];
456+
}
457+
443458
event.context = context;
444459

445460
[SentrySDK captureEvent:event];
@@ -489,6 +504,10 @@ - (void)addBreadcrumbForSessionTask:(NSURLSessionTask *)sessionTask
489504
breadcrumbData[@"status_code"] = statusCode;
490505
breadcrumbData[@"reason"] =
491506
[NSHTTPURLResponse localizedStringForStatusCode:responseStatusCode];
507+
508+
if (self.isGraphQLOperationTrackingEnabled) {
509+
breadcrumbData[@"graphql_operation_name"] = [sessionTask getGraphQLOperationName];
510+
}
492511
}
493512

494513
if (urlComponents.query != nil) {

Sources/Sentry/SentryNetworkTrackingIntegration.m

+4
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ - (BOOL)installWithOptions:(SentryOptions *)options
2929
[SentryNetworkTracker.sharedInstance enableCaptureFailedRequests];
3030
}
3131

32+
if (options.enableGraphQLOperationTracking) {
33+
[SentryNetworkTracker.sharedInstance enableGraphQLOperationTracking];
34+
}
35+
3236
if (shouldEnableNetworkTracking || options.enableNetworkBreadcrumbs
3337
|| options.enableCaptureFailedRequests) {
3438
[SentryNetworkTrackingIntegration swizzleURLSessionTask];

Sources/Sentry/SentryOptions.m

+4
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ - (instancetype)init
9494
_integrations = SentryOptions.defaultIntegrations;
9595
self.sampleRate = SENTRY_DEFAULT_SAMPLE_RATE;
9696
self.enableAutoSessionTracking = YES;
97+
self.enableGraphQLOperationTracking = NO;
9798
self.enableWatchdogTerminationTracking = YES;
9899
self.sessionTrackingIntervalMillis = [@30000 unsignedIntValue];
99100
self.attachStacktrace = YES;
@@ -353,6 +354,9 @@ - (BOOL)validateOptions:(NSDictionary<NSString *, id> *)options
353354
[self setBool:options[@"enableAutoSessionTracking"]
354355
block:^(BOOL value) { self->_enableAutoSessionTracking = value; }];
355356

357+
[self setBool:options[@"enableGraphQLOperationTracking"]
358+
block:^(BOOL value) { self->_enableGraphQLOperationTracking = value; }];
359+
356360
[self setBool:options[@"enableWatchdogTerminationTracking"]
357361
block:^(BOOL value) { self->_enableWatchdogTerminationTracking = value; }];
358362

Sources/Sentry/include/SentryNetworkTracker.h

+2
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ static NSString *const SENTRY_NETWORK_REQUEST_TRACKER_BREADCRUMB
1818
- (void)enableNetworkTracking;
1919
- (void)enableNetworkBreadcrumbs;
2020
- (void)enableCaptureFailedRequests;
21+
- (void)enableGraphQLOperationTracking;
2122
- (BOOL)isTargetMatch:(NSURL *)URL withTargets:(NSArray *)targets;
2223
- (void)disable;
2324

2425
@property (nonatomic, readonly) BOOL isNetworkTrackingEnabled;
2526
@property (nonatomic, readonly) BOOL isNetworkBreadcrumbEnabled;
2627
@property (nonatomic, readonly) BOOL isCaptureFailedRequestsEnabled;
28+
@property (nonatomic, readonly) BOOL isGraphQLOperationTrackingEnabled;
2729

2830
@end
2931

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Foundation
2+
3+
public extension URLSessionTask {
4+
5+
@objc
6+
func getGraphQLOperationName() -> String? {
7+
guard originalRequest?.value(forHTTPHeaderField: "Content-Type") == "application/json" else { return nil }
8+
guard let requestBody = originalRequest?.httpBody else { return nil }
9+
10+
let requestInfo = try? JSONDecoder().decode(GraphQLRequest.self, from: requestBody)
11+
12+
return requestInfo?.operationName
13+
}
14+
15+
}
16+
17+
private struct GraphQLRequest: Decodable {
18+
let operationName: String
19+
}

Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerIntegrationTests.swift

+14-1
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,20 @@ class SentryNetworkTrackerIntegrationTests: XCTestCase {
228228

229229
XCTAssertFalse(SentryNetworkTracker.sharedInstance.isCaptureFailedRequestsEnabled)
230230
}
231-
231+
232+
func testGraphQLOperationTrackingEnabled() {
233+
fixture.options.enableGraphQLOperationTracking = true
234+
startSDK()
235+
236+
XCTAssertTrue(SentryNetworkTracker.sharedInstance.isGraphQLOperationTrackingEnabled)
237+
}
238+
239+
func testGraphQLOperationTrackingDisabled() {
240+
startSDK()
241+
242+
XCTAssertFalse(SentryNetworkTracker.sharedInstance.isGraphQLOperationTrackingEnabled)
243+
}
244+
232245
func testGetCaptureFailedRequestsEnabled() {
233246
let expect = expectation(description: "Request completed")
234247

Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift

+44-3
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class SentryNetworkTrackerTests: XCTestCase {
1717
let dateProvider = TestCurrentDateProvider()
1818
let options: Options
1919
let scope: Scope
20-
let nsUrlRequest = NSURLRequest(url: SentryNetworkTrackerTests.fullUrl)
20+
let nsUrlRequest = NSMutableURLRequest(url: SentryNetworkTrackerTests.fullUrl)
2121
let client: TestClient!
2222
let hub: TestHub!
2323
let securityHeader = [ "X-FORWARDED-FOR": "value",
@@ -48,6 +48,7 @@ class SentryNetworkTrackerTests: XCTestCase {
4848
result.enableNetworkTracking()
4949
result.enableNetworkBreadcrumbs()
5050
result.enableCaptureFailedRequests()
51+
result.enableGraphQLOperationTracking()
5152
return result
5253
}
5354
}
@@ -337,8 +338,41 @@ class SentryNetworkTrackerTests: XCTestCase {
337338
XCTAssertEqual(breadcrumb!.data!["response_body_size"] as! Int64, DATA_BYTES_RECEIVED)
338339
XCTAssertEqual(breadcrumb!.data!["http.query"] as? String, "query=value&query2=value2")
339340
XCTAssertEqual(breadcrumb!.data!["http.fragment"] as? String, "fragment")
341+
XCTAssertNil(breadcrumb!.data!["graphql_operation_name"])
340342
}
341-
343+
344+
func testBreadcrumb_GraphQLEnabled() {
345+
let body = """
346+
{
347+
"operationName": "someOperationName",
348+
"variables":{"a": 1},
349+
"query":"query someOperationName {\\n someField\\n}\\n"
350+
}
351+
"""
352+
fixture.nsUrlRequest.httpBody = body.data(using: .utf8)
353+
fixture.nsUrlRequest.setValue("application/json", forHTTPHeaderField: "content-type")
354+
assertStatus(status: .ok, state: .completed, response: createResponse(code: 200))
355+
356+
let breadcrumbs = Dynamic(fixture.scope).breadcrumbArray as [Breadcrumb]?
357+
let breadcrumb = breadcrumbs!.first
358+
XCTAssertEqual(breadcrumb!.data!["graphql_operation_name"] as? String, "someOperationName")
359+
}
360+
361+
func testBreadcrumb_GraphQLEnabledInvalidData() {
362+
let body = """
363+
[
364+
{"message": "arrays are valid json"}
365+
]
366+
"""
367+
fixture.nsUrlRequest.httpBody = body.data(using: .utf8)
368+
fixture.nsUrlRequest.setValue("application/json", forHTTPHeaderField: "content-type")
369+
assertStatus(status: .ok, state: .completed, response: createResponse(code: 200))
370+
371+
let breadcrumbs = Dynamic(fixture.scope).breadcrumbArray as [Breadcrumb]?
372+
let breadcrumb = breadcrumbs!.first
373+
XCTAssertNil(breadcrumb!.data!["graphql_operation_name"])
374+
}
375+
342376
func testNoBreadcrumb_DisablingBreadcrumb() {
343377
assertStatus(status: .ok, state: .completed, response: createResponse(code: 200)) {
344378
$0.disable()
@@ -868,13 +902,15 @@ class SentryNetworkTrackerTests: XCTestCase {
868902
let requestType = span.data["type"] as? String
869903
let query = span.data["http.query"] as? String
870904
let fragment = span.data["http.fragment"] as? String
905+
let graphql = span.data["graphql_operation_name"] as? String
871906

872907
XCTAssertEqual(path, "https://www.domain.com/api")
873908
XCTAssertEqual(method, task.currentRequest!.httpMethod)
874909
XCTAssertEqual(requestType, "fetch")
875910
XCTAssertEqual(query, "query=value&query2=value2")
876911
XCTAssertEqual(fragment, "fragment")
877-
912+
XCTAssertNil(graphql)
913+
878914
XCTAssertEqual(span.status, status)
879915
XCTAssertNil(task.observationInfo)
880916
}
@@ -925,6 +961,11 @@ class SentryNetworkTrackerTests: XCTestCase {
925961
func createDataTask(method: String = "GET", modifyRequest: ((URLRequest) -> (URLRequest))? = nil) -> URLSessionDataTaskMock {
926962
var request = URLRequest(url: SentryNetworkTrackerTests.fullUrl)
927963
request.httpMethod = method
964+
request.httpBody = fixture.nsUrlRequest.httpBody
965+
fixture.nsUrlRequest.allHTTPHeaderFields?.forEach { key, value in
966+
request.setValue(value, forHTTPHeaderField: key)
967+
}
968+
928969
if let modifyRequest = modifyRequest {
929970
request = modifyRequest(request)
930971
}

Tests/SentryTests/SentryOptionsTest.m

+5
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ - (void)testEnableCoreDataTracking
200200
[self testBooleanField:@"enableCoreDataTracing" defaultValue:YES];
201201
}
202202

203+
- (void)testEnableGraphQLOperationTracking
204+
{
205+
[self testBooleanField:@"enableGraphQLOperationTracking" defaultValue:NO];
206+
}
207+
203208
- (void)testSendClientReports
204209
{
205210
[self testBooleanField:@"sendClientReports" defaultValue:YES];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import Foundation
2+
import Nimble
3+
@testable import Sentry
4+
import XCTest
5+
6+
final class URLSessionTaskTests: XCTestCase {
7+
8+
func testHTTPContentTypeInvalid() {
9+
let task = makeTask(
10+
headers: ["Content-Type": "image/jpeg"],
11+
body: "8J+YiQo="
12+
)
13+
14+
let operationName = task.getGraphQLOperationName()
15+
16+
expect(operationName) == nil
17+
}
18+
19+
func testHTTPBodyDataInvalid() {
20+
let task = makeTask(
21+
headers: ["Content-Type": "application/json"],
22+
body: "not json"
23+
)
24+
25+
let operationName = task.getGraphQLOperationName()
26+
27+
expect(operationName) == nil
28+
}
29+
30+
func testHTTPBodyDataMissing() {
31+
let task = makeTask(
32+
headers: ["Content-Type": "application/json"],
33+
body: nil
34+
)
35+
36+
let operationName = task.getGraphQLOperationName()
37+
38+
expect(operationName) == nil
39+
}
40+
41+
func testHTTPBodyDataValidGraphQL() {
42+
let task = makeTask(
43+
headers: ["Content-Type": "application/json"],
44+
body: """
45+
{
46+
"operationName": "MyOperation",
47+
"variables": {
48+
"id": "1234"
49+
},
50+
"query": "query MyOperation($id: ID!) { node(id: $id) { id } }"
51+
}
52+
"""
53+
)
54+
55+
let operationName = task.getGraphQLOperationName()
56+
57+
expect(operationName) == "MyOperation"
58+
}
59+
60+
}
61+
62+
private extension URLSessionTaskTests {
63+
64+
func makeTask(headers: [String: String], body: String?) -> URLSessionTask {
65+
var request = URLRequest(url: URL(string: "https://anything.com")!)
66+
request.httpBody = body?.data(using: .utf8)
67+
request.allHTTPHeaderFields = headers
68+
return URLSession(configuration: .ephemeral).dataTask(with: request)
69+
}
70+
71+
}

0 commit comments

Comments
 (0)