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
22 changes: 17 additions & 5 deletions Sources/Sentry/SentryNetworkTracker.m
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,18 @@ - (void)addBreadcrumbForSessionTask:(NSURLSessionTask *)sessionTask
breadcrumbData[@"http.fragment"] = urlComponents.fragment;
}

#if SENTRY_TARGET_REPLAY_SUPPORTED
// Check if network details was enabled for this url.
@synchronized(sessionTask) {
SentryReplayNetworkDetails *networkDetails
= objc_getAssociatedObject(sessionTask, &SentryNetworkDetailsKey);
if (networkDetails) {
// Store raw object; serialized at read time by SentrySRDefaultBreadcrumbConverter
breadcrumbData[SentryReplayNetworkDetails.replayNetworkDetailsKey] = networkDetails;
}
}
#endif // SENTRY_TARGET_REPLAY_SUPPORTED

breadcrumb.data = breadcrumbData;
[SentrySDK addBreadcrumb:breadcrumb];

Expand Down Expand Up @@ -611,8 +623,8 @@ - (void)captureResponseDetails:(NSData *)data
= objc_getAssociatedObject(task, &SentryNetworkDetailsKey);
if (!details) {
SENTRY_LOG_WARN(@"[NetworkCapture] No SentryReplayNetworkDetails found for %@ - "
@"skipping response capture",
urlString);
@"skipping response capture",
urlString);
return;
}

Expand Down Expand Up @@ -653,13 +665,13 @@ - (void)captureRequestDetails:(NSURLSessionTask *)sessionTask
if (objc_getAssociatedObject(sessionTask, &SentryNetworkDetailsKey)) {
return;
}
details =
[[SentryReplayNetworkDetails alloc] initWithMethod:request.HTTPMethod ?: @"GET"];
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.
// 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@ final class SentryRRWebOptionsEvent: SentryRRWebCustomEvent {
payload["unmaskedViewClasses"] = options.unmaskedViewClasses.map(String.init(describing:)).joined(separator: ", ")
}

payload["networkDetailHasUrls"] = options.networkDetailHasUrls

if options.networkDetailHasUrls {
payload["networkDetailAllowUrls"] = options.networkDetailAllowUrls.map { pattern in
if let regex = pattern as? NSRegularExpression {
return regex.pattern
} else {
return String(describing: pattern)
}
}
payload["networkDetailDenyUrls"] = options.networkDetailDenyUrls.map { pattern in
if let regex = pattern as? NSRegularExpression {
return regex.pattern
} else {
return String(describing: pattern)
}
}
payload["networkCaptureBodies"] = options.networkCaptureBodies
payload["networkRequestHeaders"] = options.networkRequestHeaders
payload["networkResponseHeaders"] = options.networkResponseHeaders
}

super.init(timestamp: timestamp, tag: "options", payload: payload)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,8 @@ enum NetworkBodyWarning: String {
static let maxBodySize = 150_000

/// Key used to store network details in breadcrumb data dictionary.
@objc public static let replayNetworkDetailsKey = "_networkDetails"
/// The __sentry key prefix strips this from event serialization.
@objc public static let replayNetworkDetailsKey = "__sentry_networkDetails"

// MARK: - Properties

Expand Down Expand Up @@ -333,7 +334,7 @@ enum NetworkBodyWarning: String {
// MARK: - Serialization

/// Serializes to dictionary for inclusion in breadcrumb data.
public func serialize() -> [String: Any] {
@objc public func serialize() -> [String: Any] {
var result = [String: Any]()
if let method { result["method"] = method }
if let statusCode { result["statusCode"] = statusCode }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,66 @@ import Foundation
data[newKey.snakeToCamelCase()] = value
})

// Serialize here (not when creating the breadcrumb) to give completionHandler time to
// populate response data before setState(.completed) triggers breadcrumb creation.
if let networkDetails = breadcrumb.data?[SentryReplayNetworkDetails.replayNetworkDetailsKey] as? SentryReplayNetworkDetails {
addNetworkDetails(from: networkDetails.serialize(), to: &data)
}

//We dont have end of the request in the breadcrumb.
return SentryRRWebSpanEvent(timestamp: startTimestamp, endTimestamp: timestamp, operation: "resource.http", description: description, data: data)
}

private func addNetworkDetails(from networkData: [String: Any], to data: inout [String: Any]) {
// Add top-level network metadata
if let method = networkData["method"] as? String {
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: Are network details without a method even accepted? Are all envelopes valid if just a statusCode or just a responseBodySize etc. is set? If yes, please add test cases where only individual values are available, so we can make sure that this behaviour is locked down

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.

PTAL

Based on testing, all fields are optional β€” the backend accepts partial data and the UI omits missing fields. Added unit tests to lock down the current partial-data behavior in addNetworkDetails so it's documented and doesn't regress.

data["method"] = method
}
if let statusCode = networkData["statusCode"] as? NSNumber {
data["statusCode"] = statusCode
}
if let requestBodySize = networkData["requestBodySize"] as? NSNumber {
data["requestBodySize"] = requestBodySize
}
if let responseBodySize = networkData["responseBodySize"] as? NSNumber {
data["responseBodySize"] = responseBodySize
}

// Process request and response details using shared logic
if let request = networkData["request"] as? [String: Any] {
if let requestData = processRequestOrResponseData(request), !requestData.isEmpty {
data["request"] = requestData
}
}

if let response = networkData["response"] as? [String: Any] {
if let responseData = processRequestOrResponseData(response), !responseData.isEmpty {
data["response"] = responseData
}
}
}

private func processRequestOrResponseData(_ sourceData: [String: Any]) -> [String: Any]? {
var result = [String: Any]()

if let size = sourceData["size"] as? NSNumber {
result["size"] = size
}

if let body = sourceData["body"] as? [String: Any] {
if let bodyContent = body["body"] {
result["body"] = bodyContent
}
if let warnings = body["warnings"] as? [String], !warnings.isEmpty {
result["_meta"] = ["warnings": warnings]
}
}

if let headers = sourceData["headers"] as? [String: String], !headers.isEmpty {
result["headers"] = headers
}

return result.isEmpty ? nil : result
}
}
// swiftlint:enable missing_docs
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,78 @@ class SentryNetworkTrackerTests: XCTestCase {
XCTAssertEqual(payloadData["fragment"] as? String, "fragment")
}

