Skip to content

Commit 8e08354

Browse files
committed
feat(network-details): Extend SentryReplayOptions API
Implement SDK fields, setters and validation for network details capture: https://docs.sentry.io/platforms/javascript/session-replay/configuration/#network-details - String patterns for prefix matching (e.g., "https://api.example.com" matches subpaths) - NSRegularExpression patterns for complex regex matching - Deny list precedence over allow list - Empty string filtering to handle invalid input gracefully
1 parent f194b9c commit 8e08354

4 files changed

Lines changed: 1037 additions & 7 deletions

File tree

Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift

Lines changed: 319 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// swiftlint:disable file_length missing_docs
1+
// swiftlint:disable file_length missing_docs type_body_length
22
import Foundation
33

44
@objcMembers
@@ -25,6 +25,13 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
2525
public static let excludedViewClasses: Set<String> = []
2626
public static let includedViewClasses: Set<String> = []
2727

28+
// Network capture configuration defaults
29+
public static let networkDetailAllowUrls: [Any] = []
30+
public static let networkDetailDenyUrls: [Any] = []
31+
public static let networkCaptureBodies: Bool = true
32+
public static let networkRequestHeaders: [String] = ["Content-Type", "Content-Length", "Accept"]
33+
public static let networkResponseHeaders: [String] = ["Content-Type", "Content-Length", "Accept"]
34+
2835
// The following properties are defaults which are not configurable by the user.
2936

3037
fileprivate static let sdkInfo: [String: Any]? = nil
@@ -292,6 +299,131 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
292299
*/
293300
public var enableFastViewRendering: Bool
294301

