diff --git a/Sources/Sentry/SentryNetworkTracker.m b/Sources/Sentry/SentryNetworkTracker.m index 943d142851..34f698538a 100644 --- a/Sources/Sentry/SentryNetworkTracker.m +++ b/Sources/Sentry/SentryNetworkTracker.m @@ -236,6 +236,16 @@ - (void)urlSessionTask:(NSURLSessionTask *)sessionTask setState:(NSURLSessionTas return; } +#if SENTRY_TARGET_REPLAY_SUPPORTED + SentryOptions *options = SentrySDK.startOption; + NSString *urlString = sessionTask.currentRequest.URL.absoluteString; + if ([self isNetworkDetailCaptureEnabledFor:urlString options:options]) { + [self captureRequestDetails:sessionTask + networkCaptureBodies:options.sessionReplay.networkCaptureBodies + networkRequestHeaders:options.sessionReplay.networkRequestHeaders]; + } +#endif // SENTRY_TARGET_REPLAY_SUPPORTED + if (![self isTaskSupported:sessionTask]) { return; } @@ -562,15 +572,104 @@ - (SentryLevel)getBreadcrumbLevel:(NSURLSessionTask *)sessionTask return breadcrumbLevel; } +#if SENTRY_TARGET_REPLAY_SUPPORTED +// Associated object key for attaching SentryReplayNetworkDetails to each NSURLSessionTask. +// Safe: setAssociatedObject follows existing patterns in urlSessionTask:setState: +// and getAssociatedObject is called from blocks that hold a strong reference to the task. +static const void *SentryNetworkDetailsKey = &SentryNetworkDetailsKey; + +- (BOOL)isNetworkDetailCaptureEnabledFor:(NSString *)urlString options:(SentryOptions *)options +{ + if (!options) { + return NO; + } + + if (!urlString) { + return NO; + } + + if (!options.sessionReplay) { + return NO; + } + + return [options.sessionReplay isNetworkDetailCaptureEnabledFor:urlString]; +} + - (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 + NSString *urlString = requestURL.absoluteString; + SentryOptions *options = SentrySDK.startOption; + if (![self isNetworkDetailCaptureEnabledFor:urlString options:options]) { + return; + } + + @synchronized(task) { + SentryReplayNetworkDetails *details + = objc_getAssociatedObject(task, &SentryNetworkDetailsKey); + if (!details) { + SENTRY_LOG_WARN(@"[NetworkCapture] No SentryReplayNetworkDetails found for %@ - " + @"skipping response capture", + urlString); + return; + } + + NSInteger statusCode = 0; + NSDictionary *allHeaders = nil; + NSString *contentType = nil; + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + statusCode = httpResponse.statusCode; + allHeaders = httpResponse.allHeaderFields; + contentType = httpResponse.allHeaderFields[@"Content-Type"]; + } + + NSData *bodyData + = (options.sessionReplay.networkCaptureBodies && data.length > 0) ? data : nil; + + [details setResponseWithStatusCode:statusCode + size:@(data ? data.length : 0) + bodyData:bodyData + contentType:contentType + allHeaders:allHeaders + configuredHeaders:options.sessionReplay.networkResponseHeaders]; + } +} + +- (void)captureRequestDetails:(NSURLSessionTask *)sessionTask + networkCaptureBodies:(BOOL)networkCaptureBodies + networkRequestHeaders:(NSArray *)networkRequestHeaders +{ + if (!sessionTask || !sessionTask.currentRequest) { + return; + } + + NSURLRequest *request = sessionTask.currentRequest; + SentryReplayNetworkDetails *details; + + @synchronized(sessionTask) { + if (objc_getAssociatedObject(sessionTask, &SentryNetworkDetailsKey)) { + return; + } + details = + [[SentryReplayNetworkDetails alloc] initWithMethod:request.HTTPMethod ?: @"GET"]; + objc_setAssociatedObject( + sessionTask, &SentryNetworkDetailsKey, details, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + + // Prefer originalRequest.HTTPBody: currentRequest may reflect redirects, and its HTTPBody may be nil on in-flight tasks. + NSData *rawBody = sessionTask.originalRequest.HTTPBody ?: request.HTTPBody; + NSNumber *requestSize = rawBody ? [NSNumber numberWithUnsignedInteger:rawBody.length] : nil; + NSData *bodyData = networkCaptureBodies ? rawBody : nil; + + [details setRequestWithSize:requestSize + bodyData:bodyData + contentType:request.allHTTPHeaderFields[@"Content-Type"] + allHeaders:request.allHTTPHeaderFields + configuredHeaders:networkRequestHeaders]; } +#endif // SENTRY_TARGET_REPLAY_SUPPORTED @end diff --git a/Sources/Sentry/SentrySwizzleWrapperHelper.m b/Sources/Sentry/SentrySwizzleWrapperHelper.m index 5ff8960dca..79c0513710 100644 --- a/Sources/Sentry/SentrySwizzleWrapperHelper.m +++ b/Sources/Sentry/SentrySwizzleWrapperHelper.m @@ -97,6 +97,7 @@ + (void)swizzleURLSessionTask:(SentryNetworkTracker *)networkTracker #pragma clang diagnostic pop } +#if SENTRY_TARGET_REPLAY_SUPPORTED /** * Swizzles NSURLSession data task creation methods that use completion handlers * to enable response body capture for session replay. @@ -177,6 +178,7 @@ + (void)swizzleDataTaskWithURLCompletionHandler:(SentryNetworkTracker *)networkT SentrySwizzleModeOncePerClassAndSuperclasses, (void *)selector); #pragma clang diagnostic pop } +#endif // SENTRY_TARGET_REPLAY_SUPPORTED @end diff --git a/Sources/Sentry/include/SentryNetworkTracker.h b/Sources/Sentry/include/SentryNetworkTracker.h index 07a24b4328..0225cc8974 100644 --- a/Sources/Sentry/include/SentryNetworkTracker.h +++ b/Sources/Sentry/include/SentryNetworkTracker.h @@ -1,3 +1,4 @@ +#import "SentryDefines.h" #import NS_ASSUME_NONNULL_BEGIN @@ -26,10 +27,12 @@ static NSString *const SENTRY_NETWORK_REQUEST_TRACKER_BREADCRUMB @property (nonatomic, readonly) BOOL isCaptureFailedRequestsEnabled; @property (nonatomic, readonly) BOOL isGraphQLOperationTrackingEnabled; +#if SENTRY_TARGET_REPLAY_SUPPORTED - (void)captureResponseDetails:(NSData *)data response:(NSURLResponse *)response requestURL:(nullable NSURL *)requestURL task:(NSURLSessionTask *)task; +#endif // SENTRY_TARGET_REPLAY_SUPPORTED @end diff --git a/Sources/Sentry/include/SentrySwizzleWrapperHelper.h b/Sources/Sentry/include/SentrySwizzleWrapperHelper.h index 9720e2f91a..19daf1c5d1 100644 --- a/Sources/Sentry/include/SentrySwizzleWrapperHelper.h +++ b/Sources/Sentry/include/SentrySwizzleWrapperHelper.h @@ -26,9 +26,11 @@ NS_ASSUME_NONNULL_BEGIN + (void)swizzleURLSessionTask:(SentryNetworkTracker *)networkTracker; +#if SENTRY_TARGET_REPLAY_SUPPORTED // Swizzle [NSURLSession dataTaskWithURL:completionHandler:] // [NSURLSession dataTaskWithRequest:completionHandler:] + (void)swizzleURLSessionDataTasksForResponseCapture:(SentryNetworkTracker *)networkTracker; +#endif // SENTRY_TARGET_REPLAY_SUPPORTED @end diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift index b2cb06336e..6a745ce229 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift @@ -16,6 +16,11 @@ enum NetworkBodyWarning: String { /// ObjC callers (SentryNetworkTracker) create this object and populate it /// via `setRequest`/`setResponse`. Swift callers (SentrySRDefaultBreadcrumbConverter) /// consume it via `serialize()`. +/// +/// - Important: `setRequest` and `setResponse` can be called concurrently from +/// `SentryNetworkTracker` because they write to independent properties. +/// Adding shared mutable state between will require adding synchronization. + @objc @_spi(Private) public class SentryReplayNetworkDetails: NSObject { @@ -258,36 +263,44 @@ enum NetworkBodyWarning: String { // MARK: - ObjC Setters - /// Sets request details from raw components. + /// Sets request details from raw body data. + /// + /// Parses the body data based on content type (JSON, form-urlencoded, text) + /// and applies size limits and truncation warnings automatically. /// /// - Parameters: /// - size: Request body size in bytes, or nil if unknown. - /// - body: Pre-parsed body content (dictionary, array, or string), or nil if not captured. + /// - bodyData: Raw body bytes, or nil if body capture is disabled or unavailable. + /// - contentType: MIME content type for body parsing (e.g. "application/json"). /// - allHeaders: All headers from the request (e.g. from `NSURLRequest.allHTTPHeaderFields`). /// - configuredHeaders: Header names to extract, matched case-insensitively. @objc - public func setRequest(size: NSNumber?, body: Any?, allHeaders: [String: Any]?, configuredHeaders: [String]?) { + public func setRequest(size: NSNumber?, bodyData: Data?, contentType: String?, allHeaders: [String: Any]?, configuredHeaders: [String]?) { self.request = Detail( size: size, - body: body.map { Body(content: $0) }, + body: bodyData.flatMap { Body(data: $0, contentType: contentType) }, headers: SentryReplayNetworkDetails.extractHeaders(from: allHeaders, matching: configuredHeaders) ) } - /// Sets response details from raw components. + /// Sets response details from raw body data. + /// + /// Parses the body data based on content type (JSON, form-urlencoded, text) + /// and applies size limits and truncation warnings automatically. /// /// - Parameters: /// - statusCode: HTTP status code. /// - size: Response body size in bytes, or nil if unknown. - /// - body: Pre-parsed body content (dictionary, array, or string), or nil if not captured. + /// - bodyData: Raw body bytes, or nil if body capture is disabled or unavailable. + /// - contentType: MIME content type for body parsing (e.g. "application/json"). /// - allHeaders: All headers from the response (e.g. from `NSHTTPURLResponse.allHeaderFields`). /// - configuredHeaders: Header names to extract, matched case-insensitively. @objc - public func setResponse(statusCode: Int, size: NSNumber?, body: Any?, allHeaders: [String: Any]?, configuredHeaders: [String]?) { + public func setResponse(statusCode: Int, size: NSNumber?, bodyData: Data?, contentType: String?, allHeaders: [String: Any]?, configuredHeaders: [String]?) { self.statusCode = NSNumber(value: statusCode) self.response = Detail( size: size, - body: body.map { Body(content: $0) }, + body: bodyData.flatMap { Body(data: $0, contentType: contentType) }, headers: SentryReplayNetworkDetails.extractHeaders(from: allHeaders, matching: configuredHeaders) ) } diff --git a/Tests/SentryTests/Networking/SentryReplayNetworkDetailsIntegrationTests.swift b/Tests/SentryTests/Networking/SentryReplayNetworkDetailsIntegrationTests.swift index 110a0b7465..2164276a72 100644 --- a/Tests/SentryTests/Networking/SentryReplayNetworkDetailsIntegrationTests.swift +++ b/Tests/SentryTests/Networking/SentryReplayNetworkDetailsIntegrationTests.swift @@ -20,20 +20,25 @@ class SentryReplayNetworkDetailsIntegrationTests: XCTestCase { // MARK: - Serialization Tests - func testSerialize_withFullData_shouldReturnCompleteDictionary() { + func testSerialize_withFullData_shouldReturnCompleteDictionary() throws { // -- Arrange -- let details = SentryReplayNetworkDetails(method: "PUT") + let requestBodyData = try JSONSerialization.data(withJSONObject: ["name": "test"]) details.setRequest( size: 100, - body: ["name": "test"], + bodyData: requestBodyData, + contentType: "application/json", allHeaders: ["Content-Type": "application/json", "Authorization": "Bearer token", "Accept": "*/*"], configuredHeaders: ["Content-Type", "Authorization"] ) + + let responseBodyData = try JSONSerialization.data(withJSONObject: ["id": 123, "name": "test"]) details.setResponse( statusCode: 201, size: 150, - body: ["id": 123, "name": "test"], + bodyData: responseBodyData, + contentType: "application/json", allHeaders: ["Content-Type": "application/json", "Cache-Control": "no-cache", "Set-Cookie": "session=123"], configuredHeaders: ["Content-Type", "Cache-Control"] ) @@ -85,7 +90,8 @@ class SentryReplayNetworkDetailsIntegrationTests: XCTestCase { details.setResponse( statusCode: 404, size: nil, - body: nil, + bodyData: nil, + contentType: nil, allHeaders: ["Cache-Control": "no-cache", "Content-Type": "text/plain", "X-Custom": "value"], configuredHeaders: ["Cache-Control", "Content-Type"] ) @@ -115,7 +121,8 @@ class SentryReplayNetworkDetailsIntegrationTests: XCTestCase { let details = SentryReplayNetworkDetails(method: "GET") details.setRequest( size: nil, - body: nil, + bodyData: nil, + contentType: nil, allHeaders: [ "Content-Type": "application/json", "Authorization": "Bearer secret",