From 91955b44843bb439e16a3c9ea7747ebe7bbb79cd Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Mon, 2 Mar 2026 16:39:22 -0400 Subject: [PATCH 1/3] feat(network-details): Add data holder class for network detail capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SentryReplayNetworkDetails — a single Swift class that encapsulates network request/response data for session replay breadcrumbs. Exposes minimal @objc surface (init, setRequest, setResponse) for ObjC callers (SentryNetworkTracker), with idiomatic Swift internals: nested Body, Detail, and BodyContent types, plus a NetworkBodyWarning enum. --- .../SentryReplayNetworkDetails.swift | 155 ++++++++++++++++++ .../SessionReplay/SentryReplayOptions.swift | 10 +- 2 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift new file mode 100644 index 0000000000..691145da9b --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift @@ -0,0 +1,155 @@ +import Foundation + +/// Warning codes for network body capture issues. +enum NetworkBodyWarning: String { + case jsonTruncated = "JSON_TRUNCATED" + case textTruncated = "TEXT_TRUNCATED" + case invalidJson = "INVALID_JSON" + case bodyParseError = "BODY_PARSE_ERROR" +} + +/// Main container for network request/response tracking. +/// +/// ObjC callers (SentryNetworkTracker) create this object and populate it +/// via `setRequest`/`setResponse`. Swift callers (SentrySRDefaultBreadcrumbConverter) +/// consume it via `serialize()`. +@objc +@_spi(Private) public class SentryReplayNetworkDetails: NSObject { + + // MARK: - Nested Types (Swift-only) + + /// Typed representation of captured body content. + enum BodyContent { + /// Parsed JSON body (dictionary or array). + case json(Any) + /// Text body (plain text, HTML, XML, etc.). + case text(String) + + init(_ value: Any) { + if let string = value as? String { + self = .text(string) + } else { + self = .json(value) + } + } + + var serializedValue: Any { + switch self { + case .json(let value): return value + case .text(let string): return string + } + } + } + + /// Captured request or response body with optional parsing warnings. + struct Body { + let content: BodyContent + let warnings: [NetworkBodyWarning] + + init(content: Any, warnings: [NetworkBodyWarning] = []) { + self.content = BodyContent(content) + self.warnings = warnings + } + + func serialize() -> [String: Any] { + var result = [String: Any]() + result["body"] = content.serializedValue + if !warnings.isEmpty { + result["warnings"] = warnings.map(\.rawValue) + } + return result + } + } + + /// Captured HTTP request or response details (size, body, headers). + struct Detail { + let size: NSNumber? + let body: Body? + let headers: [String: String] + + func serialize() -> [String: Any] { + var result = [String: Any]() + if let size { result["size"] = size } + if let body { result["body"] = body.serialize() } + result["headers"] = headers + return result + } + } + + // MARK: - Properties + + /// Key used to store network details in breadcrumb data dictionary. + @objc public static let replayNetworkDetailsKey = "_networkDetails" + + private(set) var method: String? + private(set) var statusCode: NSNumber? + private(set) var request: Detail? + private(set) var response: Detail? + + /// Request body size in bytes, derived from request details. + var requestBodySize: NSNumber? { request?.size } + + /// Response body size in bytes, derived from response details. + var responseBodySize: NSNumber? { response?.size } + + // MARK: - Initialization + + /// Creates a new instance with the given HTTP method. + @objc + public init(method: String?) { + self.method = method + super.init() + } + + // MARK: - ObjC Setters + + /// Sets request details from raw components. + /// + /// - 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. + /// - headers: Filtered HTTP request headers. + @objc + public func setRequest(size: NSNumber?, body: Any?, headers: [String: String]) { + self.request = Detail( + size: size, + body: body.map { Body(content: $0) }, + headers: headers + ) + } + + /// Sets response details from raw components. + /// + /// - 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. + /// - headers: Filtered HTTP response headers. + @objc + public func setResponse(statusCode: Int, size: NSNumber?, body: Any?, headers: [String: String]) { + self.statusCode = NSNumber(value: statusCode) + self.response = Detail( + size: size, + body: body.map { Body(content: $0) }, + headers: headers + ) + } + + // MARK: - Serialization + + /// Serializes to dictionary for inclusion in breadcrumb data. + public func serialize() -> [String: Any] { + var result = [String: Any]() + if let method { result["method"] = method } + if let statusCode { result["statusCode"] = statusCode } + if let requestBodySize { result["requestBodySize"] = requestBodySize } + if let responseBodySize { result["responseBodySize"] = responseBodySize } + if let request { result["request"] = request.serialize() } + if let response { result["response"] = response.serialize() } + return result + } + + public override var description: String { + "SentryReplayNetworkDetails: \(serialize())" + } +} diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift index 82c18bda9b..4121dcb78b 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift @@ -715,17 +715,14 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions { * * - Parameter userHeaders: Headers specified by the user (can be nil) * - Parameter defaults: Default headers that must always be included - * - Returns: Array containing both user headers and default headers (with duplicates removed) + * - Returns: Array containing both user headers and default headers with duplicates removed. */ private static func mergeWithDefaultHeaders(_ userHeaders: [String]?, defaults: [String]) -> [String] { let providedHeaders = userHeaders ?? [] - // Use Set to remove duplicates, then convert back to Array - // Case-insensitive comparison to avoid duplicate headers with different casing var seenHeaders = Set() var result: [String] = [] - - // Add default headers first + for header in defaults { let lowercased = header.lowercased() if !seenHeaders.contains(lowercased) { @@ -733,8 +730,7 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions { result.append(header) } } - - // Add user-provided headers + for header in providedHeaders { let lowercased = header.lowercased() if !seenHeaders.contains(lowercased) { From a9ef79ec9b416c48862cc309e46dc75c7813b647 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Mon, 9 Mar 2026 16:19:38 -0400 Subject: [PATCH 2/3] review-comment: Don't serialize empty headers map https://github.com/getsentry/sentry-cocoa/pull/7582/changes/72a427abb077eabab042ac1650cbe26a0ac2a01a#r2907397868 --- .../Integrations/SessionReplay/SentryReplayNetworkDetails.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift index 691145da9b..e923085297 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift @@ -71,7 +71,7 @@ enum NetworkBodyWarning: String { var result = [String: Any]() if let size { result["size"] = size } if let body { result["body"] = body.serialize() } - result["headers"] = headers + if !headers.isEmpty { result["headers"] = headers } return result } } From 4a96fe9e7604d8c2cd3ec3ee4775283bcf8c9623 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Tue, 10 Mar 2026 15:14:03 -0400 Subject: [PATCH 3/3] review-comment: Link replay frontend constants for reference https://github.com/getsentry/sentry-cocoa/pull/7582/changes#r2907724349 --- .../SessionReplay/SentryReplayNetworkDetails.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift index e923085297..8751456864 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift @@ -1,6 +1,9 @@ import Foundation /// Warning codes for network body capture issues. +/// +/// Raw values must match the frontend constants so the Sentry UI renders the correct warnings. +/// - SeeAlso: https://github.com/getsentry/sentry/blob/8b79857b2eff86f4df2f3abaf1e46c74893e3781/static/app/utils/replays/replay.tsx#L5 enum NetworkBodyWarning: String { case jsonTruncated = "JSON_TRUNCATED" case textTruncated = "TEXT_TRUNCATED"