From eeba0255a08c5f98056d77694cf7471cf4b7b784 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Wed, 25 Feb 2026 16:18:17 -0400 Subject: [PATCH 1/6] 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 --- Sources/Sentry/SentryNetworkTracker.m | 11 ++ Sources/Sentry/SentrySwizzleWrapperHelper.m | 81 ++++++++++ Sources/Sentry/include/SentryNetworkTracker.h | 5 + .../include/SentrySwizzleWrapperHelper.h | 4 + .../SentryNSURLSessionTaskSearchTests.swift | 149 +++++++++++++++++- 5 files changed, 247 insertions(+), 3 deletions(-) diff --git a/Sources/Sentry/SentryNetworkTracker.m b/Sources/Sentry/SentryNetworkTracker.m index 274c9c1333..943d142851 100644 --- a/Sources/Sentry/SentryNetworkTracker.m +++ b/Sources/Sentry/SentryNetworkTracker.m @@ -562,4 +562,15 @@ - (SentryLevel)getBreadcrumbLevel:(NSURLSessionTask *)sessionTask return breadcrumbLevel; } +- (void)captureResponseDetails:(NSData *)data + response:(NSURLResponse *)response + requestURL:(NSURL *)requestURL + task:(NSURLSessionTask *)task +{ + // TODO: Implementation + // 2. Parse response body data + // 3. Store in appropriate location for session replay + // 4. Handle size limits and truncation if needed +} + @end diff --git a/Sources/Sentry/SentrySwizzleWrapperHelper.m b/Sources/Sentry/SentrySwizzleWrapperHelper.m index fb7188ab85..5ff8960dca 100644 --- a/Sources/Sentry/SentrySwizzleWrapperHelper.m +++ b/Sources/Sentry/SentrySwizzleWrapperHelper.m @@ -97,6 +97,87 @@ + (void)swizzleURLSessionTask:(SentryNetworkTracker *)networkTracker #pragma clang diagnostic pop } +/** + * Swizzles NSURLSession data task creation methods that use completion handlers + * to enable response body capture for session replay. + * + * Both dataTaskWithRequest: and dataTaskWithURL: are independent implementations + * (neither calls through to the other), so both need swizzling. + * + * See SentryNSURLSessionTaskSearchTests that verifies these assumptions still hold. + */ ++ (void)swizzleURLSessionDataTasksForResponseCapture:(SentryNetworkTracker *)networkTracker +{ + [self swizzleDataTaskWithRequestCompletionHandler:networkTracker]; + [self swizzleDataTaskWithURLCompletionHandler:networkTracker]; +} + +/** + * Swizzles -[NSURLSession dataTaskWithRequest:completionHandler:] to intercept response data. + */ ++ (void)swizzleDataTaskWithRequestCompletionHandler:(SentryNetworkTracker *)networkTracker +{ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wshadow" + SEL selector = @selector(dataTaskWithRequest:completionHandler:); + SentrySwizzleInstanceMethod([NSURLSession class], selector, + SentrySWReturnType(NSURLSessionDataTask *), + SentrySWArguments(NSURLRequest * request, + void (^completionHandler)(NSData *, NSURLResponse *, NSError *)), + SentrySWReplacement({ + __block NSURLSessionDataTask *task = nil; + void (^wrappedHandler)(NSData *, NSURLResponse *, NSError *) = nil; + if (completionHandler) { + wrappedHandler = ^(NSData *data, NSURLResponse *response, NSError *error) { + if (!error && data && task) { + [networkTracker captureResponseDetails:data + response:response + requestURL:request.URL + task:task]; + } + completionHandler(data, response, error); + }; + } + task = SentrySWCallOriginal(request, wrappedHandler ?: completionHandler); + return task; + }), + SentrySwizzleModeOncePerClassAndSuperclasses, (void *)selector); +#pragma clang diagnostic pop +} + +/** + * Swizzles -[NSURLSession dataTaskWithURL:completionHandler:] to intercept response data. + */ ++ (void)swizzleDataTaskWithURLCompletionHandler:(SentryNetworkTracker *)networkTracker +{ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wshadow" + SEL selector = @selector(dataTaskWithURL:completionHandler:); + SentrySwizzleInstanceMethod([NSURLSession class], selector, + SentrySWReturnType(NSURLSessionDataTask *), + SentrySWArguments( + NSURL * url, void (^completionHandler)(NSData *, NSURLResponse *, NSError *)), + SentrySWReplacement({ + __block NSURLSessionDataTask *task = nil; + void (^wrappedHandler)(NSData *, NSURLResponse *, NSError *) = nil; + if (completionHandler) { + wrappedHandler = ^(NSData *data, NSURLResponse *response, NSError *error) { + if (!error && data && task) { + [networkTracker captureResponseDetails:data + response:response + requestURL:url + task:task]; + } + completionHandler(data, response, error); + }; + } + task = SentrySWCallOriginal(url, wrappedHandler ?: completionHandler); + return task; + }), + SentrySwizzleModeOncePerClassAndSuperclasses, (void *)selector); +#pragma clang diagnostic pop +} + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryNetworkTracker.h b/Sources/Sentry/include/SentryNetworkTracker.h index dfc211d085..07a24b4328 100644 --- a/Sources/Sentry/include/SentryNetworkTracker.h +++ b/Sources/Sentry/include/SentryNetworkTracker.h @@ -26,6 +26,11 @@ static NSString *const SENTRY_NETWORK_REQUEST_TRACKER_BREADCRUMB @property (nonatomic, readonly) BOOL isCaptureFailedRequestsEnabled; @property (nonatomic, readonly) BOOL isGraphQLOperationTrackingEnabled; +- (void)captureResponseDetails:(NSData *)data + response:(NSURLResponse *)response + requestURL:(nullable NSURL *)requestURL + task:(NSURLSessionTask *)task; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentrySwizzleWrapperHelper.h b/Sources/Sentry/include/SentrySwizzleWrapperHelper.h index baeb89449a..9720e2f91a 100644 --- a/Sources/Sentry/include/SentrySwizzleWrapperHelper.h +++ b/Sources/Sentry/include/SentrySwizzleWrapperHelper.h @@ -26,6 +26,10 @@ NS_ASSUME_NONNULL_BEGIN + (void)swizzleURLSessionTask:(SentryNetworkTracker *)networkTracker; +// Swizzle [NSURLSession dataTaskWithURL:completionHandler:] +// [NSURLSession dataTaskWithRequest:completionHandler:] ++ (void)swizzleURLSessionDataTasksForResponseCapture:(SentryNetworkTracker *)networkTracker; + @end NS_ASSUME_NONNULL_END diff --git a/Tests/SentryTests/Integrations/Performance/Network/SentryNSURLSessionTaskSearchTests.swift b/Tests/SentryTests/Integrations/Performance/Network/SentryNSURLSessionTaskSearchTests.swift index 0ea354fc29..34ad077328 100644 --- a/Tests/SentryTests/Integrations/Performance/Network/SentryNSURLSessionTaskSearchTests.swift +++ b/Tests/SentryTests/Integrations/Performance/Network/SentryNSURLSessionTaskSearchTests.swift @@ -1,13 +1,156 @@ import XCTest +// We need to know whether Apple changes the NSURLSessionTask implementation. class SentryNSURLSessionTaskSearchTests: XCTestCase { - // We need to know whether Apple changes the NSURLSessionTask implementation. - func test_URLSessionTask_ByIosVersion() { + func test_URLSessionTask_ByIosVersion() { let classes = SentryNSURLSessionTaskSearch.urlSessionTaskClassesToTrack() - + XCTAssertEqual(classes.count, 1) XCTAssertTrue(classes.first === URLSessionTask.self) } + // MARK: - NSURLSession class hierarchy validation tests + // + // Based on testing, NSURLSession implements dataTaskWithRequest:completionHandler: + // and dataTaskWithURL:completionHandler: directly on the base class. + // + // The swizzling code relies on this by swizzling [NSURLSession class] directly + // rather than doing runtime discovery. These tests verify that assumption + // still holds — if Apple ever moves these methods to a subclass, these tests + // will fail and we'll know to update the swizzling approach. + + func test_URLSession_isNotClassCluster_dataTaskWithRequest() { + let selector = #selector(URLSession.dataTask(with:completionHandler:) + as (URLSession) -> (URLRequest, @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask) + assertNSURLSessionImplementsDirectly(selector: selector, selectorName: "dataTaskWithRequest:completionHandler:") + } + + func test_URLSession_isNotClassCluster_dataTaskWithURL() { + let selector = #selector(URLSession.dataTask(with:completionHandler:) + as (URLSession) -> (URL, @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask) + assertNSURLSessionImplementsDirectly(selector: selector, selectorName: "dataTaskWithURL:completionHandler:") + } + + // MARK: - dataTaskWithURL: / dataTaskWithRequest: independence + // + // We swizzle both dataTaskWithRequest:completionHandler: and + // dataTaskWithURL:completionHandler: because they are independent + // implementations — dataTaskWithURL: does NOT dispatch to + // dataTaskWithRequest: via objc_msgSend. + // + // If this test ever fails, Apple has changed the internal dispatch so + // one calls through to the other. In that case, remove the redundant + // swizzle and add a deduplication guard to avoid double-capture. + + func test_dataTaskWithURL_doesNotCallThrough_dataTaskWithRequest() { + assertNoCallThrough( + from: NSSelectorFromString("dataTaskWithURL:completionHandler:"), + to: NSSelectorFromString("dataTaskWithRequest:completionHandler:"), + call: { session in + let url = URL(string: "https://example.com")! + let task = session.dataTask(with: url) { _, _, _ in } + task.cancel() + } + ) + } + + func test_dataTaskWithRequest_doesNotCallThrough_dataTaskWithURL() { + assertNoCallThrough( + from: NSSelectorFromString("dataTaskWithRequest:completionHandler:"), + to: NSSelectorFromString("dataTaskWithURL:completionHandler:"), + call: { session in + let request = URLRequest(url: URL(string: "https://example.com")!) + let task = session.dataTask(with: request) { _, _, _ in } + task.cancel() + } + ) + } + + /// Temporarily replaces the IMP of `targetSelector` with one that increments + /// a counter, then invokes `call` (which should trigger `sourceSelector`). + /// Asserts the counter stays at 0 — meaning `sourceSelector` does not + /// internally dispatch to `targetSelector` via objc_msgSend. + private func assertNoCallThrough( + from sourceSelector: Selector, + to targetSelector: Selector, + call: (URLSession) -> Void + ) { + guard let method = class_getInstanceMethod(URLSession.self, targetSelector) else { + XCTFail("URLSession should implement \(targetSelector)") + return + } + + let originalIMP = method_getImplementation(method) + defer { method_setImplementation(method, originalIMP) } + + var hitCount = 0 + + let replacementBlock: @convention(block) (NSObject, AnyObject, Any?) -> AnyObject = { obj, arg, handler in + hitCount += 1 + typealias Fn = @convention(c) (NSObject, Selector, AnyObject, Any?) -> AnyObject + let original = unsafeBitCast(originalIMP, to: Fn.self) + return original(obj, targetSelector, arg, handler) + } + + method_setImplementation(method, imp_implementationWithBlock(replacementBlock)) + + let session = URLSession(configuration: .ephemeral) + defer { session.invalidateAndCancel() } + + call(session) + + XCTAssertEqual( + hitCount, 0, + "\(sourceSelector) called through to \(targetSelector). " + + "These methods are no longer independent — remove the redundant swizzle " + + "in SentrySwizzleWrapperHelper and add a deduplication guard." + ) + } + + // MARK: - Helper + + /// Walks the class hierarchy for sessions created with default and ephemeral + /// configurations and asserts that no subclass overrides `selector`. + private func assertNSURLSessionImplementsDirectly(selector: Selector, selectorName: String) { + let baseClass: AnyClass = URLSession.self + + // The base class must implement the method. + XCTAssertNotNil( + class_getInstanceMethod(baseClass, selector), + "URLSession should implement \(selectorName)" + ) + + // Check sessions created with each relevant configuration. + let configs: [URLSessionConfiguration] = [ + .default, + .ephemeral + ] + + for config in configs { + let session = URLSession(configuration: config) + let sessionClass: AnyClass = type(of: session) + + defer { session.invalidateAndCancel() } + + if sessionClass === baseClass { + continue + } + + // If Apple returns a subclass, it must NOT provide its own + // implementation — it should inherit from URLSession. + let subMethod = class_getInstanceMethod(sessionClass, selector) + let baseMethod = class_getInstanceMethod(baseClass, selector) + + if let subMethod, let baseMethod { + let subIMP = method_getImplementation(subMethod) + let baseIMP = method_getImplementation(baseMethod) + XCTAssertEqual( + subIMP, baseIMP, + "\(NSStringFromClass(sessionClass)) overrides \(selectorName) with an unexpected IMP — " + + "Verify swizzling in SentrySwizzleWrapperHelper is correct for dataTasks." + ) + } + } + } } From 389a27c667f2c0a264b599649f0dc516a018d5a9 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Tue, 10 Mar 2026 17:00:58 -0400 Subject: [PATCH 2/6] review-feedback: Remove redundant and possibly flakey tests https://github.com/getsentry/sentry-cocoa/pull/7584#discussion_r2911502634 test_dataTaskWithURL_doesNotCallThrough_dataTaskWithRequest test_dataTaskWithRequest_doesNotCallThrough_dataTaskWithURL were asserting that one impl does not rely on the other internally. There's no reason to believe that they do - the test was probably checking for something that would not happen. Additionally it was relying on method_setImplementation which could cause random failures in CI if not cleaned up properly => remove --- .../SentryNSURLSessionTaskSearchTests.swift | 82 +------------------ 1 file changed, 3 insertions(+), 79 deletions(-) diff --git a/Tests/SentryTests/Integrations/Performance/Network/SentryNSURLSessionTaskSearchTests.swift b/Tests/SentryTests/Integrations/Performance/Network/SentryNSURLSessionTaskSearchTests.swift index 34ad077328..ea7c035299 100644 --- a/Tests/SentryTests/Integrations/Performance/Network/SentryNSURLSessionTaskSearchTests.swift +++ b/Tests/SentryTests/Integrations/Performance/Network/SentryNSURLSessionTaskSearchTests.swift @@ -17,97 +17,21 @@ class SentryNSURLSessionTaskSearchTests: XCTestCase { // // The swizzling code relies on this by swizzling [NSURLSession class] directly // rather than doing runtime discovery. These tests verify that assumption - // still holds — if Apple ever moves these methods to a subclass, these tests + // still holds — if Apple ever moves these methods, these tests // will fail and we'll know to update the swizzling approach. - func test_URLSession_isNotClassCluster_dataTaskWithRequest() { + func test_URLSessionDataTaskWithRequest_ByIosVersion() { let selector = #selector(URLSession.dataTask(with:completionHandler:) as (URLSession) -> (URLRequest, @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask) assertNSURLSessionImplementsDirectly(selector: selector, selectorName: "dataTaskWithRequest:completionHandler:") } - func test_URLSession_isNotClassCluster_dataTaskWithURL() { + func test_URLSessionDataTaskWithURL_ByIosVersion() { let selector = #selector(URLSession.dataTask(with:completionHandler:) as (URLSession) -> (URL, @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask) assertNSURLSessionImplementsDirectly(selector: selector, selectorName: "dataTaskWithURL:completionHandler:") } - // MARK: - dataTaskWithURL: / dataTaskWithRequest: independence - // - // We swizzle both dataTaskWithRequest:completionHandler: and - // dataTaskWithURL:completionHandler: because they are independent - // implementations — dataTaskWithURL: does NOT dispatch to - // dataTaskWithRequest: via objc_msgSend. - // - // If this test ever fails, Apple has changed the internal dispatch so - // one calls through to the other. In that case, remove the redundant - // swizzle and add a deduplication guard to avoid double-capture. - - func test_dataTaskWithURL_doesNotCallThrough_dataTaskWithRequest() { - assertNoCallThrough( - from: NSSelectorFromString("dataTaskWithURL:completionHandler:"), - to: NSSelectorFromString("dataTaskWithRequest:completionHandler:"), - call: { session in - let url = URL(string: "https://example.com")! - let task = session.dataTask(with: url) { _, _, _ in } - task.cancel() - } - ) - } - - func test_dataTaskWithRequest_doesNotCallThrough_dataTaskWithURL() { - assertNoCallThrough( - from: NSSelectorFromString("dataTaskWithRequest:completionHandler:"), - to: NSSelectorFromString("dataTaskWithURL:completionHandler:"), - call: { session in - let request = URLRequest(url: URL(string: "https://example.com")!) - let task = session.dataTask(with: request) { _, _, _ in } - task.cancel() - } - ) - } - - /// Temporarily replaces the IMP of `targetSelector` with one that increments - /// a counter, then invokes `call` (which should trigger `sourceSelector`). - /// Asserts the counter stays at 0 — meaning `sourceSelector` does not - /// internally dispatch to `targetSelector` via objc_msgSend. - private func assertNoCallThrough( - from sourceSelector: Selector, - to targetSelector: Selector, - call: (URLSession) -> Void - ) { - guard let method = class_getInstanceMethod(URLSession.self, targetSelector) else { - XCTFail("URLSession should implement \(targetSelector)") - return - } - - let originalIMP = method_getImplementation(method) - defer { method_setImplementation(method, originalIMP) } - - var hitCount = 0 - - let replacementBlock: @convention(block) (NSObject, AnyObject, Any?) -> AnyObject = { obj, arg, handler in - hitCount += 1 - typealias Fn = @convention(c) (NSObject, Selector, AnyObject, Any?) -> AnyObject - let original = unsafeBitCast(originalIMP, to: Fn.self) - return original(obj, targetSelector, arg, handler) - } - - method_setImplementation(method, imp_implementationWithBlock(replacementBlock)) - - let session = URLSession(configuration: .ephemeral) - defer { session.invalidateAndCancel() } - - call(session) - - XCTAssertEqual( - hitCount, 0, - "\(sourceSelector) called through to \(targetSelector). " - + "These methods are no longer independent — remove the redundant swizzle " - + "in SentrySwizzleWrapperHelper and add a deduplication guard." - ) - } - // MARK: - Helper /// Walks the class hierarchy for sessions created with default and ephemeral From 22e2b20fdaf426597538da8c1bafc9200d28791f Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Wed, 25 Feb 2026 16:27:00 -0400 Subject: [PATCH 3/6] feat(network-details): [swizzle] Gate swizzling based on SDKOptions#networkDetailHasUrls --- .../Network/SentryNetworkTrackingIntegration.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/Swift/Integrations/Performance/Network/SentryNetworkTrackingIntegration.swift b/Sources/Swift/Integrations/Performance/Network/SentryNetworkTrackingIntegration.swift index d36e339eec..9f8108e3f7 100644 --- a/Sources/Swift/Integrations/Performance/Network/SentryNetworkTrackingIntegration.swift +++ b/Sources/Swift/Integrations/Performance/Network/SentryNetworkTrackingIntegration.swift @@ -41,6 +41,12 @@ final class SentryNetworkTrackingIntegration Date: Tue, 28 Apr 2026 16:57:23 -0400 Subject: [PATCH 4/6] fix(network-details): Gate swizzling with !SENTRY_NO_UI_FRAMEWORK DebugWithoutUIKit build was failing, now it passes \0/ --- .../Performance/Network/SentryNetworkTrackingIntegration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Swift/Integrations/Performance/Network/SentryNetworkTrackingIntegration.swift b/Sources/Swift/Integrations/Performance/Network/SentryNetworkTrackingIntegration.swift index 9f8108e3f7..c435bbb80b 100644 --- a/Sources/Swift/Integrations/Performance/Network/SentryNetworkTrackingIntegration.swift +++ b/Sources/Swift/Integrations/Performance/Network/SentryNetworkTrackingIntegration.swift @@ -42,7 +42,7 @@ final class SentryNetworkTrackingIntegration Date: Tue, 28 Apr 2026 17:38:32 -0400 Subject: [PATCH 5/6] fix(network-details): make format --- Sources/Sentry/SentrySwizzleWrapperHelper.m | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/Sentry/SentrySwizzleWrapperHelper.m b/Sources/Sentry/SentrySwizzleWrapperHelper.m index 5ff8960dca..8d486e2314 100644 --- a/Sources/Sentry/SentrySwizzleWrapperHelper.m +++ b/Sources/Sentry/SentrySwizzleWrapperHelper.m @@ -131,9 +131,9 @@ + (void)swizzleDataTaskWithRequestCompletionHandler:(SentryNetworkTracker *)netw wrappedHandler = ^(NSData *data, NSURLResponse *response, NSError *error) { if (!error && data && task) { [networkTracker captureResponseDetails:data - response:response - requestURL:request.URL - task:task]; + response:response + requestURL:request.URL + task:task]; } completionHandler(data, response, error); }; @@ -164,9 +164,9 @@ + (void)swizzleDataTaskWithURLCompletionHandler:(SentryNetworkTracker *)networkT wrappedHandler = ^(NSData *data, NSURLResponse *response, NSError *error) { if (!error && data && task) { [networkTracker captureResponseDetails:data - response:response - requestURL:url - task:task]; + response:response + requestURL:url + task:task]; } completionHandler(data, response, error); }; From dc934e5496065e7c883b8f6b8ab6929d88d916bf Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Tue, 28 Apr 2026 18:08:29 -0400 Subject: [PATCH 6/6] chore(api): Regenerate sdk_api.json Run make generate-public-api --- sdk_api.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sdk_api.json b/sdk_api.json index 7b9c2900d6..a7da0f980e 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -787,6 +787,13 @@ "name": "UIKit", "printedName": "UIKit" }, + { + "declKind": "Import", + "kind": "Import", + "moduleName": "Sentry", + "name": "UniformTypeIdentifiers", + "printedName": "UniformTypeIdentifiers" + }, { "declKind": "Import", "kind": "Import",