Skip to content

Commit 3547546

Browse files
committed
feat(network-details): Extract headers
Casts to lower-case before comparing headers.
1 parent 58904f7 commit 3547546

File tree

4 files changed

+448
-5
lines changed

4 files changed

+448
-5
lines changed

Sources/Sentry/SentryReplayNetworkRequestOrResponse.m

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ @implementation SentryReplayNetworkRequestOrResponse
44

55
- (instancetype)initWithSize:(nullable NSNumber *)size
66
body:(nullable SentryNetworkBody *)body
7-
headers:(NSDictionary<NSString *, NSString *> *)headers
7+
allHeaders:(nullable NSDictionary<NSString *, id> *)allHeaders
8+
configuredHeaders:(nullable NSArray<NSString *> *)configuredHeaders
89
{
910
if (self = [super init]) {
1011
_size = size;
1112
_body = body;
12-
_headers = [headers copy] ?: @{};
13+
_headers = [[self class] extractHeaders:allHeaders configuredHeaders:configuredHeaders];
1314
}
1415
return self;
1516
}
@@ -31,4 +32,40 @@ - (NSDictionary *)serialize
3132
return result;
3233
}
3334

34-
@end
35+
/**
36+
* Header extraction for both request & response:
37+
* 1) Case-insensitive.
38+
* 2) Uses the key value seen in-flight (i.e. not from SentryReplayOptions#networkRequestHeaders)
39+
*/
40+
+ (NSDictionary<NSString *, NSString *> *)
41+
extractHeaders:(nullable NSDictionary<NSString *, id> *)sourceHeaders
42+
configuredHeaders:(nullable NSArray<NSString *> *)configuredHeaders
43+
{
44+
if (!sourceHeaders || !configuredHeaders) {
45+
return @{};
46+
}
47+
48+
NSMutableDictionary<NSString *, NSString *> *extractedHeaders =
49+
[NSMutableDictionary dictionary];
50+
51+
for (NSString *configuredHeader in configuredHeaders) {
52+
NSString *lowercaseConfigured = [configuredHeader lowercaseString];
53+
for (NSString *headerKey in sourceHeaders) {
54+
if ([[headerKey lowercaseString] isEqualToString:lowercaseConfigured]) {
55+
id headerValue = sourceHeaders[headerKey];
56+
NSString *stringValue = [headerValue isKindOfClass:[NSString class]]
57+
? headerValue
58+
: [headerValue description];
59+
60+
if (stringValue) {
61+
extractedHeaders[headerKey] = stringValue;
62+
}
63+
break;
64+
}
65+
}
66+
}
67+
68+
return [extractedHeaders copy];
69+
}
70+
71+
@end

Sources/Sentry/include/SentryReplayNetworkRequestOrResponse.h

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,38 @@ NS_ASSUME_NONNULL_BEGIN
1818
/** HTTP headers (non-null) */
1919
@property (nonatomic, copy, readonly) NSDictionary<NSString *, NSString *> *headers;
2020

21+
/**
22+
* Initializes with size, body, and filtered headers.
23+
*
24+
* @param size Content size in bytes (nullable)
25+
* @param body Body content (nullable)
26+
* @param allHeaders All available headers from the request/response (nullable)
27+
* @param configuredHeaders Array of header names to extract using case-insensitive matching
28+
* (nullable) If nil or empty, no headers will be extracted
29+
*/
2130
- (instancetype)initWithSize:(nullable NSNumber *)size
2231
body:(nullable SentryNetworkBody *)body
23-
headers:(NSDictionary<NSString *, NSString *> *)headers
32+
allHeaders:(nullable NSDictionary<NSString *, id> *)allHeaders
33+
configuredHeaders:(nullable NSArray<NSString *> *)configuredHeaders
2434
NS_DESIGNATED_INITIALIZER;
2535

2636
- (instancetype)init NS_UNAVAILABLE;
2737

38+
/**
39+
* Extracts headers from a source dictionary using case-insensitive matching.
40+
* This method is exposed for unit testing purposes.
41+
*
42+
* @param sourceHeaders All available headers (e.g., from NSURLRequest or NSHTTPURLResponse)
43+
* @param configuredHeaders Array of header names to extract (case-insensitive matching)
44+
* @return Dictionary containing matched headers with original casing preserved
45+
*/
46+
+ (NSDictionary<NSString *, NSString *> *)
47+
extractHeaders:(nullable NSDictionary<NSString *, id> *)sourceHeaders
48+
configuredHeaders:(nullable NSArray<NSString *> *)configuredHeaders;
49+
2850
/** Serializes to dictionary for inclusion in breadcrumb data. */
2951
- (NSDictionary *)serialize;
3052

