Skip to content

Commit c1ab58f

Browse files
committed
feat(network-details): Add (simplified) URLSession swizzling for response capture
Add (no-op) callback into SentryNetworkTracker Remove run-time discovery for swizzle targets; directly swizzle [NSURLSession class] Add unit test to do run-time discovery and report if assumptions invalid
1 parent 4e16c52 commit c1ab58f

5 files changed

Lines changed: 247 additions & 3 deletions

File tree

Sources/Sentry/SentryNetworkTracker.m

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,4 +562,15 @@ - (SentryLevel)getBreadcrumbLevel:(NSURLSessionTask *)sessionTask
562562
return breadcrumbLevel;
563563
}
564564

565+
- (void)captureResponseDetails:(NSData *)data
566+
response:(NSURLResponse *)response
567+
requestURL:(NSURL *)requestURL
568+
task:(NSURLSessionTask *)task
569+
{
570+
// TODO: Implementation
571+
// 2. Parse response body data
572+
// 3. Store in appropriate location for session replay
573+
// 4. Handle size limits and truncation if needed
574+
}
575+
565576
@end

Sources/Sentry/SentrySwizzleWrapperHelper.m

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,87 @@ + (void)swizzleURLSessionTask:(SentryNetworkTracker *)networkTracker
9797
#pragma clang diagnostic pop
9898
}
9999

100+
/**
101+
* Swizzles NSURLSession data task creation methods that use completion handlers
102+
* to enable response body capture for session replay.
103+
*
104+
* Both dataTaskWithRequest: and dataTaskWithURL: are independent implementations
105+
* (neither calls through to the other), so both need swizzling.
106+
*
107+
* See SentryNSURLSessionTaskSearchTests that verifies these assumptions still hold.
108+
*/
109+
+ (void)swizzleURLSessionDataTasksForResponseCapture:(SentryNetworkTracker *)networkTracker
110+
{
111+
[self swizzleDataTaskWithRequestCompletionHandler:networkTracker];
112+
[self swizzleDataTaskWithURLCompletionHandler:networkTracker];
113+
}
114+
115+
/**
116+
* Swizzles -[NSURLSession dataTaskWithRequest:completionHandler:] to intercept response data.
117+
*/
118+
+ (void)swizzleDataTaskWithRequestCompletionHandler:(SentryNetworkTracker *)networkTracker
119+
{
120+
#pragma clang diagnostic push
121+
#pragma clang diagnostic ignored "-Wshadow"
122+
SEL selector = @selector(dataTaskWithRequest:completionHandler:);
123+
SentrySwizzleInstanceMethod([NSURLSession class], selector,
124+
SentrySWReturnType(NSURLSessionDataTask *),
125+
SentrySWArguments(NSURLRequest * request,
126+
void (^completionHandler)(NSData *, NSURLResponse *, NSError *)),
127+
SentrySWReplacement({
128+
__block NSURLSessionDataTask *task = nil;
129+
void (^wrappedHandler)(NSData *, NSURLResponse *, NSError *) = nil;
130+
if (completionHandler) {
131+
wrappedHandler = ^(NSData *data, NSURLResponse *response, NSError *error) {
132+
if (!error && data && task) {
133+
[networkTracker captureResponseDetails:data
134+
response:response
135+
requestURL:request.URL
136+
task:task];
137+
}
138+
completionHandler(data, response, error);
139+
};
140+
}
141+
task = SentrySWCallOriginal(request, wrappedHandler ?: completionHandler);
142+
return task;
143+
}),
144+
SentrySwizzleModeOncePerClassAndSuperclasses, (void *)selector);
145+
#pragma clang diagnostic pop
146+
}
147+
148+
/**
149+
* Swizzles -[NSURLSession dataTaskWithURL:completionHandler:] to intercept response data.
150+
*/
151+
+ (void)swizzleDataTaskWithURLCompletionHandler:(SentryNetworkTracker *)networkTracker
152+
{
153+
#pragma clang diagnostic push
154+
#pragma clang diagnostic ignored "-Wshadow"
155+
SEL selector = @selector(dataTaskWithURL:completionHandler:);
156+
SentrySwizzleInstanceMethod([NSURLSession class], selector,
157+
SentrySWReturnType(NSURLSessionDataTask *),
158+
SentrySWArguments(
159+
NSURL * url, void (^completionHandler)(NSData *, NSURLResponse *, NSError *)),
160+
SentrySWReplacement({
161+
__block NSURLSessionDataTask *task = nil;
162+
void (^wrappedHandler)(NSData *, NSURLResponse *, NSError *) = nil;
163+
if (completionHandler) {
164+
wrappedHandler = ^(NSData *data, NSURLResponse *response, NSError *error) {
165+
if (!error && data && task) {
166+
[networkTracker captureResponseDetails:data
167+
response:response
168+
requestURL:url
169+
task:task];
170+
}
171+
completionHandler(data, response, error);
172+
};
173+
}
174+
task = SentrySWCallOriginal(url, wrappedHandler ?: completionHandler);
175+
return task;
176+
}),
177+
SentrySwizzleModeOncePerClassAndSuperclasses, (void *)selector);
178+
#pragma clang diagnostic pop
179+
}
180+
100181
@end
101182

