Skip to content

Commit b717bcb

Browse files
authored
feat(react-native): Add NetworkRecordingOptions and network sanitizer (#485)
## Summary Ports `NetworkRecordingOptions` and the network sanitizer from `highlight-run` into `@launchdarkly/observability-react-native`, wiring them up to the existing OpenTelemetry `FetchInstrumentation` and `XMLHttpRequestInstrumentation` via the `applyCustomAttributesOnSpan` hook. When `recordHeadersAndBody` is enabled, the SDK will add sanitized request/response headers (and response bodies for XHR, request bodies for string fetch bodies) as attributes on the HTTP spans that OTel already creates. Sensitive headers (`authorization`, `cookie`, `proxy-authorization`, `set-cookie`, `token`) are always redacted by default, regardless of other configuration. (Note: This PR was mostly generated with Claude.) --- ## Breaking Changes **None**. The new `networkRecording` option on `ReactNativeOptions` is optional and defaults to `{}` (feature off). Existing SDK behavior is unchanged. --- ## New APIs The following are added to the public API surface of `@launchdarkly/observability-react-native`: **`NetworkRecordingOptions`** (new exported type): ```typescript type NetworkRecordingOptions = { recordHeadersAndBody?: boolean // master gate; default false networkHeadersToRedact?: string[] // headers to redact (case-insensitive) headerKeysToRecord?: string[] // header whitelist (overrides redact) networkBodyKeysToRedact?: string[] // JSON body keys to redact bodyKeysToRecord?: string[] // JSON body key whitelist (overrides redact) } ``` **`ReactNativeOptions.networkRecording`** (new optional field): ```typescript networkRecording?: NetworkRecordingOptions ``` This is a **minor version bump** (new opt-in API, no breaking changes). ### Note: `urlBlocklist` placement In the web SDK (`highlight-run`), `urlBlocklist` lives inside `NetworkRecordingOptions`. In the React Native SDK it is intentionally omitted from `NetworkRecordingOptions` because it already exists as a top-level field on `ReactNativeOptions` (where it controls OTel trace header propagation). The existing top-level `urlBlocklist` is reused to also gate header/body recording, so there is no duplication. --- ## Files Copied From `highlight-run` - `src/listeners/network-listener/utils/models.ts` — copied from `sdk/highlight-run/src/client/listeners/network-listener/utils/models.ts` with no changes - `src/listeners/network-listener/utils/network-sanitizer.ts` — copied from `sdk/highlight-run/src/client/listeners/network-listener/utils/network-sanitizer.ts` with no changes - `src/listeners/network-listener/utils/xhr-listener.ts` — copied from `sdk/highlight-run/src/client/listeners/network-listener/utils/xhr-listener.ts`, stripped to `getBodyThatShouldBeRecorded` and its size-limit constants only; removed `XHRListener`, `getBodyData`, the `json-stringify-safe` import, and all other browser-patching code **New file** (no direct equivalent in `highlight-run`): - `src/listeners/network-listener/network-listener.ts` — exports `FetchHook` and `XHRHook`, curried functions that take `NetworkRecordingOptions` and return the appropriate `applyCustomAttributesOnSpan` handlers for OTel's `FetchInstrumentation` and `XMLHttpRequestInstrumentation` --- ## Testing The existing source files copied from `highlight-run` have no unit tests in that package, so adding unit tests is deferred to a follow-up PR, if desired. The natural candidates would be: - `network-sanitizer.test.ts` — `sanitizeHeaders` and `sanitizeUrl` (pure functions) - `xhr-listener.test.ts` — `getBodyThatShouldBeRecorded` (pure function) - `network-listener.test.ts` — `FetchHook`/`XHRHook` with a mocked `Span` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds optional recording of HTTP headers/bodies onto OTel spans; despite redaction controls, mistakes or misconfiguration could capture sensitive data and increase span size/perf overhead. > > **Overview** > Adds a new `networkRecording` option (via exported `NetworkRecordingOptions`) to optionally attach sanitized request/response headers and bodies to OpenTelemetry `FetchInstrumentation`/`XMLHttpRequestInstrumentation` spans. > > Introduces a network listener + sanitizer utilities that **always** sanitize span URL attributes (redacting credentials and sensitive query params), redact known sensitive headers, support header/body allowlists/redaction lists, and applies a built-in `DEFAULT_URL_BLOCKLIST` (merged into `urlBlocklist`) to prevent recording for specific auth/token endpoints. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 45db54b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 2b1cd44 commit b717bcb

7 files changed

Lines changed: 466 additions & 1 deletion

File tree

sdk/@launchdarkly/observability-react-native/src/api/Options.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,46 @@
11
import { Attributes } from '@opentelemetry/api'
22
import type { LDContext } from '@launchdarkly/js-sdk-common'
33

4+
export type NetworkRecordingOptions = {
5+
/**
6+
* This enables recording XMLHttpRequest and Fetch headers and bodies.
7+
* @default false
8+
*/
9+
recordHeadersAndBody?: boolean
10+
/**
11+
* Request and response headers where the value is not recorded.
12+
* The header value is replaced with '[REDACTED]'.
13+
* These headers are case-insensitive.
14+
* `recordHeadersAndBody` needs to be enabled.
15+
* This option will be ignored if `headerKeysToRecord` is set.
16+
* @example
17+
* networkHeadersToRedact: ['Secret-Header', 'Plain-Text-Password']
18+
*/
19+
networkHeadersToRedact?: string[]
20+
/**
21+
* Specifies the keys for request/response JSON body that should not be recorded.
22+
* The body value is replaced with '[REDACTED]'.
23+
* These keys are case-insensitive.
24+
* `recordHeadersAndBody` needs to be `true`. Otherwise this option will be ignored.
25+
* @example bodyKeysToRedact: ['secret-token', 'plain-text-password']
26+
*/
27+
networkBodyKeysToRedact?: string[]
28+
/**
29+
* Specifies the keys for request/response headers to record.
30+
* This option will override `networkHeadersToRedact` if specified.
31+
* `recordHeadersAndBody` needs to be `true`. Otherwise this option will be ignored.
32+
* @example headerKeysToRecord: ['id', 'pageNumber']
33+
*/
34+
headerKeysToRecord?: string[]
35+
/**
36+
* Specifies the keys for request/response JSON body to record.
37+
* This option will override `networkBodyKeysToRedact` if specified.
38+
* `recordHeadersAndBody` needs to be `true`. Otherwise this option will be ignored.
39+
* @example bodyKeysToRecord: ['id', 'pageNumber']
40+
*/
41+
bodyKeysToRecord?: string[]
42+
}
43+
444
export interface ReactNativeOptions {
545
/**
646
* The service name for the application.
@@ -81,6 +121,12 @@ export interface ReactNativeOptions {
81121
*/
82122
disableMetrics?: boolean
83123

124+
/**
125+
* Options for recording network request and response headers and bodies,
126+
* with controls for redacting sensitive data.
127+
*/
128+
networkRecording?: NetworkRecordingOptions
129+
84130
/**
85131
* A function that returns a friendly name for a given context.
86132
* This name will be used to identify the session in the observability UI.

sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ import {
3939
import { SpanStatusCode } from '@opentelemetry/api'
4040
import { W3CBaggagePropagator, CompositePropagator } from '@opentelemetry/core'
4141
import { ReactNativeOptions } from '../api/Options'
42+
import {
43+
FetchHook,
44+
XHRHook,
45+
} from '../listeners/network-listener/network-listener'
4246
import { Metric } from '../api/Metric'
4347
import { SessionManager } from './SessionManager'
4448
import {
@@ -161,14 +165,23 @@ export class InstrumentationManager {
161165
trace.setGlobalTracerProvider(this.traceProvider)
162166

163167
const corsPattern = getCorsUrlsPattern(this.options.tracingOrigins)
168+
const networkRecording = this.options.networkRecording
164169

165170
registerInstrumentations({
166171
instrumentations: [
167172
new FetchInstrumentation({
168173
propagateTraceHeaderCorsUrls: corsPattern,
174+
applyCustomAttributesOnSpan: FetchHook(
175+
networkRecording,
176+
this.options.urlBlocklist,
177+
),
169178
}),
170179
new XMLHttpRequestInstrumentation({
171180
propagateTraceHeaderCorsUrls: corsPattern,
181+
applyCustomAttributesOnSpan: XHRHook(
182+
networkRecording,
183+
this.options.urlBlocklist,
184+
),
172185
}),
173186
],
174187
})

sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ATTR_TELEMETRY_SDK_VERSION,
1515
} from '@opentelemetry/semantic-conventions'
1616
import { ReactNativeOptions } from '../api/Options'
17+
import { DEFAULT_URL_BLOCKLIST } from '../listeners/network-listener/utils/network-sanitizer'
1718
import { Metric } from '../api/Metric'
1819
import { RequestContext } from '../api/RequestContext'
1920
import { SessionManager } from '../client/SessionManager'
@@ -70,7 +71,11 @@ export class ObservabilityClient {
7071
disableMetrics: options.disableMetrics ?? false,
7172
disableTraces: options.disableTraces ?? false,
7273
tracingOrigins: options.tracingOrigins ?? false,
73-
urlBlocklist: options.urlBlocklist ?? [],
74+
urlBlocklist: [
75+
...(options.urlBlocklist ?? []),
76+
...DEFAULT_URL_BLOCKLIST,
77+
],
78+
networkRecording: options.networkRecording ?? {},
7479
contextFriendlyName:
7580
options.contextFriendlyName ?? (() => undefined),
7681
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { Span } from '@opentelemetry/api'
2+
import { FetchCustomAttributeFunction } from '@opentelemetry/instrumentation-fetch'
3+
import { NetworkRecordingOptions } from '../../api/Options'
4+
import { sanitizeHeaders, sanitizeUrl } from './utils/network-sanitizer'
5+
import { getBodyThatShouldBeRecorded } from './utils/xhr-listener'
6+
7+
const applyNetworkAttributes = (
8+
span: Span,
9+
url: string,
10+
recording: NetworkRecordingOptions,
11+
urlBlocklist: string[],
12+
requestHeaders: Record<string, string>,
13+
responseHeaders: Record<string, string>,
14+
requestBody: string | undefined,
15+
responseBody: string | undefined,
16+
) => {
17+
// Always overwrite OTel-set URL attributes with sanitized version to
18+
// redact credentials and sensitive query params.
19+
if (url) {
20+
url = sanitizeUrl(url)
21+
span.setAttribute('http.url', url)
22+
span.setAttribute('url.full', url)
23+
}
24+
25+
if (urlBlocklist.some((blocked) => url.toLowerCase().includes(blocked))) {
26+
return
27+
}
28+
29+
if (!recording.recordHeadersAndBody) {
30+
return
31+
}
32+
33+
const headersToRedact = (recording.networkHeadersToRedact ?? []).map((h) =>
34+
h.toLowerCase(),
35+
)
36+
const headersToRecord = recording.headerKeysToRecord?.map((h) =>
37+
h.toLowerCase(),
38+
)
39+
const bodyKeysToRedact = (recording.networkBodyKeysToRedact ?? []).map(
40+
(k) => k.toLowerCase(),
41+
)
42+
const bodyKeysToRecord = recording.bodyKeysToRecord?.map((k) =>
43+
k.toLowerCase(),
44+
)
45+
46+
// Request headers
47+
const sanitizedReqHeaders = sanitizeHeaders(
48+
headersToRedact,
49+
requestHeaders,
50+
headersToRecord,
51+
)
52+
Object.entries(sanitizedReqHeaders).forEach(([key, value]) => {
53+
span.setAttribute(`http.request.header.${key}`, value)
54+
})
55+
56+
// Request body
57+
if (requestBody !== undefined) {
58+
const sanitizedBody = getBodyThatShouldBeRecorded(
59+
requestBody,
60+
bodyKeysToRedact,
61+
bodyKeysToRecord,
62+
requestHeaders,
63+
)
64+
if (sanitizedBody != null) {
65+
span.setAttribute('http.request.body', sanitizedBody)
66+
}
67+
}
68+
69+
// Response headers
70+
const sanitizedRespHeaders = sanitizeHeaders(
71+
headersToRedact,
72+
responseHeaders,
73+
headersToRecord,
74+
)
75+
Object.entries(sanitizedRespHeaders).forEach(([key, value]) => {
76+
span.setAttribute(`http.response.header.${key}`, value)
77+
})
78+
79+
// Response body
80+
if (responseBody !== undefined) {
81+
const sanitizedBody = getBodyThatShouldBeRecorded(
82+
responseBody,
83+
bodyKeysToRedact,
84+
bodyKeysToRecord,
85+
responseHeaders,
86+
)
87+
if (sanitizedBody != null) {
88+
span.setAttribute('http.response.body', sanitizedBody)
89+
}
90+
}
91+
}
92+
93+
export const FetchHook =
94+
(
95+
recording: NetworkRecordingOptions,
96+
urlBlocklist: string[],
97+
): FetchCustomAttributeFunction =>
98+
(span, request, result) => {
99+
const url = request instanceof Request ? request.url : ''
100+
101+
let requestHeaders: Record<string, string> = {}
102+
if (request instanceof Request) {
103+
requestHeaders = Object.fromEntries(request.headers.entries())
104+
} else if (
105+
request &&
106+
typeof request === 'object' &&
107+
'headers' in request &&
108+
request.headers
109+
) {
110+
requestHeaders = Object.fromEntries(
111+
new Headers(request.headers as HeadersInit).entries(),
112+
)
113+
}
114+
115+
const bodyInit =
116+
request instanceof Request
117+
? undefined
118+
: (request as RequestInit).body
119+
const requestBody = typeof bodyInit === 'string' ? bodyInit : undefined
120+
121+
const responseHeaders =
122+
result instanceof Response
123+
? Object.fromEntries(result.headers.entries())
124+
: {}
125+
126+
applyNetworkAttributes(
127+
span,
128+
url,
129+
recording,
130+
urlBlocklist,
131+
requestHeaders,
132+
responseHeaders,
133+
requestBody,
134+
undefined, // Fetch response body is a stream; not recorded
135+
)
136+
}
137+
138+
export const XHRHook =
139+
(recording: NetworkRecordingOptions, urlBlocklist: string[]) =>
140+
(span: Span, xhr: XMLHttpRequest) => {
141+
const url = (xhr as any)._url ?? ''
142+
143+
const responseHeaders: Record<string, string> = {}
144+
xhr.getAllResponseHeaders()
145+
.trim()
146+
.split(/[\r\n]+/)
147+
.forEach((line) => {
148+
const parts = line.split(': ')
149+
const header = parts.shift()
150+
if (header) {
151+
responseHeaders[header] = parts.join(': ')
152+
}
153+
})
154+
155+
const responseBody =
156+
xhr.responseType === '' || xhr.responseType === 'text'
157+
? xhr.responseText
158+
: undefined
159+
160+
applyNetworkAttributes(
161+
span,
162+
url,
163+
recording,
164+
urlBlocklist,
165+
{}, // XHR does not expose request headers
166+
responseHeaders,
167+
undefined, // XHR request body is not accessible post-send
168+
responseBody,
169+
)
170+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface Headers {
2+
[key: string]: any
3+
}

0 commit comments

Comments
 (0)