3153
@end
3254

33-
NS_ASSUME_NONNULL_END
55+
NS_ASSUME_NONNULL_END
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
@testable import Sentry
2+
import XCTest
3+
4+
class SentryNetworkRequestDataTests: XCTestCase {
5+
6+
// MARK: - Initialization Tests
7+
8+
func testInit_withMethod_shouldSetMethod() {
9+
// -- Arrange --
10+
let method = "POST"
11+
12+
// -- Act --
13+
let requestData = SentryNetworkRequestData(method: method)
14+
15+
// -- Assert --
16+
XCTAssertEqual(requestData.method, method)
17+
XCTAssertNil(requestData.statusCode)
18+
XCTAssertNil(requestData.requestBodySize)
19+
XCTAssertNil(requestData.responseBodySize)
20+
XCTAssertNil(requestData.request)
21+
XCTAssertNil(requestData.response)
22+
}
23+
24+
// MARK: - Serialization Tests
25+
26+
func testSerialize_withFullData_shouldReturnCompleteDictionary() {
27+
// -- Arrange --
28+
let requestData = SentryNetworkRequestData(method: "PUT")
29+
requestData.setRequestDetails(createRequestOrResponse(
30+
size: 100,
31+
body: createRequestBody(content: ["name": "test"]),
32+
allHeaders: ["Content-Type": "application/json", "Authorization": "Bearer token", "Accept": "*/*"],
33+
configuredHeaders: ["Content-Type", "Authorization"]
34+
))
35+
requestData.setResponseDetails(201, responseData: createRequestOrResponse(
36+
size: 150,
37+
body: createRequestBody(content: ["id": 123, "name": "test"]),
38+
allHeaders: ["Content-Type": "application/json", "Cache-Control": "no-cache", "Set-Cookie": "session=123"],
39+
configuredHeaders: ["Content-Type", "Cache-Control"]
40+
))
41+
42+
// -- Act --
43+
let result = requestData.serialize()
44+
45+
// -- Assert --
46+
let expectedJSON = """
47+
{
48+
"method": "PUT",
49+
"statusCode": 201,
50+
"requestBodySize": 100,
51+
"responseBodySize": 150,
52+
"request": {
53+
"size": 100,
54+
"headers": {
55+
"Authorization": "Bearer token",
56+
"Content-Type": "application/json"
57+
},
58+
"body": {
59+
"body": {
60+
"name": "test"
61+
}
62+
}
63+
},
64+
"response": {
65+
"size": 150,
66+
"headers": {
67+
"Cache-Control": "no-cache",
68+
"Content-Type": "application/json"
69+
},
70+
"body": {
71+
"body": {
72+
"id": 123,
73+
"name": "test"
74+
}
75+
}
76+
}
77+
}
78+
"""
79+
80+
assertJSONEqual(result, expectedJSON: expectedJSON)
81+
}
82+
83+
func testSerialize_withPartialData_shouldOnlyIncludeSetFields() {
84+
// -- Arrange --
85+
let requestData = SentryNetworkRequestData(method: "GET")
86+
let responseDetails = createRequestOrResponse(
87+
allHeaders: ["Cache-Control": "no-cache", "Content-Type": "text/plain", "X-Custom": "value"],
88+
configuredHeaders: ["Cache-Control", "Content-Type"]
89+
)
90+
requestData.setResponseDetails(404, responseData: responseDetails)
91+
92+
// -- Act --
93+
let result = requestData.serialize()
94+
95+
// -- Assert --
96+
let expectedJSON = """
97+
{
98+
"method": "GET",
99+
"statusCode": 404,
100+
"response": {
101+
"headers": {
102+
"Cache-Control": "no-cache",
103+
"Content-Type": "text/plain"
104+
}
105+
}
106+
}
107+
"""
108+
109+
assertJSONEqual(result, expectedJSON: expectedJSON)
110+
}
111+
112+
func testSerialize_withLargeBody_shouldHandleTruncation() {
113+
// -- Arrange --
114+
// Create a large JSON payload that exceeds the 150KB limit
115+
let largeContentSize = 155 * 1024 // 155KB - exceeds the 150KB limit
116+
let largeString = String(repeating: "a", count: largeContentSize)
117+
let largeContent = ["largeData": largeString]
118+
119+
let requestData = SentryNetworkRequestData(method: "POST")
120+
requestData.setRequestDetails(createRequestOrResponse(
121+
size: largeContentSize,
122+
body: createRequestBody(content: largeContent),
123+
allHeaders: ["Content-Type": "application/json", "Content-Length": "\(largeContentSize)"],
124+
configuredHeaders: ["Content-Type", "Content-Length"]
125+
))
126+
requestData.setResponseDetails(200, responseData: createRequestOrResponse(
127+
size: 100,
128+
body: createRequestBody(content: ["status": "ok"]),
129+
allHeaders: ["Content-Type": "application/json"],
130+
configuredHeaders: ["Content-Type"]
131+
))
132+
133+
// -- Act --
134+
let result = requestData.serialize()
135+
136+
// -- Assert --
137+
let expectedFields = [
138+
"method": "POST",
139+
"statusCode": 200,
140+
"requestBodySize": largeContentSize,
141+
"responseBodySize": 100
142+
] as [String: Any]
143+
144+
// Check basic fields
145+
XCTAssertEqual(result["method"] as? String, expectedFields["method"] as? String)
146+
XCTAssertEqual(result["statusCode"] as? Int, expectedFields["statusCode"] as? Int)
147+
XCTAssertEqual(result["requestBodySize"] as? Int, expectedFields["requestBodySize"] as? Int)
148+
XCTAssertEqual(result["responseBodySize"] as? Int, expectedFields["responseBodySize"] as? Int)
149+
150+
if let response = result["response"] as? [AnyHashable: Any],
151+
let body = response["body"] as? [AnyHashable: Any] {
152+
XCTAssertNil(body["warnings"], "Response should not have warnings")
153+
if let bodyContent = body["body"] as? [String: Any] {
154+
XCTAssertEqual(bodyContent["status"] as? String, "ok")
155+
}
156+
}
157+
158+
// Verify request has truncation warning
159+
if let request = result["request"] as? [AnyHashable: Any],
160+
let body = request["body"] as? [AnyHashable: Any],
161+
let warnings = body["warnings"] as? [String] {
162+
XCTAssertTrue(warnings.contains("MAYBE_JSON_TRUNCATED"), "Request should have JSON truncation warning")
163+
} else {
164+
XCTFail("Request should have body with truncation warnings")
165+
}
166+
}
167+
168+
// MARK: - Test Helpers
169+
170+
private func createRequestBody(content: Any, contentType: String = "application/json") -> SentryNetworkBody? {
171+
do {
172+
let data = try JSONSerialization.data(withJSONObject: content)
173+
return SentryNetworkBody(data: data, contentType: contentType)
174+
} catch {
175+
XCTFail("Failed to create JSON data: \(error)")
176+
return nil
177+
}
178+
}
179+
180+
private func createRequestOrResponse(
181+
size: Int? = nil,
182+
body: SentryNetworkBody? = nil,
183+
allHeaders: [String: String],
184+
configuredHeaders: [String]
185+
) -> SentryReplayNetworkRequestOrResponse {
186+
return SentryReplayNetworkRequestOrResponse(
187+
size: size.map { NSNumber(value: $0) },
188+
body: body,
189+
allHeaders: allHeaders,
190+
configuredHeaders: configuredHeaders
191+
)
192+
}
193+
194+
private func assertJSONEqual(_ result: [AnyHashable: Any], expectedJSON: String) {
195+
guard let expectedData = expectedJSON.data(using: .utf8) else {
196+
return XCTFail("Failed to convert expected JSON string to data")
197+
}
198+
199+
do {
200+
let expectedDict = try JSONSerialization.jsonObject(with: expectedData, options: []) as? NSDictionary
201+
let actualDict = result as NSDictionary
202+
XCTAssertEqual(actualDict, expectedDict)
203+
} catch {
204+
XCTFail("Failed to parse expected JSON: \(error)")
205+
}
206+
}
207+
}

0 commit comments

Comments
 (0)