302+
/**
303+
* A list of URL patterns to capture request and response details for during session replay.
304+
*
305+
* When non-empty, network requests with URLs matching any of these patterns will have their
306+
* headers and bodies captured for session replay.
307+
*
308+
* Supports both String and NSRegularExpression patterns (See [JavaScript SDK](https://github.com/getsentry/sentry-javascript/blob/6fb1ee139a92a6055b52b0bbf5136fa0e5a9353f/packages/core/src/utils/string.ts#L114-L119)):
309+
* - String: Uses substring contains
310+
* - NSRegularExpression: Uses full regex matching
311+
*
312+
* Default: empty array (network detail capture disabled)
313+
*
314+
* Example:
315+
* ```swift
316+
* // String patterns (substring matching)
317+
* options.sessionReplay.networkDetailAllowUrls = [
318+
* "api.example.com", // Matches any URL containing this string
319+
* "/api/v1/", // Matches any URL containing this path
320+
* "https://analytics.myapp.com" // Matches any URL containing this prefix
321+
* ]
322+
*
323+
* // NSRegularExpression patterns (full regex matching)
324+
* let apiRegex = try? NSRegularExpression(pattern: "^https://api\\.example\\.com/v[0-9]+/.*")
325+
* let imageRegex = try? NSRegularExpression(pattern: ".*\\.(jpg|jpeg|png|gif)$")
326+
*
327+
* // Mixed array of both types
328+
* options.sessionReplay.networkDetailAllowUrls = [
329+
* "api.example.com", // String: substring match
330+
* apiRegex!, // Regex: versioned API endpoints
331+
* imageRegex! // Regex: image files
332+
* ]
333+
* ```
334+
*
335+
* - Note: Request and response bodies are truncated to 150KB maximum.
336+
* - Note: See ``SentryReplayOptions.DefaultValues.networkDetailAllowUrls`` for the default value.
337+
*/
338+
public var networkDetailAllowUrls: [Any]
339+
340+
/**
341+
* A list of URL patterns to exclude from network detail capture during session replay.
342+
*
343+
* URLs matching any pattern in this array will NOT have their headers and bodies captured,
344+
* even if they match patterns in `networkDetailAllowUrls`. This provides fine-grained
345+
* control for excluding sensitive endpoints from capture.
346+
*
347+
* Supports both String and NSRegularExpression patterns (mirroring JavaScript SDK):
348+
* - String: Uses substring containment check (like JavaScript's `includes()`)
349+
* - NSRegularExpression: Uses full regex matching
350+
*
351+
* Default: empty array (no URLs explicitly denied)
352+
*
353+
* Examples:
354+
* - String patterns: "/auth/", "/payment/", "password", ".internal."
355+
* - NSRegularExpression patterns: Use try NSRegularExpression(pattern:) to create regex objects
356+
* - Mixed arrays are supported with both types
357+
*/
358+
public var networkDetailDenyUrls: [Any]
359+
360+
/**
361+
* Whether to capture request and response bodies for allowed URLs.
362+
*
363+
* When `true` (default), bodies will be captured and parsed (JSON bodies are
364+
* parsed for structured display in the Sentry UI).
365+
*
366+
* When `false`, only headers and metadata will be captured for allowed URLs.
367+
*
368+
* Default: `true`
369+
*
370+
* - Note: This setting only applies when ``networkDetailAllowUrls`` is non-empty.
371+
* - Note: Bodies are automatically truncated to 150KB to prevent excessive memory usage.
372+
*/
373+
public var networkCaptureBodies: Bool
374+
375+
/**
376+
* Request headers to capture for allowed URLs during session replay.
377+
*
378+
* Specifies which HTTP request headers should be captured and included in session replay
379+
* network details. Header matching is case-insensitive (e.g., "content-type", "Content-Type",
380+
* and "CoNtEnT-tYpE" are all equivalent).
381+
*
382+
* Default (always included): `["Content-Type", "Content-Length", "Accept"]`
383+
*
384+
* Example:
385+
* ```
386+
* options.sessionReplay.networkRequestHeaders = [
387+
* "Authorization",
388+
* "User-Agent"
389+
* ]
390+
* ```
391+
*
392+
* - Note: This setting only applies when ``networkDetailAllowUrls`` is non-empty.
393+
* - Note: Header names preserve the case seen on the request, not the case specified here.
394+
*/
395+
public var networkRequestHeaders: [String] {
396+
get { _networkRequestHeaders }
397+
set { _networkRequestHeaders = Self.mergeWithDefaultHeaders(newValue, defaults: DefaultValues.networkRequestHeaders) }
398+
}
399+
private var _networkRequestHeaders: [String]
400+
401+
/**
402+
* Response headers to capture for allowed URLs during session replay.
403+
*
404+
* Specifies which HTTP response headers should be captured and included in session replay
405+
* network details. Header matching is case-insensitive (e.g., "content-type", "Content-Type",
406+
* and "CoNtEnT-tYpE" are all equivalent).
407+
*
408+
* Default (always included): `["Content-Type", "Content-Length", "Accept"]`
409+
*
410+
* Example:
411+
* ```
412+
* options.sessionReplay.networkResponseHeaders = [
413+
* "Cache-Control", // Custom header
414+
* "Set-Cookie" // Custom header
415+
* ]
416+
* ```
417+
*
418+
* - Note: This setting only applies when ``networkDetailAllowUrls`` is non-empty.
419+
* - Note: Header names preserve the case seen on the response, not the case specified here.
420+
*/
421+
public var networkResponseHeaders: [String] {
422+
get { _networkResponseHeaders }
423+
set { _networkResponseHeaders = Self.mergeWithDefaultHeaders(newValue, defaults: DefaultValues.networkResponseHeaders) }
424+
}
425+
private var _networkResponseHeaders: [String]
426+
295427
/**
296428
* Defines the quality of the session replay.
297429
*
@@ -351,6 +483,60 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
351483
*/
352484
var sdkInfo: [String: Any]?
353485

