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..8d486e2314 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/Sources/Swift/Integrations/Performance/Network/SentryNetworkTrackingIntegration.swift b/Sources/Swift/Integrations/Performance/Network/SentryNetworkTrackingIntegration.swift index d36e339eec..c435bbb80b 100644 --- a/Sources/Swift/Integrations/Performance/Network/SentryNetworkTrackingIntegration.swift +++ b/Sources/Swift/Integrations/Performance/Network/SentryNetworkTrackingIntegration.swift @@ -41,6 +41,12 @@ final class SentryNetworkTrackingIntegration (URLRequest, @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask) + assertNSURLSessionImplementsDirectly(selector: selector, selectorName: "dataTaskWithRequest:completionHandler:") + } + + 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: - 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." + ) + } + } + } } 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",