diff --git a/Sources/Sentry/SentryNetworkTracker.m b/Sources/Sentry/SentryNetworkTracker.m index 274c9c13333..943d1428517 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 fb7188ab85b..5ff8960dca7 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 dfc211d0859..07a24b4328d 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 baeb89449a5..9720e2f91a0 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 d36e339eec2..9f8108e3f72 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/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkDetailSwizzlingTests.swift b/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkDetailSwizzlingTests.swift new file mode 100644 index 00000000000..cfd541e7e02 --- /dev/null +++ b/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkDetailSwizzlingTests.swift @@ -0,0 +1,148 @@ +#if os(iOS) || os(tvOS) + +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +/// Integration tests that verify the completion-handler swizzling for replay's +/// network detail capture actually works end-to-end. +/// +/// Unlike the unit tests in SentryNetworkTrackerTests (which call tracker +/// methods directly), these tests start the SDK, make real HTTP requests, +/// and assert that the swizzled completion handler fires and populates +/// network details on the resulting breadcrumb. +/// +/// Uses postman-echo.com so no local test server is required. +class SentryNetworkDetailSwizzlingTests: XCTestCase { + + private let echoURL = URL(string: "https://postman-echo.com/get")! + + override func setUp() { + super.setUp() + + let options = Options() + options.dsn = TestConstants.dsnAsString(username: "SentryNetworkDetailSwizzlingTests") + options.tracesSampleRate = 1.0 + options.enableNetworkBreadcrumbs = true + options.sessionReplay.networkDetailAllowUrls = ["postman-echo.com"] + options.sessionReplay.networkCaptureBodies = true + SentrySDK.start(options: options) + } + + override func tearDown() { + super.tearDown() + clearTestState() + } + + // MARK: - Tests + + /// Verifies the swizzle of `-[NSURLSession dataTaskWithRequest:completionHandler:]` + /// captures response details into the breadcrumb. + func testDataTaskWithRequest_completionHandler_capturesNetworkDetails() throws { + let transaction = SentrySDK.startTransaction( + name: "Test", operation: "test", bindToScope: true + ) + + let expect = expectation(description: "Request completed") + expect.assertForOverFulfill = false + + let session = URLSession(configuration: .default) + let request = URLRequest(url: echoURL) + + var receivedData: Data? + var receivedResponse: URLResponse? + var receivedError: Error? + + let task = session.dataTask(with: request) { data, response, error in + receivedData = data + receivedResponse = response + receivedError = error + expect.fulfill() + } + defer { task.cancel() } + + task.resume() + wait(for: [expect], timeout: 5) + + transaction.finish() + + // Original completion handler received valid data + XCTAssertNil(receivedError, "Request should succeed") + XCTAssertNotNil(receivedData, "Should receive response data") + let httpResponse = try XCTUnwrap(receivedResponse as? HTTPURLResponse) + XCTAssertEqual(httpResponse.statusCode, 200) + + // Network details were captured via the swizzled completion handler + let breadcrumb = try lastHTTPBreadcrumb(for: echoURL) + let details = try XCTUnwrap( + breadcrumb.data?[SentryReplayNetworkDetails.replayNetworkDetailsKey] as? SentryReplayNetworkDetails, + "Swizzled completion handler should have populated network details on the breadcrumb" + ) + let serialized = details.serialize() + XCTAssertEqual(serialized["statusCode"] as? Int, 200) + XCTAssertNotNil(serialized["response"], "Response details should be captured") + } + + /// Verifies the swizzle of `-[NSURLSession dataTaskWithURL:completionHandler:]` + /// captures response details into the breadcrumb. + func testDataTaskWithURL_completionHandler_capturesNetworkDetails() throws { + let transaction = SentrySDK.startTransaction( + name: "Test", operation: "test", bindToScope: true + ) + + let expect = expectation(description: "Request completed") + expect.assertForOverFulfill = false + + let session = URLSession(configuration: .default) + + var receivedData: Data? + var receivedResponse: URLResponse? + var receivedError: Error? + + let task = session.dataTask(with: echoURL) { data, response, error in + receivedData = data + receivedResponse = response + receivedError = error + expect.fulfill() + } + defer { task.cancel() } + + task.resume() + wait(for: [expect], timeout: 5) + + transaction.finish() + + // Original completion handler received valid data + XCTAssertNil(receivedError, "Request should succeed") + XCTAssertNotNil(receivedData, "Should receive response data") + let httpResponse = try XCTUnwrap(receivedResponse as? HTTPURLResponse) + XCTAssertEqual(httpResponse.statusCode, 200) + + // Network details were captured via the swizzled completion handler + let breadcrumb = try lastHTTPBreadcrumb(for: echoURL) + let details = try XCTUnwrap( + breadcrumb.data?[SentryReplayNetworkDetails.replayNetworkDetailsKey] as? SentryReplayNetworkDetails, + "Swizzled completion handler should have populated network details on the breadcrumb" + ) + let serialized = details.serialize() + XCTAssertEqual(serialized["statusCode"] as? Int, 200) + XCTAssertNotNil(serialized["response"], "Response details should be captured") + } + + // MARK: - Helpers + + /// Finds the most recent HTTP breadcrumb whose URL matches the given URL. + private func lastHTTPBreadcrumb(for url: URL) throws -> Breadcrumb { + let scope = SentrySDKInternal.currentHub().scope + let breadcrumbs = try XCTUnwrap( + Dynamic(scope).breadcrumbArray as [Breadcrumb]?, + "Scope should contain breadcrumbs" + ) + let matching = breadcrumbs.filter { + $0.category == "http" && ($0.data?["url"] as? String)?.contains(url.host ?? "") == true + } + return try XCTUnwrap(matching.last, "Should find an HTTP breadcrumb for \(url)") + } +} + +#endif // os(iOS) || os(tvOS)