Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: Please double-check if this is the correct approach to get the options. other parts of this file use SENTRY_UNWRAP_NULLABLE(SentryOptions, SentrySDKInternal.currentHub.client.options)

Copy link
Copy Markdown
Collaborator Author

@43jay 43jay Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

afaict using SentrySDK.startOption is appropriate:

  1. Majority of code that interacts with the options in SentryNetworkTracker.m does SentrySDK.startOption ref over SentrySDKInternal.currentHub.client.options ref

  2. It's the same instance in either case, just SentrySDKInternal.currentHub.client.options can be swapped out if the hub changes, but this is only used in tests.

  3. If someone calls SentrySDK.start multiple times, the SentryNetworkTracker.m would pick up the options from the most recent call => seems appropriate.

Re SENTRY_UNWRAP_NULLABLE... seems like it should be
SENTRY_UNWRAP_NULLABLE(SentryOptions, SentrySDK.startOption) in case an objC caller manages to set a nil options ... but the other call-sites omit the SENTRY_UNWRAP_NULLABLE so i omitted as well.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose fo SENTRY_UNWRAP_NULLABLE is just casting from a nullable type to-non-nullable type. We use this in cases where we already did a null check and are sure that it's not null. We use a macro because it allows us to find all cases again in the future

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

understood. don't believe it is needed here -> there's no guarantee SentryOptions won't be null, as objC callers can do so (not that they would want to)

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
Comment on lines +239 to +247
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Sentry's own backend requests are not automatically excluded from network detail capture, unlike breadcrumbs. This relies on user configuration to prevent capturing Sentry's own traffic.
Severity: MEDIUM

Suggested Fix

Add an explicit check to exclude Sentry backend URLs before calling captureRequestDetails. This would make the network detail capture logic consistent with the existing breadcrumb and span creation logic.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: Sources/Sentry/SentryNetworkTracker.m#L239-L247

Potential issue: In `SentryNetworkTracker.m`, the logic to capture network request
details does not automatically exclude requests made to Sentry's own backend. This is
inconsistent with the breadcrumb capture logic, which explicitly filters out these
requests. While the default configuration of an empty `networkDetailAllowUrls` prevents
this issue, a user configuring this option with a broad pattern could inadvertently
cause the SDK to capture details of its own network traffic, violating the principle of
"defense in depth".


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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: Please elaborate on this. We want to keep static state to a minimum, and also I do not understand how this is handled with parallel network calls

Copy link
Copy Markdown
Collaborator Author

@43jay 43jay Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added ptal

re parallel network calls -

  1. each parallel call with have its own instance of NSURLSessionTask, and this SentryNetworkDetailsKey is going to do objc_set|getAssociatedObject on that task instance => no shared state.

  2. action required the objc_set|getAssociatedObject calls are happening on the setState and completionHandler callbacks respectively.
    Your question prompted research which corrected some assumptions about queues on iOS vs threads on android => this impl is not thread safe atm:
    completionHandler is called on whatever delegateQueue the session was created with while setState is an internal API and has no documented guarantees about which thread/queue is going to run => need to add some syncrhonization

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the plan with the thread safety? Is this still an open task of yours?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i had it locally. just pushed (details here)


- (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<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
2 changes: 2 additions & 0 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 Down Expand Up @@ -177,6 +178,7 @@ + (void)swizzleDataTaskWithURLCompletionHandler:(SentryNetworkTracker *)networkT
SentrySwizzleModeOncePerClassAndSuperclasses, (void *)selector);
#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