Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,16 +224,35 @@ const MiddlewareConfig: ReactNativeConfiguration = {

### Distributed Tracing

To enable distributed tracing you need to pass backend domains in `tracePropagationTargets` which takes values in `Array<Regex>`
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<string | RegExp>`:

Example:
```typescript
const MiddlewareConfig: ReactNativeConfiguration = {
...
tracePropagationTargets: [/api.example.com/, /anotherapi.example.com/]
};
```

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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
144 changes: 144 additions & 0 deletions src/__tests__/tracePropagation.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {
__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<string, unknown>) {
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<string | RegExp>, 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([]);
});
});
23 changes: 20 additions & 3 deletions src/middlewareRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | RegExp>;
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.
Expand Down Expand Up @@ -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`,
Expand All @@ -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) => {
Expand Down Expand Up @@ -476,7 +491,7 @@ export const MiddlewareRum: MiddlewareRumType = {
},
});
const fetchInstrumentation = new FetchInstrumentation({
propagateTraceHeaderCorsUrls: config.tracePropagationTargets ?? [],
propagateTraceHeaderCorsUrls: tracePropagationTargets,
clearTimingResources: false,
ignoreUrls: DEFAULT_IGNORE_URLS,
applyCustomAttributesOnSpan: (
Expand Down Expand Up @@ -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();
}
Expand Down
Loading