Skip to content
107 changes: 103 additions & 4 deletions Sources/Sentry/SentryNetworkTracker.m
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,16 @@ - (void)urlSessionTask:(NSURLSessionTask *)sessionTask setState:(NSURLSessionTas
return;
}

#if SENTRY_TARGET_REPLAY_SUPPORTED
SentryOptions *options = SentrySDK.startOption;
Comment thread
43jay marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
43jay marked this conversation as resolved.
NSString *urlString = sessionTask.originalRequest.URL.absoluteString;
if ([self isNetworkDetailCaptureEnabledFor:urlString options:options]) {
[self captureRequestDetails:sessionTask
networkCaptureBodies:options.sessionReplay.networkCaptureBodies
networkRequestHeaders:options.sessionReplay.networkRequestHeaders];
}
Comment thread
43jay marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
#endif // SENTRY_TARGET_REPLAY_SUPPORTED
Comment thread
43jay marked this conversation as resolved.
Comment thread
43jay marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
43jay marked this conversation as resolved.

if (![self isTaskSupported:sessionTask]) {
return;
}
Expand Down Expand Up @@ -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;
Comment thread
43jay marked this conversation as resolved.

- (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
Comment thread
43jay marked this conversation as resolved.
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;
}
Comment thread
cursor[bot] marked this conversation as resolved.

@synchronized(task) {
SentryReplayNetworkDetails *details
= objc_getAssociatedObject(task, &SentryNetworkDetailsKey);
if (!details) {
SENTRY_LOG_WARN(@"[NetworkCapture] No SentryReplayNetworkDetails found for %@ - "
@"skipping response capture",
urlString);
return;
Comment thread
sentry[bot] marked this conversation as resolved.
}

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];
}
Comment thread
43jay marked this conversation as resolved.
}

- (void)captureRequestDetails:(NSURLSessionTask *)sessionTask
networkCaptureBodies:(BOOL)networkCaptureBodies
networkRequestHeaders:(NSArray<NSString *> *)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
14 changes: 8 additions & 6 deletions Sources/Sentry/SentrySwizzleWrapperHelper.m
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -117,8 +118,8 @@ + (void)swizzleURLSessionDataTasksForResponseCapture:(SentryNetworkTracker *)net
*/
+ (void)swizzleDataTaskWithRequestCompletionHandler:(SentryNetworkTracker *)networkTracker
{
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wshadow"
# pragma clang diagnostic push
# pragma clang diagnostic ignored "-Wshadow"
SEL selector = @selector(dataTaskWithRequest:completionHandler:);
SentrySwizzleInstanceMethod([NSURLSession class], selector,
SentrySWReturnType(NSURLSessionDataTask *),
Expand All @@ -142,16 +143,16 @@ + (void)swizzleDataTaskWithRequestCompletionHandler:(SentryNetworkTracker *)netw
return task;
}),
SentrySwizzleModeOncePerClassAndSuperclasses, (void *)selector);
#pragma clang diagnostic pop
# 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"
# pragma clang diagnostic push
# pragma clang diagnostic ignored "-Wshadow"
SEL selector = @selector(dataTaskWithURL:completionHandler:);
SentrySwizzleInstanceMethod([NSURLSession class], selector,
SentrySWReturnType(NSURLSessionDataTask *),
Expand All @@ -175,8 +176,9 @@ + (void)swizzleDataTaskWithURLCompletionHandler:(SentryNetworkTracker *)networkT
return task;
}),
SentrySwizzleModeOncePerClassAndSuperclasses, (void *)selector);
#pragma clang diagnostic pop
# pragma clang diagnostic pop
}
#endif // SENTRY_TARGET_REPLAY_SUPPORTED

@end

Expand Down
3 changes: 3 additions & 0 deletions Sources/Sentry/include/SentryNetworkTracker.h
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#import "SentryDefines.h"
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions Sources/Sentry/include/SentrySwizzleWrapperHelper.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
)
Expand Down Expand Up @@ -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"]
)
Expand Down Expand Up @@ -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",
Expand Down
Loading