Skip to content

Commit 2d31c8d

Browse files
committed
feat(network-details): Add data holder class for network detail capture
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.
1 parent 327fd2e commit 2d31c8d

2 files changed

Lines changed: 158 additions & 7 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import Foundation
2+
3+
/// Warning codes for network body capture issues.
4+
enum NetworkBodyWarning: String {
5+
case jsonTruncated = "JSON_TRUNCATED"
6+
case textTruncated = "TEXT_TRUNCATED"
7+
case invalidJson = "INVALID_JSON"
8+
case bodyParseError = "BODY_PARSE_ERROR"
9+
}
10+
11+
/// Main container for network request/response tracking.
12+
///
13+
/// ObjC callers (SentryNetworkTracker) create this object and populate it
14+
/// via `setRequest`/`setResponse`. Swift callers (SentrySRDefaultBreadcrumbConverter)
15+
/// consume it via `serialize()`.
16+
@objc
17+
@_spi(Private) public class SentryReplayNetworkDetails: NSObject {
18+
19+
// MARK: - Nested Types (Swift-only)
20+
21+
/// Typed representation of captured body content.
22+
enum BodyContent {
23+
/// Parsed JSON body (dictionary or array).
24+
case json(Any)
25+
/// Text body (plain text, HTML, XML, etc.).
26+
case text(String)
27+
28+
init(_ value: Any) {
29+
if let string = value as? String {
30+
self = .text(string)
31+
} else {
32+
self = .json(value)
33+
}
34+
}
35+
36+
var serializedValue: Any {
37+
switch self {
38+
case .json(let value): return value
39+
case .text(let string): return string
40+
}
41+
}
42+
}
43+
44+
/// Captured request or response body with optional parsing warnings.
45+
struct Body {
46+
let content: BodyContent
47+
let warnings: [NetworkBodyWarning]
48+
49+
init(content: Any, warnings: [NetworkBodyWarning] = []) {
50+
self.content = BodyContent(content)
51+
self.warnings = warnings
52+
}
53+
54+
func serialize() -> [String: Any] {
55+
var result = [String: Any]()
56+
result["body"] = content.serializedValue
57+
if !warnings.isEmpty {
58+
result["warnings"] = warnings.map(\.rawValue)
59+
}
60+
return result
61+
}
62+
}
63+
64+
/// Captured HTTP request or response details (size, body, headers).
65+
struct Detail {
66+
let size: NSNumber?
67+
let body: Body?
68+
let headers: [String: String]
69+
70+
func serialize() -> [String: Any] {
71+
var result = [String: Any]()
72+
if let size { result["size"] = size }
73+
if let body { result["body"] = body.serialize() }
74+
result["headers"] = headers
75+
return result
76+
}
77+
}
78+
79+
// MARK: - Properties
80+
81+
/// Key used to store network details in breadcrumb data dictionary.
82+
@objc public static let replayNetworkDetailsKey = "_networkDetails"
83+
84+
private(set) var method: String?
85+
private(set) var statusCode: NSNumber?
86+
private(set) var request: Detail?
87+
private(set) var response: Detail?
88+
89+
/// Request body size in bytes, derived from request details.
90+
var requestBodySize: NSNumber? { request?.size }
91+
92+
/// Response body size in bytes, derived from response details.
93+
var responseBodySize: NSNumber? { response?.size }
94+
95+
// MARK: - Initialization
96+
97+
/// Creates a new instance with the given HTTP method.
98+
@objc
99+
public init(method: String?) {
100+
self.method = method
101+
super.init()
102+
}
103+
104+
// MARK: - ObjC Setters
105+
106+
/// Sets request details from raw components.
107+
///
108+
/// - Parameters:
109+
/// - size: Request body size in bytes, or nil if unknown.
110+
/// - body: Pre-parsed body content (dictionary, array, or string), or nil if not captured.
111+
/// - headers: Filtered HTTP request headers.
112+
@objc
113+
public func setRequest(size: NSNumber?, body: Any?, headers: [String: String]) {
114+
self.request = Detail(
115+
size: size,
116+
body: body.map { Body(content: $0) },
117+
headers: headers
118+
)
119+
}
120+
121+
/// Sets response details from raw components.
122+
///
123+
/// - Parameters:
124+
/// - statusCode: HTTP status code.
125+
/// - size: Response body size in bytes, or nil if unknown.
126+
/// - body: Pre-parsed body content (dictionary, array, or string), or nil if not captured.
127+
/// - headers: Filtered HTTP response headers.
128+
@objc
129+
public func setResponse(statusCode: Int, size: NSNumber?, body: Any?, headers: [String: String]) {
130+
self.statusCode = NSNumber(value: statusCode)
131+
self.response = Detail(
132+
size: size,
133+
body: body.map { Body(content: $0) },
134+
headers: headers
135+
)
136+
}
137+
138+
// MARK: - Serialization
139+
140+
/// Serializes to dictionary for inclusion in breadcrumb data.
141+
public func serialize() -> [String: Any] {
142+
var result = [String: Any]()
143+
if let method { result["method"] = method }
144+
if let statusCode { result["statusCode"] = statusCode }
145+
if let requestBodySize { result["requestBodySize"] = requestBodySize }
146+
if let responseBodySize { result["responseBodySize"] = responseBodySize }
147+
if let request { result["request"] = request.serialize() }
148+
if let response { result["response"] = response.serialize() }
149+
return result
150+
}
151+
152+
public override var description: String {
153+
"SentryReplayNetworkDetails: \(serialize())"
154+
}
155+
}

Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -719,26 +719,22 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
719719
*
720720
* - Parameter userHeaders: Headers specified by the user (can be nil)
721721
* - Parameter defaults: Default headers that must always be included
722-
* - Returns: Array containing both user headers and default headers (with duplicates removed)
722+
* - Returns: Array containing both user headers and default headers with duplicates removed.
723723
*/
724724
private static func mergeWithDefaultHeaders(_ userHeaders: [String]?, defaults: [String]) -> [String] {
725725
let providedHeaders = userHeaders ?? []
726726

727-
// Use Set to remove duplicates, then convert back to Array
728-
// Case-insensitive comparison to avoid duplicate headers with different casing
729727
var seenHeaders = Set<String>()
730728
var result: [String] = []
731-
732-
// Add default headers first
729+
733730
for header in defaults {
734731
let lowercased = header.lowercased()
735732
if !seenHeaders.contains(lowercased) {
736733
seenHeaders.insert(lowercased)
737734
result.append(header)
738735
}
739736
}
740-
741-
// Add user-provided headers
737+
742738
for header in providedHeaders {
743739
let lowercased = header.lowercased()
744740
if !seenHeaders.contains(lowercased) {

0 commit comments

Comments
 (0)