486+
/**
487+
* Determines if network detail capture is enabled for a given URL.
488+
*
489+
* - Parameter urlString: The URL string to check
490+
* - Returns: `true` if network details should be captured for this URL, `false` otherwise
491+
*/
492+
@objc
493+
public func isNetworkDetailCaptureEnabled(for urlString: String) -> Bool {
494+
// If allow list is empty, network detail capture is disabled
495+
guard !networkDetailAllowUrls.isEmpty else {
496+
return false
497+
}
498+
499+
if matchesAnyPattern(urlString, patterns: networkDetailDenyUrls) {
500+
return false
501+
}
502+
503+
return matchesAnyPattern(urlString, patterns: networkDetailAllowUrls)
504+
}
505+
506+
/**
507+
* Helper method to check if a URL string matches any pattern in a list.
508+
*
509+
* Supports both String and NSRegularExpression patterns:
510+
* - String: Uses substring containment check (like JavaScript's includes())
511+
* - NSRegularExpression: Uses full regex matching
512+
*
513+
* - Parameters:
514+
* - urlString: The URL string to test
515+
* - patterns: Array of String or NSRegularExpression patterns
516+
* - Returns: `true` if the URL matches any pattern, `false` otherwise
517+
*/
518+
private func matchesAnyPattern(_ urlString: String, patterns: [Any]) -> Bool {
519+
for pattern in patterns {
520+
if let stringPattern = pattern as? String {
521+
// String provided: substring match
522+
// Filter out empty strings and whitespace-only strings
523+
let trimmed = stringPattern.trimmingCharacters(in: .whitespacesAndNewlines)
524+
guard !trimmed.isEmpty else { continue }
525+
526+
if urlString.contains(stringPattern) {
527+
return true
528+
}
529+
} else if let regexPattern = pattern as? NSRegularExpression {
530+
// NSRegularExpression: use regex matching
531+
let range = NSRange(location: 0, length: urlString.utf16.count)
532+
if regexPattern.firstMatch(in: urlString, options: [], range: range) != nil {
533+
return true
534+
}
535+
}
536+
}
537+
return false
538+
}
539+
354540
/**
355541
* Initialize session replay options disabled
356542
*
@@ -374,7 +560,12 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
374560
frameRate: nil,
375561
errorReplayDuration: nil,
376562
sessionSegmentDuration: nil,
377-
maximumDuration: nil
563+
maximumDuration: nil,
564+
networkDetailAllowUrls: nil,
565+
networkDetailDenyUrls: nil,
566+
networkCaptureBodies: nil,
567+
networkRequestHeaders: nil,
568+
networkResponseHeaders: nil
378569
)
379570
}
380571

@@ -409,7 +600,12 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
409600
sessionSegmentDuration: (dictionary["sessionSegmentDuration"] as? NSNumber)?.doubleValue,
410601
maximumDuration: (dictionary["maximumDuration"] as? NSNumber)?.doubleValue,
411602
excludedViewClasses: (dictionary["excludedViewClasses"] as? [String]).map { Set($0) },
412-
includedViewClasses: (dictionary["includedViewClasses"] as? [String]).map { Set($0) }
603+
includedViewClasses: (dictionary["includedViewClasses"] as? [String]).map { Set($0) },
604+
networkDetailAllowUrls: Self.validateNetworkDetailUrlPatterns(from: dictionary["networkDetailAllowUrls"]),
605+
networkDetailDenyUrls: Self.validateNetworkDetailUrlPatterns(from: dictionary["networkDetailDenyUrls"]),
606+
networkCaptureBodies: (dictionary["networkCaptureBodies"] as? NSNumber)?.boolValue,
607+
networkRequestHeaders: Self.parseStringArray(from: dictionary["networkRequestHeaders"]),
608+
networkResponseHeaders: Self.parseStringArray(from: dictionary["networkResponseHeaders"])
413609
)
414610
}
415611

@@ -456,10 +652,81 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
456652
sessionSegmentDuration: nil,
457653
maximumDuration: nil,
458654
excludedViewClasses: nil,
459-
includedViewClasses: nil
655+
includedViewClasses: nil,
656+
networkDetailAllowUrls: nil,
657+
networkDetailDenyUrls: nil,
658+
networkCaptureBodies: nil,
659+
networkRequestHeaders: nil,
660+
networkResponseHeaders: nil
460661
)
461662
}
462663

664+
/**
665+
* Helper method to parse and filter string arrays from dictionary configuration.
666+
*
667+
* Filters out non-string entries from mixed arrays while preserving valid strings.
668+
* Returns nil when the input is not an array type, allowing callers to fall back to defaults.
669+
*
670+
* - Parameter value: The value from the dictionary to parse
671+
* - Returns: Filtered array of strings, or nil if input is not an array
672+
*/
673+
private static func parseStringArray(from value: Any?) -> [String]? {
674+
guard let array = value as? [Any] else {
675+
return nil
676+
}
677+
return array.compactMap { $0 as? String }
678+
}
679+
680+
/**
681+
* Validates developer-provided NetworkDetail URL patterns and returns a subset of only valid entries.
682+
*
683+
* Accepts both String and NSRegularExpression objects.
684+
* Filters out invalid entries and preserves valid patterns.
685+
* Filters out empty strings and whitespace-only strings.
686+
*
687+
* - Parameter value: The value from the dictionary to parse
688+
* - Returns: Filtered array of String and NSRegularExpression patterns, or nil if input is not an array
689+
*/
690+
private static func validateNetworkDetailUrlPatterns(from value: Any?) -> [Any]? {
691+
guard let array = value as? [Any] else {
692+
if let nonNilValue = value {
693+
SentrySDKLog.log(message: "Invalid networkDetail URL pattern configuration: expected array, got \(type(of: nonNilValue))",
694+
andLevel: .warning)
695+
}
696+
return nil
697+
}
698+
699+
var validPatterns: [Any] = []
700+
var invalidCount = 0
701+
702+
for (index, element) in array.enumerated() {
703+
if let stringElement = element as? String {
704+
// Filter out empty strings and whitespace-only strings
705+
let trimmed = stringElement.trimmingCharacters(in: .whitespacesAndNewlines)
706+
if trimmed.isEmpty {
707+
SentrySDKLog.log(message: "Invalid networkDetail URL pattern at index \(index): empty or whitespace-only string discarded",
708+
andLevel: .warning)
709+
invalidCount += 1
710+
} else {
711+
validPatterns.append(stringElement)
712+
}
713+
} else if let regexElement = element as? NSRegularExpression {
714+
validPatterns.append(regexElement)
715+
} else {
716+
SentrySDKLog.log(message: "Invalid networkDetail URL pattern at index \(index): expected String or NSRegularExpression, got \(type(of: element))",
717+
andLevel: .warning)
718+
invalidCount += 1
719+
}
720+
}
721+
722+
if invalidCount > 0 {
723+
SentrySDKLog.log(message: "NetworkDetail URL patterns: \(invalidCount) invalid entries discarded, \(validPatterns.count) valid patterns retained",
724+
andLevel: .info)
725+
}
726+
727+
return validPatterns
728+
}
729+
463730
// swiftlint:disable:next function_parameter_count cyclomatic_complexity
464731
private init(
465732
sessionSampleRate: Float?,
@@ -477,7 +744,12 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
477744
sessionSegmentDuration: TimeInterval?,
478745
maximumDuration: TimeInterval?,
479746
excludedViewClasses: Set<String>? = nil,
480-
includedViewClasses: Set<String>? = nil
747+
includedViewClasses: Set<String>? = nil,
748+
networkDetailAllowUrls: [Any]? = nil,
749+
networkDetailDenyUrls: [Any]? = nil,
750+
networkCaptureBodies: Bool? = nil,
751+
networkRequestHeaders: [String]? = nil,
752+
networkResponseHeaders: [String]? = nil
481753
) {
482754
self.sessionSampleRate = sessionSampleRate ?? DefaultValues.sessionSampleRate
483755
self.onErrorSampleRate = onErrorSampleRate ?? DefaultValues.onErrorSampleRate
@@ -495,8 +767,49 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
495767
self.maximumDuration = maximumDuration ?? DefaultValues.maximumDuration
496768
self.excludedViewClasses = excludedViewClasses ?? DefaultValues.excludedViewClasses
497769
self.includedViewClasses = includedViewClasses ?? DefaultValues.includedViewClasses
770+
self.networkDetailAllowUrls = Self.validateNetworkDetailUrlPatterns(from: networkDetailAllowUrls) ?? DefaultValues.networkDetailAllowUrls
771+
self.networkDetailDenyUrls = Self.validateNetworkDetailUrlPatterns(from: networkDetailDenyUrls) ?? DefaultValues.networkDetailDenyUrls
772+
self.networkCaptureBodies = networkCaptureBodies ?? DefaultValues.networkCaptureBodies
773+
self._networkRequestHeaders = Self.mergeWithDefaultHeaders(networkRequestHeaders, defaults: DefaultValues.networkRequestHeaders)
774+
self._networkResponseHeaders = Self.mergeWithDefaultHeaders(networkResponseHeaders, defaults: DefaultValues.networkResponseHeaders)
498775

499776
super.init()
500777
}
778+
779+
/**
780+
* Merges user-provided headers with default headers, ensuring defaults are always included.
781+
*
782+
* - Parameter userHeaders: Headers specified by the user (can be nil)
783+
* - Parameter defaults: Default headers that must always be included
784+
* - Returns: Array containing both user headers and default headers (with duplicates removed)
785+
*/
786+
private static func mergeWithDefaultHeaders(_ userHeaders: [String]?, defaults: [String]) -> [String] {
787+
let providedHeaders = userHeaders ?? []
788+
789+
// Use Set to remove duplicates, then convert back to Array
790+
// Case-insensitive comparison to avoid duplicate headers with different casing
791+
var seenHeaders = Set<String>()
792+
var result: [String] = []
793+
794+
// Add default headers first
795+
for header in defaults {
796+
let lowercased = header.lowercased()
797+
if !seenHeaders.contains(lowercased) {
798+
seenHeaders.insert(lowercased)
799+
result.append(header)
800+
}
801+
}
802+
803+
// Add user-provided headers
804+
for header in providedHeaders {
805+
let lowercased = header.lowercased()
806+
if !seenHeaders.contains(lowercased) {
807+
seenHeaders.insert(lowercased)
808+
result.append(header)
809+
}
810+
}
811+
812+
return result
813+
}
501814
}
502-
// swiftlint:enable file_length missing_docs
815+
// swiftlint:enable file_length missing_docs type_body_length

0 commit comments

Comments
 (0)