102183
NS_ASSUME_NONNULL_END

Sources/Sentry/include/SentryNetworkTracker.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ static NSString *const SENTRY_NETWORK_REQUEST_TRACKER_BREADCRUMB
2626
@property (nonatomic, readonly) BOOL isCaptureFailedRequestsEnabled;
2727
@property (nonatomic, readonly) BOOL isGraphQLOperationTrackingEnabled;
2828

29+
- (void)captureResponseDetails:(NSData *)data
30+
response:(NSURLResponse *)response
31+
requestURL:(nullable NSURL *)requestURL
32+
task:(NSURLSessionTask *)task;
33+
2934
@end
3035

3136
NS_ASSUME_NONNULL_END

Sources/Sentry/include/SentrySwizzleWrapperHelper.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ NS_ASSUME_NONNULL_BEGIN
2626

2727
+ (void)swizzleURLSessionTask:(SentryNetworkTracker *)networkTracker;
2828

29+
// Swizzle [NSURLSession dataTaskWithURL:completionHandler:]
30+
// [NSURLSession dataTaskWithRequest:completionHandler:]
31+
+ (void)swizzleURLSessionDataTasksForResponseCapture:(SentryNetworkTracker *)networkTracker;
32+
2933
@end
3034

3135
NS_ASSUME_NONNULL_END
Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,156 @@
11
import XCTest
22

