diff --git a/README.md b/README.md index 802b0f5..3bdc76f 100644 --- a/README.md +++ b/README.md @@ -224,9 +224,19 @@ const MiddlewareConfig: ReactNativeConfiguration = { ### Distributed Tracing -To enable distributed tracing you need to pass backend domains in `tracePropagationTargets` which takes values in `Array` +End-to-end tracing links a RUM session to the backend traces it caused, so you can open a slow +screen in the session explorer and see the server spans behind it. + +It works by trace-context propagation: the SDK creates a client span for each outgoing request and +injects the W3C `traceparent` header. Your instrumented backend continues that same trace, and +Middleware correlates the two by trace ID. + +**This is on by default and requires no code.** Every request made through `fetch` or +`XMLHttpRequest` is traced and carries trace headers. + +To keep your trace IDs off third-party APIs, narrow propagation to your own domains with +`tracePropagationTargets`, which takes `Array`: -Example: ```typescript const MiddlewareConfig: ReactNativeConfiguration = { ... @@ -234,6 +244,15 @@ const MiddlewareConfig: ReactNativeConfiguration = { }; ``` +Requests to other hosts are still timed and still appear in the session — they just travel +without trace headers. An explicit empty array disables propagation entirely. + +Prefer regexes: a `RegExp` entry is matched against the URL, but a plain `string` entry has to +equal the whole URL exactly, so `'api.example.com'` matches nothing. + +By default both W3C (`traceparent`) and B3 headers are sent. Use `tracePropagationFormat: 'w3c'` +or `'b3'` to send only one. + ### Reporting custom errors diff --git a/package.json b/package.json index cf4e220..04a1e9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@middleware.io/middleware-react-native", - "version": "2.1.4", + "version": "2.1.5", "description": "Middleware React Native real user monitoring SDK", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/src/__tests__/tracePropagation.test.ts b/src/__tests__/tracePropagation.test.ts new file mode 100644 index 0000000..541db15 --- /dev/null +++ b/src/__tests__/tracePropagation.test.ts @@ -0,0 +1,144 @@ +/** + * What reaches the otel instrumentations decides whether a request carries `traceparent`, so + * these assert on the constructor options rather than on any log output. + * + * The instrumentation classes are mocked inside the isolated registry: `middlewareRum` gets its + * own copy of every module there, so a mock installed outside it would not be the one the SDK + * constructs. + */ +type Captured = { + xhr: any[]; + fetch: any[]; + disabled: string[]; +}; + +function loadRum() { + const captured: Captured = { xhr: [], fetch: [], disabled: [] }; + let rum: any; + + jest.isolateModules(() => { + jest.doMock('../native', () => { + const stubs: Record = { + __esModule: true, + initializeNativeSdk: jest + .fn() + .mockImplementation(() => + Promise.resolve({ moduleStart: Date.now(), isColdStart: true }) + ), + isNativeSdkAvailable: jest.fn().mockReturnValue(false), + isNativeExporterUsable: jest.fn().mockReturnValue(false), + isNativeRecording: jest.fn().mockResolvedValue(false), + }; + return new Proxy(stubs, { + get(target, key: string) { + if (!(key in target)) { + target[key] = jest.fn(); + } + return target[key]; + }, + }); + }); + + const fakeInstrumentation = (kind: 'xhr' | 'fetch') => + class { + constructor(options: any) { + captured[kind].push(options); + } + disable() { + captured.disabled.push(kind); + } + enable() {} + setTracerProvider() {} + setMeterProvider() {} + setConfig() {} + getConfig() { + return {}; + } + }; + + jest.doMock('@opentelemetry/instrumentation-xml-http-request', () => ({ + XMLHttpRequestInstrumentation: fakeInstrumentation('xhr'), + })); + jest.doMock('@opentelemetry/instrumentation-fetch', () => ({ + FetchInstrumentation: fakeInstrumentation('fetch'), + })); + jest.doMock('@opentelemetry/instrumentation', () => ({ + registerInstrumentations: jest.fn(), + })); + + rum = require('../middlewareRum').MiddlewareRum; + }); + + return { rum, captured }; +} + +const CONFIG = { + target: 'https://myproject.middleware.io', + accountKey: 'key', + projectName: 'proj', + serviceName: 'svc', +}; + +/** The `propagateTraceHeaderCorsUrls` both instrumentations were built with. */ +function targetsFrom(config: Record) { + const { rum, captured } = loadRum(); + rum.init({ ...CONFIG, ...config }); + return { + xhr: captured.xhr[0]?.propagateTraceHeaderCorsUrls, + fetch: captured.fetch[0]?.propagateTraceHeaderCorsUrls, + disabled: captured.disabled, + }; +} + +/** Mirrors `urlMatches` in @opentelemetry/core: a RegExp matches, a string must equal. */ +const propagatesTo = (targets: Array, url: string) => + targets.some((t) => (typeof t === 'string' ? url === t : !!url.match(t))); + +describe('trace propagation targets', () => { + it('propagates to every URL by default', () => { + const { xhr, fetch } = targetsFrom({}); + + expect(propagatesTo(xhr, 'https://api.example.com/orders')).toBe(true); + expect(propagatesTo(fetch, 'https://anything.else/path')).toBe(true); + }); + + it('applies the same targets to fetch and XHR', () => { + const { xhr, fetch } = targetsFrom({}); + + expect(xhr).toEqual(fetch); + }); + + it('honours configured targets and excludes everything else', () => { + const { xhr } = targetsFrom({ + tracePropagationTargets: [/api\.example\.com/], + }); + + expect(propagatesTo(xhr, 'https://api.example.com/orders')).toBe(true); + expect(propagatesTo(xhr, 'https://third-party.io/track')).toBe(false); + }); + + it('treats an explicit empty array as propagate-to-nothing', () => { + const { xhr } = targetsFrom({ tracePropagationTargets: [] }); + + expect(xhr).toEqual([]); + expect(propagatesTo(xhr, 'https://api.example.com/orders')).toBe(false); + }); +}); + +describe('networkInstrumentation flag', () => { + it('disables both instrumentations when false', () => { + expect(targetsFrom({ networkInstrumentation: false }).disabled).toEqual([ + 'xhr', + 'fetch', + ]); + }); + + it('leaves them enabled when true, matching its name', () => { + // It previously meant the opposite: passing `true` disabled instrumentation. + expect(targetsFrom({ networkInstrumentation: true }).disabled).toEqual([]); + }); + + it('leaves them enabled when unset', () => { + expect(targetsFrom({}).disabled).toEqual([]); + }); +}); diff --git a/src/middlewareRum.ts b/src/middlewareRum.ts index 2cf7d9b..79c170c 100644 --- a/src/middlewareRum.ts +++ b/src/middlewareRum.ts @@ -90,8 +90,16 @@ export interface ReactNativeConfiguration { debug?: boolean; /** Sets attributes added to every Span. */ globalAttributes?: Attributes; + /** + * Decides which outbound requests carry `traceparent`. Defaults to every URL; narrow it to + * keep your trace ids off third-party hosts. An explicit empty array disables propagation. + * + * A `RegExp` entry is matched against the URL, but a `string` entry must equal the whole URL + * exactly — prefer regexes, e.g. `[/api\.example\.com/]`. + */ tracePropagationTargets?: Array; tracePropagationFormat?: string; + /** Set to `false` to disable fetch/XHR instrumentation. Enabled by default. */ networkInstrumentation?: boolean; /** * URLs that partially match any regex in ignoreUrls will not be traced. @@ -381,6 +389,13 @@ export const MiddlewareRum: MiddlewareRumType = { nativeSdkConf.target ); + // Propagate to every host unless the caller narrows it, matching the browser and native + // SDKs. Otel only falls back to same-origin when this is empty, and React Native has no + // `location` for that fallback to match against — so an empty list here would mean no + // request carries `traceparent` at all. + const tracePropagationTargets: (string | RegExp)[] = + config.tracePropagationTargets ?? [/.*/]; + const DEFAULT_IGNORE_URLS: (string | RegExp)[] | undefined = [ `${config.target}/v1/metrics`, `${config.target}/v1/traces`, @@ -395,7 +410,7 @@ export const MiddlewareRum: MiddlewareRumType = { // request made with XMLHttpRequest. Since in this demo calls to /api/ are made using fetch, turn off // instrumentation for that path to avoid the extra spans. const xhrInstrumentation = new XMLHttpRequestInstrumentation({ - propagateTraceHeaderCorsUrls: config.tracePropagationTargets ?? [], + propagateTraceHeaderCorsUrls: tracePropagationTargets, clearTimingResources: false, ignoreUrls: DEFAULT_IGNORE_URLS, applyCustomAttributesOnSpan: (span: Span, xhr: XMLHttpRequest) => { @@ -476,7 +491,7 @@ export const MiddlewareRum: MiddlewareRumType = { }, }); const fetchInstrumentation = new FetchInstrumentation({ - propagateTraceHeaderCorsUrls: config.tracePropagationTargets ?? [], + propagateTraceHeaderCorsUrls: tracePropagationTargets, clearTimingResources: false, ignoreUrls: DEFAULT_IGNORE_URLS, applyCustomAttributesOnSpan: ( @@ -545,7 +560,9 @@ export const MiddlewareRum: MiddlewareRumType = { }, }); - if (config.networkInstrumentation) { + // `networkInstrumentation: false` turns it off, which is what the README documents. + // Anything else, including leaving it unset, keeps it on. + if (config.networkInstrumentation === false) { xhrInstrumentation.disable(); fetchInstrumentation.disable(); }