@datadog/mobile-react-native
Version:
A client-side React Native module to interact with Datadog
1,323 lines (1,154 loc) • 59.5 kB
text/typescript
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/
import BigInt from 'big-integer';
import { Platform, NativeModules } from 'react-native';
import { InternalLog } from '../../../../../../InternalLog';
import { SdkVerbosity } from '../../../../../../SdkVerbosity';
import { BufferSingleton } from '../../../../../../sdk/DatadogProvider/Buffer/BufferSingleton';
import { DdRum } from '../../../../../DdRum';
import { setCachedSessionId } from '../../../../../sessionId/sessionIdHelper';
import { PropagatorType } from '../../../../../types';
import { XMLHttpRequestMock } from '../../../__tests__/__utils__/XMLHttpRequestMock';
import { TracingIdentifierUtils } from '../../../distributedTracing/__tests__/__utils__/TracingIdentifierUtils';
import {
PARENT_ID_HEADER_KEY,
TRACE_ID_HEADER_KEY,
SAMPLING_PRIORITY_HEADER_KEY,
TRACECONTEXT_HEADER_KEY,
B3_HEADER_KEY,
B3_MULTI_TRACE_ID_HEADER_KEY,
B3_MULTI_SPAN_ID_HEADER_KEY,
B3_MULTI_SAMPLED_HEADER_KEY,
ORIGIN_RUM,
ORIGIN_HEADER_KEY,
TRACESTATE_HEADER_KEY,
TAGS_HEADER_KEY,
BAGGAGE_HEADER_KEY
} from '../../../distributedTracing/distributedTracingHeaders';
import { firstPartyHostsRegexMapBuilder } from '../../../distributedTracing/firstPartyHosts';
import {
DATADOG_GRAPH_QL_OPERATION_NAME_HEADER,
DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER,
DATADOG_GRAPH_QL_VARIABLES_HEADER
} from '../../../graphql/graphqlHeaders';
import { ResourceReporter } from '../DatadogRumResource/ResourceReporter';
import { XHRProxy } from '../XHRProxy';
import {
calculateResponseSize,
RESOURCE_SIZE_ERROR_MESSAGE
} from '../responseSize';
jest.useFakeTimers();
jest.mock('../../../../../../InternalLog');
const mockedInternalLog = (InternalLog as unknown) as {
log: jest.MockedFunction<typeof InternalLog.log>;
};
jest.spyOn(global.Math, 'random');
const DdNativeRum = NativeModules.DdRum;
function randomInt(max: number): number {
return Math.floor(Math.random() * max);
}
const flushPromises = () =>
new Promise(jest.requireActual('timers').setImmediate);
let xhrProxy;
const hexToDecimal = (hex: string): string => {
return BigInt(hex, 16).toString(10);
};
beforeEach(() => {
DdNativeRum.startResource.mockClear();
DdNativeRum.stopResource.mockClear();
BufferSingleton.onInitialization();
xhrProxy = new XHRProxy({
xhrType: XMLHttpRequestMock,
resourceReporter: new ResourceReporter([])
});
// we need this because with ms precision between Date.now() calls we can get 0, so we advance
// it manually with each call
let now = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => {
now += 5;
return now;
});
jest.setTimeout(20000);
});
afterEach(() => {
xhrProxy.onTrackingStop();
(Date.now as jest.MockedFunction<typeof Date.now>).mockClear();
jest.spyOn(global.Math, 'random').mockRestore();
DdRum.unregisterResourceEventMapper();
});
describe('XHRProxy', () => {
describe('resource interception', () => {
it('intercepts XHR request when startTracking() + XHR.open() + XHR.send()', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com:443/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(DdNativeRum.startResource.mock.calls.length).toBe(1);
expect(DdNativeRum.startResource.mock.calls[0][1]).toBe(method);
expect(DdNativeRum.startResource.mock.calls[0][2]).toBe(url);
expect(DdNativeRum.stopResource.mock.calls.length).toBe(1);
expect(DdNativeRum.stopResource.mock.calls[0][0]).toBe(
DdNativeRum.startResource.mock.calls[0][0]
);
expect(DdNativeRum.stopResource.mock.calls[0][1]).toBe(200);
expect(DdNativeRum.stopResource.mock.calls[0][2]).toBe('xhr');
expect(DdNativeRum.stopResource.mock.calls[0][3]).toBeGreaterThan(
0
);
expect(xhr.originalOpenCalled).toBe(true);
expect(xhr.originalSendCalled).toBe(true);
expect(xhr.originalOnReadyStateChangeCalled).toBe(true);
});
it('intercepts failing XHR request when startTracking() + XHR.open() + XHR.send()', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(500, 'error');
await flushPromises();
// THEN
expect(DdNativeRum.startResource.mock.calls.length).toBe(1);
expect(DdNativeRum.startResource.mock.calls[0][1]).toBe(method);
expect(DdNativeRum.startResource.mock.calls[0][2]).toBe(url);
expect(DdNativeRum.stopResource.mock.calls.length).toBe(1);
expect(DdNativeRum.stopResource.mock.calls[0][0]).toBe(
DdNativeRum.startResource.mock.calls[0][0]
);
expect(DdNativeRum.stopResource.mock.calls[0][1]).toBe(500);
expect(DdNativeRum.stopResource.mock.calls[0][2]).toBe('xhr');
expect(DdNativeRum.stopResource.mock.calls[0][3]).toBeGreaterThan(
0
);
expect(xhr.originalOpenCalled).toBe(true);
expect(xhr.originalSendCalled).toBe(true);
expect(xhr.originalOnReadyStateChangeCalled).toBe(true);
});
it('intercepts aborted XHR request when startTracking() + XHR.open() + XHR.send() + XHR.abort()', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.abort();
xhr.complete(0, undefined);
await flushPromises();
// THEN
expect(DdNativeRum.startResource.mock.calls.length).toBe(1);
expect(DdNativeRum.startResource.mock.calls[0][1]).toBe(method);
expect(DdNativeRum.startResource.mock.calls[0][2]).toBe(url);
expect(DdNativeRum.stopResource.mock.calls.length).toBe(1);
expect(DdNativeRum.stopResource.mock.calls[0][0]).toBe(
DdNativeRum.startResource.mock.calls[0][0]
);
expect(DdNativeRum.stopResource.mock.calls[0][1]).toBe(0);
expect(DdNativeRum.stopResource.mock.calls[0][2]).toBe('xhr');
expect(DdNativeRum.stopResource.mock.calls[0][3]).toBe(-1);
expect(xhr.originalOpenCalled).toBe(true);
expect(xhr.originalSendCalled).toBe(true);
expect(xhr.originalOnReadyStateChangeCalled).toBe(true);
});
});
describe('request headers', () => {
it('adds the span id and trace Id in the request headers when startTracking() + XHR.open() + XHR.send()', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'api.example.com',
propagatorTypes: [PropagatorType.DATADOG]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
const spanId = xhr.requestHeaders[PARENT_ID_HEADER_KEY];
expect(spanId).toBeDefined();
expect(spanId).toMatch(/[1-9].+/);
const traceId = xhr.requestHeaders[TRACE_ID_HEADER_KEY];
expect(traceId).toBeDefined();
expect(traceId).toMatch(/[1-9].+/);
expect(traceId !== spanId).toBeTruthy();
expect(xhr.requestHeaders[SAMPLING_PRIORITY_HEADER_KEY]).toBe('1');
expect(xhr.requestHeaders[ORIGIN_HEADER_KEY]).toBe(ORIGIN_RUM);
});
it('does not generate spanId and traceId in request headers when no first party hosts are provided', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(xhr.requestHeaders[TRACE_ID_HEADER_KEY]).toBeUndefined();
expect(xhr.requestHeaders[PARENT_ID_HEADER_KEY]).toBeUndefined();
});
it('does not generate spanId and traceId in request headers when the url does not match first party hosts', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'google.com',
propagatorTypes: [PropagatorType.DATADOG]
},
{
match: 'api.example.co',
propagatorTypes: [PropagatorType.DATADOG]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(xhr.requestHeaders[TRACE_ID_HEADER_KEY]).toBeUndefined();
expect(xhr.requestHeaders[PARENT_ID_HEADER_KEY]).toBeUndefined();
});
it('does not crash when provided URL is not a valid one', async () => {
// GIVEN
const method = 'GET';
const url = 'crash';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'example.com',
propagatorTypes: [PropagatorType.DATADOG]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(xhr.requestHeaders[TRACE_ID_HEADER_KEY]).toBeUndefined();
expect(xhr.requestHeaders[PARENT_ID_HEADER_KEY]).toBeUndefined();
});
it('generates spanId and traceId with 0 sampling priority in request headers when trace is not sampled', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 0,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'api.example.com',
propagatorTypes: [PropagatorType.DATADOG]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(xhr.requestHeaders[TRACE_ID_HEADER_KEY]).not.toBeUndefined();
expect(
xhr.requestHeaders[PARENT_ID_HEADER_KEY]
).not.toBeUndefined();
expect(xhr.requestHeaders[SAMPLING_PRIORITY_HEADER_KEY]).toBe('0');
expect(xhr.requestHeaders[ORIGIN_HEADER_KEY]).toBe(ORIGIN_RUM);
});
it('does not origin as RUM in the request headers when startTracking() + XHR.open() + XHR.send()', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(xhr.requestHeaders[ORIGIN_HEADER_KEY]).toBeUndefined();
});
it('forces the agent to keep the request generated trace when startTracking() + XHR.open() + XHR.send()', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'api.example.com',
propagatorTypes: [PropagatorType.DATADOG]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(xhr.requestHeaders[SAMPLING_PRIORITY_HEADER_KEY]).toBe('1');
});
it('forces the agent to discard the request generated trace when startTracking when the request is not traced', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 0,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'api.example.com',
propagatorTypes: [PropagatorType.DATADOG]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(xhr.requestHeaders[SAMPLING_PRIORITY_HEADER_KEY]).toBe('0');
});
it('adds tracecontext request headers when the host is instrumented with tracecontext and request is sampled', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com:443/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'something.fr',
propagatorTypes: [PropagatorType.DATADOG]
},
{
match: 'example.com',
propagatorTypes: [PropagatorType.TRACECONTEXT]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
const contextHeader = xhr.requestHeaders[TRACECONTEXT_HEADER_KEY];
expect(contextHeader).toMatch(
/^00-[0-9a-f]{8}[0]{8}[0-9a-f]{16}-[0-9a-f]{16}-01$/
);
// Parent value of the context header is the 3rd part of it
const parentValue = contextHeader.split('-')[2];
const stateHeader = xhr.requestHeaders[TRACESTATE_HEADER_KEY];
expect(stateHeader).toBe(`dd=s:1;o:rum;p:${parentValue}`);
});
it('adds correct trace IDs headers for all propagatorTypes', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com:443/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'example.com',
propagatorTypes: [PropagatorType.DATADOG]
},
{
match: 'example.com',
propagatorTypes: [PropagatorType.TRACECONTEXT]
},
{
match: 'example.com',
propagatorTypes: [PropagatorType.B3]
},
{
match: 'example.com',
propagatorTypes: [PropagatorType.B3MULTI]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
/* =================================================================================
* Verify that the trace id in the traceparent header is a 128 bit trace ID (hex).
* ================================================================================= */
const traceparentHeader =
xhr.requestHeaders[TRACECONTEXT_HEADER_KEY];
const traceparentTraceId = traceparentHeader.split('-')[1];
expect(traceparentTraceId).toMatch(
/^[0-9a-f]{8}[0]{8}[0-9a-f]{16}$/
);
expect(
TracingIdentifierUtils.isWithin128Bits(traceparentTraceId, 16)
);
/* =========================================================================
* Verify that the trace id in the x-datadog-trace-id is a 64 bit decimal.
* ========================================================================= */
// x-datadog-trace-id is a decimal representing the low 64 bits of the 128 bits Trace ID
const xDatadogTraceId = xhr.requestHeaders[TRACE_ID_HEADER_KEY];
expect(TracingIdentifierUtils.isWithin64Bits(xDatadogTraceId));
/* ===============================================================
* Verify that the trace id in x-datadog-tags headers is HEX 16.
* =============================================================== */
// x-datadog-tags is a HEX 16 contains the high 64 bits of the 128 bits Trace ID
const xDatadogTagsTraceId = xhr.requestHeaders[
TAGS_HEADER_KEY
].split('=')[1];
expect(xDatadogTagsTraceId).toMatch(/^[a-f0-9]{16}$/);
expect(
TracingIdentifierUtils.isWithin64Bits(xDatadogTagsTraceId, 16)
);
/* =========================================================================
* Verify that the trace id in the b3 header is a 128 bit trace ID (hex).
* ========================================================================= */
const b3Header = xhr.requestHeaders[B3_HEADER_KEY];
const b3TraceId = b3Header.split('-')[0];
expect(b3TraceId).toMatch(/^[0-9a-f]{8}[0]{8}[0-9a-f]{16}$/);
expect(TracingIdentifierUtils.isWithin128Bits(b3TraceId, 16));
/* =================================================================================
* Verify that the trace id in the X-B3-TraceId header is a 128 bit trace ID (hex).
* ================================================================================= */
const xB3TraceId = xhr.requestHeaders[B3_MULTI_TRACE_ID_HEADER_KEY];
expect(xB3TraceId).toMatch(/^[0-9a-f]{8}[0]{8}[0-9a-f]{16}$/);
expect(TracingIdentifierUtils.isWithin128Bits(xB3TraceId, 16));
});
it('adds tracing headers with matching value when all headers are added', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com:443/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'example.com',
propagatorTypes: [PropagatorType.DATADOG]
},
{
match: 'example.com',
propagatorTypes: [PropagatorType.TRACECONTEXT]
},
{
match: 'example.com',
propagatorTypes: [PropagatorType.B3]
},
{
match: 'example.com',
propagatorTypes: [PropagatorType.B3MULTI]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
// x-datadog-trace-id is just the low 64 bits (DECIMAL)
const datadogLowTraceValue =
xhr.requestHeaders[TRACE_ID_HEADER_KEY];
// We convert the low 64 bits to HEX
const datadogLowTraceValueHex = `${BigInt(datadogLowTraceValue)
.toString(16)
.padStart(16, '0')}`;
// The high 64 bits are expressed in x-datadog-tags (HEX)
const datadogHighTraceValueHex = xhr.requestHeaders[
TAGS_HEADER_KEY
].split('=')[1]; // High HEX 64 bits
// We re-compose the full 128 bit trace-id by joining the strings
const datadogTraceValue128BitHex = `${datadogHighTraceValueHex}${datadogLowTraceValueHex}`;
// We then get the decimal value of the trace-id
const datadogTraceValue128BitDec = hexToDecimal(
datadogTraceValue128BitHex
);
const datadogParentValue = xhr.requestHeaders[PARENT_ID_HEADER_KEY];
const contextHeader = xhr.requestHeaders[TRACECONTEXT_HEADER_KEY];
const traceContextValue = contextHeader.split('-')[1];
const parentContextValue = contextHeader.split('-')[2];
const b3MultiTraceHeader =
xhr.requestHeaders[B3_MULTI_TRACE_ID_HEADER_KEY];
const b3MultiParentHeader =
xhr.requestHeaders[B3_MULTI_SPAN_ID_HEADER_KEY];
const b3Header = xhr.requestHeaders[B3_HEADER_KEY];
const traceB3Value = b3Header.split('-')[0];
const parentB3Value = b3Header.split('-')[1];
expect(hexToDecimal(traceContextValue)).toBe(
datadogTraceValue128BitDec
);
expect(hexToDecimal(parentContextValue)).toBe(datadogParentValue);
//
expect(hexToDecimal(b3MultiTraceHeader)).toBe(
datadogTraceValue128BitDec
);
expect(hexToDecimal(b3MultiParentHeader)).toBe(datadogParentValue);
expect(hexToDecimal(traceB3Value)).toBe(datadogTraceValue128BitDec);
expect(hexToDecimal(parentB3Value)).toBe(datadogParentValue);
});
it('adds tracecontext request headers when the host is instrumented with tracecontext and request is sampled', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com:443/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'something.fr',
propagatorTypes: [PropagatorType.DATADOG]
},
{
match: 'example.com',
propagatorTypes: [PropagatorType.B3MULTI]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
const traceId = xhr.requestHeaders[B3_MULTI_TRACE_ID_HEADER_KEY];
const spanId = xhr.requestHeaders[B3_MULTI_SPAN_ID_HEADER_KEY];
const sampled = xhr.requestHeaders[B3_MULTI_SAMPLED_HEADER_KEY];
expect(traceId).toMatch(/^[0-9a-f]{8}[0]{8}[0-9a-f]{16}$/);
expect(spanId).toMatch(/^[0-9a-f]{16}$/);
expect(sampled).toBe('1');
});
it('adds tracecontext request headers when the host is instrumented with b3 and request is sampled', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com:443/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'something.fr',
propagatorTypes: [PropagatorType.DATADOG]
},
{
match: 'example.com',
propagatorTypes: [PropagatorType.B3]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
const headerValue = xhr.requestHeaders[B3_HEADER_KEY];
expect(headerValue).toMatch(
/^[0-9a-f]{8}[0]{8}[0-9a-f]{16}-[0-9a-f]{16}-1$/
);
});
it('adds all headers when the host is matched for different propagators', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com:443/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'api.example.com',
propagatorTypes: [
PropagatorType.DATADOG,
PropagatorType.TRACECONTEXT
]
},
{
match: 'example.com',
propagatorTypes: [
PropagatorType.B3,
PropagatorType.B3MULTI
]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(xhr.requestHeaders[B3_HEADER_KEY]).not.toBeUndefined();
expect(
xhr.requestHeaders[B3_MULTI_TRACE_ID_HEADER_KEY]
).not.toBeUndefined();
expect(
xhr.requestHeaders[B3_MULTI_SPAN_ID_HEADER_KEY]
).not.toBeUndefined();
expect(xhr.requestHeaders[B3_MULTI_SAMPLED_HEADER_KEY]).toBe('1');
expect(
xhr.requestHeaders[TRACECONTEXT_HEADER_KEY]
).not.toBeUndefined();
expect(xhr.requestHeaders[TRACE_ID_HEADER_KEY]).not.toBeUndefined();
expect(
xhr.requestHeaders[PARENT_ID_HEADER_KEY]
).not.toBeUndefined();
expect(xhr.requestHeaders[SAMPLING_PRIORITY_HEADER_KEY]).toBe('1');
expect(xhr.requestHeaders[ORIGIN_HEADER_KEY]).toBe(ORIGIN_RUM);
});
it('adds rum session id to baggage headers when available', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com:443/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'api.example.com',
propagatorTypes: [
PropagatorType.DATADOG,
PropagatorType.TRACECONTEXT
]
},
{
match: 'example.com',
propagatorTypes: [
PropagatorType.B3,
PropagatorType.B3MULTI
]
}
])
});
setCachedSessionId('TEST-SESSION-ID');
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).not.toBeUndefined();
expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).toBe(
'session.id=TEST-SESSION-ID'
);
});
it('does not add rum session id to baggage headers when session id not cached', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com:443/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'api.example.com',
propagatorTypes: [
PropagatorType.DATADOG,
PropagatorType.TRACECONTEXT
]
},
{
match: 'example.com',
propagatorTypes: [
PropagatorType.B3,
PropagatorType.B3MULTI
]
}
])
});
setCachedSessionId(undefined as any);
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).toBeUndefined();
});
it('does not add rum session id to baggage headers when propagator type is not datadog or w3c', async () => {
// GIVEN
const method = 'GET';
const url = 'https://example.com';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'api.example.com',
propagatorTypes: [
PropagatorType.DATADOG,
PropagatorType.TRACECONTEXT
]
},
{
match: 'example.com', // <-- no datadog or tracecontext here
propagatorTypes: [
PropagatorType.B3,
PropagatorType.B3MULTI
]
}
])
});
setCachedSessionId('TEST-SESSION-ID');
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).toBeUndefined();
});
it('rum session id does not overwrite existing baggage headers', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com:443/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'api.example.com',
propagatorTypes: [
PropagatorType.DATADOG,
PropagatorType.TRACECONTEXT
]
},
{
match: 'example.com',
propagatorTypes: [
PropagatorType.B3,
PropagatorType.B3MULTI
]
}
])
});
setCachedSessionId('TEST-SESSION-ID');
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.setRequestHeader('baggage', 'existing.key=existing-value');
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).not.toBeUndefined();
expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).toContain(
'existing.key=existing-value'
);
const values = xhr.requestHeaders[BAGGAGE_HEADER_KEY].split(
','
).sort();
expect(values[0]).toBe('existing.key=existing-value');
expect(values[1]).toBe('session.id=TEST-SESSION-ID');
});
});
describe('DdRum.startResource calls', () => {
it('adds the span id, trace id and rule_psr as resource attributes when startTracking() + XHR.open() + XHR.send()', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'api.example.com',
propagatorTypes: [PropagatorType.DATADOG]
}
])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
const spanId =
DdNativeRum.startResource.mock.calls[0][3]['_dd.span_id'];
expect(spanId).toBeDefined();
expect(spanId).toMatch(/[1-9].+/);
const traceId =
DdNativeRum.startResource.mock.calls[0][3]['_dd.trace_id'];
expect(traceId).toBeDefined();
expect(traceId).toMatch(/[1-9].+/);
const rulePsr =
DdNativeRum.startResource.mock.calls[0][3]['_dd.rule_psr'];
expect(rulePsr).toBe(1);
// Check traceId and spanId are different
expect(traceId).not.toBe(spanId);
});
it('does not generate spanId and traceId when tracing is disabled', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 50,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(DdNativeRum.startResource).not.toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({
'_dd.trace_id': expect.any(String),
'_dd.span_id': expect.any(String),
'_dd.rule_psr': expect.any(Number)
}),
expect.anything()
);
expect(DdNativeRum.startResource.mock.calls[0][3]).toStrictEqual(
{}
);
});
it('generates spanId and traceId when the trace is not sampled', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 0,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([
{
match: 'api.example.com',
propagatorTypes: [PropagatorType.DATADOG]
}
])
});
jest.spyOn(global.Math, 'random').mockReturnValue(0.7);
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.notifyResponseArrived();
xhr.complete(200, 'ok');
await flushPromises();
// THEN
expect(DdNativeRum.startResource).not.toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({
'_dd.trace_id': expect.any(String),
'_dd.span_id': expect.any(String),
'_dd.rule_psr': expect.any(Number)
}),
expect.anything()
);
expect(DdNativeRum.startResource.mock.calls[0][3]).toStrictEqual(
{}
);
});
});
describe.each([['android'], ['ios']])('timings test', platform => {
it(`M generate resource timings when startTracking() + XHR.open() + XHR.send(), platform=${platform}`, async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([])
});
jest.doMock('react-native/Libraries/Utilities/Platform', () => ({
OS: platform
}));
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
jest.advanceTimersByTime(50);
xhr.notifyResponseArrived();
jest.advanceTimersByTime(50);
xhr.complete(200, 'ok');
await flushPromises();
// THEN
const timings =
DdNativeRum.stopResource.mock.calls[0][4][
'_dd.resource_timings'
];
if (Platform.OS === 'ios') {
expect(timings['firstByte']['startTime']).toBeGreaterThan(0);
} else {
expect(timings['firstByte']['startTime']).toBe(0);
}
expect(timings['firstByte']['duration']).toBeGreaterThan(0);
expect(timings['download']['startTime']).toBeGreaterThan(0);
expect(timings['download']['duration']).toBeGreaterThan(0);
if (Platform.OS === 'ios') {
expect(timings['fetch']['startTime']).toBeGreaterThan(0);
} else {
expect(timings['fetch']['startTime']).toBe(0);
}
expect(timings['fetch']['duration']).toBeGreaterThan(0);
});
it(`M generate resource timings when startTracking() + XHR.open() + XHR.send() + XHR.abort(), platform=${platform}`, async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([])
});
jest.doMock('react-native/Libraries/Utilities/Platform', () => ({
OS: platform
}));
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
jest.advanceTimersByTime(50);
xhr.notifyResponseArrived();
jest.advanceTimersByTime(50);
xhr.abort();
xhr.complete(0, undefined);
await flushPromises();
// THEN
const timings =
DdNativeRum.stopResource.mock.calls[0][4][
'_dd.resource_timings'
];
if (Platform.OS === 'ios') {
expect(timings['firstByte']['startTime']).toBeGreaterThan(0);
} else {
expect(timings['firstByte']['startTime']).toBe(0);
}
expect(timings['firstByte']['duration']).toBeGreaterThan(0);
expect(timings['download']['startTime']).toBeGreaterThan(0);
expect(timings['download']['duration']).toBeGreaterThan(0);
if (Platform.OS === 'ios') {
expect(timings['fetch']['startTime']).toBeGreaterThan(0);
} else {
expect(timings['fetch']['startTime']).toBe(0);
}
expect(timings['fetch']['duration']).toBeGreaterThan(0);
});
});
describe('DdRum.stopResource calls', () => {
it('does not generate resource timings when startTracking() + XHR.open() + XHR.send() + XHR.abort() before load started', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([])
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.abort();
xhr.complete(0, undefined);
await flushPromises();
// THEN
const attributes = DdNativeRum.stopResource.mock.calls[0][4];
expect(attributes['_dd.resource_timings']).toBeUndefined();
});
it('attaches the XMLHttpRequest object containing response to the event mapper', async () => {
// GIVEN
const method = 'GET';
const url = 'https://api.example.com/v2/user';
xhrProxy.onTrackingStart({
tracingSamplingRate: 100,
firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([])
});
DdRum.registerResourceEventMapper(event => {
event.context['body'] = JSON.parse(
event.resourceContext?.response
);
return event;
});
// WHEN
const xhr = new XMLHttpRequestMock();
xhr.open(method, url);
xhr.send();
xhr.abort();
xhr.complete(200, JSON.stringify({ body: 'content' }));
await flushPromises();
// THEN
const attributes = DdNativeRum.stopResource.mock.calls[0][4];
expect(attributes['body']).toEqual({
body: 'content'
});
});
});
describe.each(
([
'blob',
'arraybuffer',
'text',
'',
'json'
] as XMLHttpRequestResponseType[]).map(responseType => {
const xhr = new XMLHttpRequestMock();
xhr.readyState = XMLHttpRequestMock.DONE;
xhr.responseType = responseType;
xhr.response = {};
const contentLength = randomInt(1_000_000_000);
xhr.setResponseHeader('Content-Length', contentLength.toString());
return {
xhr,
responseType: responseType || '_empty_',
expectedSize: contentLength
};
})
)(
'Response size from response header',
({ xhr, responseType, expectedSize }) => {
it(`M calculate response size when calculateResponseSize(), responseType=${responseType}`, () => {
// WHEN
const size = calculateResponseSize(
(xhr as unknown) as XMLHttpRequest
);
// THEN
expect(size).toEqual(expectedSize);
});
}
);
describe('response size calculation', () => {
it('calculates response size when calculateResponseSize() { responseType=blob }', () => {
// GIVEN
const xhr = new XMLHttpRequestMock();
xhr.readyState = XMLHttpRequestMock.DONE;
xhr.responseType = 'blob';
const expectedSize = randomInt(1_000_000);
xhr.response = {
get size() {
return expectedSize;
}
};
// WHEN
const size = calculateResponseSize(
(xhr as unknown) as XMLHttpRequest
);
// THEN
expect(size).toEqual(expectedSize);
});
it('calculates response size when calculateResponseSize() { responseType=arraybuffer }', () => {
// GIVEN
const xhr = new XMLHttpRequestMock();
xhr.readyState = XMLHttpRequestMock.DONE;
xhr.responseType = 'arraybuffer';
const expectedSize = randomInt(100_000);
xhr.response = new ArrayBuffer(expectedSize);
// WHEN
const size = calculateResponseSize(
(xhr as unknown) as XMLHttpRequest
);
// THEN
expect(size).toEqual(expectedSize);
});
it('calculates response size when calculateResponseSize() { responseType=text }', () => {
// GIVEN
const xhr = new XMLHttpRequestMock();
xhr.readyState = XMLHttpRequestMock.DONE;
xhr.responseType = 'text';
// size per char is 24, but in bytes it is 33.
const expectedSize = 33;
xhr.response = '{"foo": "bar+úñïçôδè ℓ"}';
// WHEN
const size = calculateResponseSize(
(xhr as unknown) as XMLHttpRequest
);
// THEN
expect(size).toEqual(expectedSize);
});
it('calculates response size when calculateResponseSize() { responseType=_empty_ }', () => {
// GIVEN
const xhr = new XMLHttpRequestMock();
xhr.readyState = XMLHttpRequestMock.DONE;
xhr.responseType = '';
// size per char is 24, but in bytes it is 33.
const expectedSize = 33;
xhr.response = '{"foo": "bar+úñïçôδè ℓ"}';
// WHEN
const size = calculateResponseSize(
(xhr as unknown) as XMLHttpRequest
);
// THEN
expect(size).toEqual(expectedSize);
});
it('calculates response size when calculateResponseSize() { responseType=json }', () => {
// GIVEN
const xhr = new XMLHttpRequestMock();
xhr.readyState = XMLHttpRequestMock.DONE;
xhr.responseType = 'json';
const expectedSize = 24;