3+
// We need to know whether Apple changes the NSURLSessionTask implementation.
34
class SentryNSURLSessionTaskSearchTests: XCTestCase {
45

5-
// We need to know whether Apple changes the NSURLSessionTask implementation.
6-
func test_URLSessionTask_ByIosVersion() {
6+
func test_URLSessionTask_ByIosVersion() {
77
let classes = SentryNSURLSessionTaskSearch.urlSessionTaskClassesToTrack()
8-
8+
99
XCTAssertEqual(classes.count, 1)
1010
XCTAssertTrue(classes.first === URLSessionTask.self)
1111
}
1212

13+
// MARK: - NSURLSession class hierarchy validation tests
14+
//
15+
// Based on testing, NSURLSession implements dataTaskWithRequest:completionHandler:
16+
// and dataTaskWithURL:completionHandler: directly on the base class.
17+
//
18+
// The swizzling code relies on this by swizzling [NSURLSession class] directly
19+
// rather than doing runtime discovery. These tests verify that assumption
20+
// still holds — if Apple ever moves these methods to a subclass, these tests
21+
// will fail and we'll know to update the swizzling approach.
22+
23+
func test_URLSession_isNotClassCluster_dataTaskWithRequest() {
24+
let selector = #selector(URLSession.dataTask(with:completionHandler:)
25+
as (URLSession) -> (URLRequest, @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask)
26+
assertNSURLSessionImplementsDirectly(selector: selector, selectorName: "dataTaskWithRequest:completionHandler:")
27+
}
28+
29+
func test_URLSession_isNotClassCluster_dataTaskWithURL() {
30+
let selector = #selector(URLSession.dataTask(with:completionHandler:)
31+
as (URLSession) -> (URL, @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask)
32+
assertNSURLSessionImplementsDirectly(selector: selector, selectorName: "dataTaskWithURL:completionHandler:")
33+
}
34+
35+
// MARK: - dataTaskWithURL: / dataTaskWithRequest: independence
36+
//
37+
// We swizzle both dataTaskWithRequest:completionHandler: and
38+
// dataTaskWithURL:completionHandler: because they are independent
39+
// implementations — dataTaskWithURL: does NOT dispatch to
40+
// dataTaskWithRequest: via objc_msgSend.
41+
//
42+
// If this test ever fails, Apple has changed the internal dispatch so
43+
// one calls through to the other. In that case, remove the redundant
44+
// swizzle and add a deduplication guard to avoid double-capture.
45+
46+
func test_dataTaskWithURL_doesNotCallThrough_dataTaskWithRequest() {
47+
assertNoCallThrough(
48+
from: NSSelectorFromString("dataTaskWithURL:completionHandler:"),
49+
to: NSSelectorFromString("dataTaskWithRequest:completionHandler:"),
50+
call: { session in
51+
let url = URL(string: "https://example.com")!
52+
let task = session.dataTask(with: url) { _, _, _ in }
53+
task.cancel()
54+
}
55+
)
56+
}
57+
58+
func test_dataTaskWithRequest_doesNotCallThrough_dataTaskWithURL() {
59+
assertNoCallThrough(
60+
from: NSSelectorFromString("dataTaskWithRequest:completionHandler:"),
61+
to: NSSelectorFromString("dataTaskWithURL:completionHandler:"),
62+
call: { session in
63+
let request = URLRequest(url: URL(string: "https://example.com")!)
64+
let task = session.dataTask(with: request) { _, _, _ in }
65+
task.cancel()
66+
}
67+
)
68+
}
69+
70+
/// Temporarily replaces the IMP of `targetSelector` with one that increments
71+
/// a counter, then invokes `call` (which should trigger `sourceSelector`).
72+
/// Asserts the counter stays at 0 — meaning `sourceSelector` does not
73+
/// internally dispatch to `targetSelector` via objc_msgSend.
74+
private func assertNoCallThrough(
75+
from sourceSelector: Selector,
76+
to targetSelector: Selector,
77+
call: (URLSession) -> Void
78+
) {
79+
guard let method = class_getInstanceMethod(URLSession.self, targetSelector) else {
80+
XCTFail("URLSession should implement \(targetSelector)")
81+
return
82+
}
83+
84+
let originalIMP = method_getImplementation(method)
85+
defer { method_setImplementation(method, originalIMP) }
86+
87+
var hitCount = 0
88+
89+
let replacementBlock: @convention(block) (NSObject, AnyObject, Any?) -> AnyObject = { obj, arg, handler in
90+
hitCount += 1
91+
typealias Fn = @convention(c) (NSObject, Selector, AnyObject, Any?) -> AnyObject
92+
let original = unsafeBitCast(originalIMP, to: Fn.self)
93+
return original(obj, targetSelector, arg, handler)
94+
}
95+
96+
method_setImplementation(method, imp_implementationWithBlock(replacementBlock))
97+
98+
let session = URLSession(configuration: .ephemeral)
99+
defer { session.invalidateAndCancel() }
100+
101+
call(session)
102+
103+
XCTAssertEqual(
104+
hitCount, 0,
105+
"\(sourceSelector) called through to \(targetSelector). "
106+
+ "These methods are no longer independent — remove the redundant swizzle "
107+
+ "in SentrySwizzleWrapperHelper and add a deduplication guard."
108+
)
109+
}
110+
111+
// MARK: - Helper
112+
113+
/// Walks the class hierarchy for sessions created with default and ephemeral
114+
/// configurations and asserts that no subclass overrides `selector`.
115+
private func assertNSURLSessionImplementsDirectly(selector: Selector, selectorName: String) {
116+
let baseClass: AnyClass = URLSession.self
117+
118+
// The base class must implement the method.
119+
XCTAssertNotNil(
120+
class_getInstanceMethod(baseClass, selector),
121+
"URLSession should implement \(selectorName)"
122+
)
123+
124+
// Check sessions created with each relevant configuration.
125+
let configs: [URLSessionConfiguration] = [
126+
.default,
127+
.ephemeral
128+
]
129+
130+
for config in configs {
131+
let session = URLSession(configuration: config)
132+
let sessionClass: AnyClass = type(of: session)
133+
134+
defer { session.invalidateAndCancel() }
135+
136+
if sessionClass === baseClass {
137+
continue
138+
}
139+
140+
// If Apple returns a subclass, it must NOT provide its own
141+
// implementation — it should inherit from URLSession.
142+
let subMethod = class_getInstanceMethod(sessionClass, selector)
143+
let baseMethod = class_getInstanceMethod(baseClass, selector)
144+
145+
if let subMethod, let baseMethod {
146+
let subIMP = method_getImplementation(subMethod)
147+
let baseIMP = method_getImplementation(baseMethod)
148+
XCTAssertEqual(
149+
subIMP, baseIMP,
150+
"\(NSStringFromClass(sessionClass)) overrides \(selectorName) with an unexpected IMP — "
151+
+ "Verify swizzling in SentrySwizzleWrapperHelper is correct for dataTasks."
152+
)
153+
}
154+
}
155+
}
13156
}

0 commit comments

Comments
 (0)