/// Simple case - when network details are enabled, `addBreadcrumbForSessionTask` will include
/// serialized network details in the breadcrumb data.
func testAddBreadcrumb_withNetworkDetails_shouldIncludeSerializedDetailsInBreadcrumbData() throws {
// -- Arrange --
let testUrl = URL(string: "https://api.example.com/users")!
let options = Options()
options.dsn = "https://key@sentry.io/1234"
options.sessionReplay.networkDetailAllowUrls = ["api.example.com"]
options.sessionReplay.networkResponseHeaders = ["Cache-Control"]
options.sessionReplay.networkCaptureBodies = false

let scope = Scope()
let client = TestClient(options: options)
let hub = TestHub(client: client, andScope: scope)
SentrySDKInternal.setCurrentHub(hub)
SentrySDK.setStart(with: options)

let tracker = SentryNetworkTracker.sharedInstance
tracker.enableNetworkTracking()
tracker.enableNetworkBreadcrumbs()

var request = URLRequest(url: testUrl)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let task = URLSessionDataTaskMock(request: request)

let httpResponse = try XCTUnwrap(HTTPURLResponse(
url: testUrl, statusCode: 200, httpVersion: "1.1",
headerFields: ["Content-Type": "application/json", "Cache-Control": "no-cache"]
))
task.setResponse(httpResponse)

// -- Act --
// 1. setState(.running) triggers captureRequestDetails (associates details with task).
tracker.urlSessionTask(task, setState: .running)

// 2. completionHandler fires, capturing response details on the associated object.
tracker.captureResponseDetails(
Data(), response: httpResponse, request: testUrl, task: task
)

// 3. setState(.completed) triggers addBreadcrumbForSessionTask, which serializes
// the now-complete details (request + response) into the breadcrumb.
tracker.urlSessionTask(task, setState: .completed)

// -- Assert --
let breadcrumbs = try XCTUnwrap(Dynamic(scope).breadcrumbArray as [Breadcrumb]?)
let breadcrumb = try XCTUnwrap(breadcrumbs.first)
let detailsObject = try XCTUnwrap(
breadcrumb.data?[SentryReplayNetworkDetails.replayNetworkDetailsKey] as? SentryReplayNetworkDetails
)
let networkDetails = detailsObject.serialize()

XCTAssertEqual(networkDetails["method"] as? String, "POST")
XCTAssertEqual(networkDetails["statusCode"] as? Int, 200)

// Verify request details show up
let requestDict = try XCTUnwrap(networkDetails["request"] as? [String: Any])
let requestHeaders = try XCTUnwrap(requestDict["headers"] as? [String: String])
// "Content-Type" is always extracted when present.
XCTAssertEqual(requestHeaders["Content-Type"], "application/json")

// Verify response details show up
let responseDict = try XCTUnwrap(networkDetails["response"] as? [String: Any])
let responseHeaders = try XCTUnwrap(responseDict["headers"] as? [String: String])
// "Content-Type" is always extracted when present.
XCTAssertEqual(responseHeaders["Content-Type"], "application/json")
XCTAssertEqual(responseHeaders["Cache-Control"], "no-cache")

clearTestState()
}

