diff --git a/Sources/Sentry/SentryNetworkTracker.m b/Sources/Sentry/SentryNetworkTracker.m index 34f698538a9..0231866cc19 100644 --- a/Sources/Sentry/SentryNetworkTracker.m +++ b/Sources/Sentry/SentryNetworkTracker.m @@ -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]; @@ -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; } @@ -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; diff --git a/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebOptionsEvent.swift b/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebOptionsEvent.swift index 7344f535481..93d9d8e8487 100644 --- a/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebOptionsEvent.swift +++ b/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebOptionsEvent.swift @@ -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) } } diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift index 6a745ce229f..429058c3ddf 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift @@ -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 @@ -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 } diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverter.swift b/Sources/Swift/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverter.swift index e5aed47562a..c64c75c2767 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverter.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverter.swift @@ -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 { + 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 diff --git a/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift index affea7e8782..9c4c38a2c2d 100644 --- a/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift @@ -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 = """ { diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverterTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverterTests.swift index 979c32382ca..9d1242d7538 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverterTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverterTests.swift @@ -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()