func testBreadcrumb_GraphQLEnabled() throws {
let body = """
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,92 @@ class SentrySRDefaultBreadcrumbConverterTests: XCTestCase {
XCTAssertEqual(payloadData["SomeInfo"] as? String, "Info")
}

// MARK: - Network Details (partial data)

/// All network detail fields are optional. The backend accepts partial data
/// and the replay UI simply omits missing fields. These tests lock down that
/// behavior so partial details are never accidentally rejected.

func testHttpBreadcrumb_withNetworkDetails_onlyStatusCode() throws {
let details = SentryReplayNetworkDetails(method: nil)
let payloadData = try convertHttpBreadcrumbWithNetworkDetails(details: details) { details in
details.setResponse(statusCode: 200, size: nil, bodyData: nil, contentType: nil, allHeaders: nil, configuredHeaders: nil)
}

XCTAssertEqual(payloadData["statusCode"] as? Int, 200)
XCTAssertNil(payloadData["method"], "method should be absent when not set")
XCTAssertNil(payloadData["requestBodySize"])
XCTAssertNil(payloadData["responseBodySize"])
}

func testHttpBreadcrumb_withNetworkDetails_onlyMethod() throws {
let payloadData = try convertHttpBreadcrumbWithNetworkDetails { _ in
// method is set via the initializer; no response/request set
}

XCTAssertEqual(payloadData["method"] as? String, "POST")
XCTAssertNil(payloadData["statusCode"], "statusCode should be absent when no response is set")
XCTAssertNil(payloadData["requestBodySize"])
XCTAssertNil(payloadData["responseBodySize"])
}

func testHttpBreadcrumb_withNetworkDetails_onlyResponseBodySize() throws {
let payloadData = try convertHttpBreadcrumbWithNetworkDetails { details in
details.setResponse(statusCode: 0, size: NSNumber(value: 512), bodyData: nil, contentType: nil, allHeaders: nil, configuredHeaders: nil)
}

XCTAssertEqual(payloadData["responseBodySize"] as? Int, 512)
XCTAssertNil(payloadData["requestBodySize"])
}

func testHttpBreadcrumb_withNetworkDetails_onlyRequestBodySize() throws {
let payloadData = try convertHttpBreadcrumbWithNetworkDetails { details in
details.setRequest(size: NSNumber(value: 256), bodyData: nil, contentType: nil, allHeaders: nil, configuredHeaders: nil)
}

XCTAssertEqual(payloadData["requestBodySize"] as? Int, 256)
XCTAssertNil(payloadData["responseBodySize"])
}

func testHttpBreadcrumb_withNetworkDetails_emptyDetails_producesNoExtraKeys() throws {
let details = SentryReplayNetworkDetails(method: nil)
let payloadData = try convertHttpBreadcrumbWithNetworkDetails(details: details) { _ in }

XCTAssertNil(payloadData["method"])
XCTAssertNil(payloadData["statusCode"])
XCTAssertNil(payloadData["requestBodySize"])
XCTAssertNil(payloadData["responseBodySize"])
XCTAssertNil(payloadData["request"])
XCTAssertNil(payloadData["response"])
}

// MARK: - Helpers

/// Creates an HTTP breadcrumb with a `SentryReplayNetworkDetails` attached,
/// runs it through the converter, and returns the payload data dictionary.
private func convertHttpBreadcrumbWithNetworkDetails(
details: SentryReplayNetworkDetails? = nil,
configure: (SentryReplayNetworkDetails) -> Void
) throws -> [String: Any] {
let sut = SentrySRDefaultBreadcrumbConverter()
let breadcrumb = Breadcrumb(level: .info, category: "http")
let start = Date(timeIntervalSince1970: 5)

let networkDetails = details ?? SentryReplayNetworkDetails(method: "POST")
configure(networkDetails)

breadcrumb.data = [
"url": "https://test.com",
"request_start": start,
SentryReplayNetworkDetails.replayNetworkDetailsKey: networkDetails
]

let result = try XCTUnwrap(sut.convert(from: breadcrumb) as? SentryRRWebSpanEvent)
let crumbData = try XCTUnwrap(result.data)
let payload = try XCTUnwrap(crumbData["payload"] as? [String: Any])
return try XCTUnwrap(payload["data"] as? [String: Any])
}

func testSerializedSRBreadcrumbLevelIsString() throws {
let sut = SentrySRDefaultBreadcrumbConverter()
let breadcrumb = Breadcrumb()
Expand Down
Loading