Skip to content

Commit 12d460c

Browse files
committed
add tests for sampling decision propagation for XHR requests
1 parent 104cc88 commit 12d460c

File tree

2 files changed

+129
-4
lines changed

2 files changed

+129
-4
lines changed

packages/tracing/test/hub.test.ts

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
/* eslint-disable @typescript-eslint/unbound-method */
22
import { BrowserClient } from '@sentry/browser';
33
import { getMainCarrier, Hub } from '@sentry/hub';
4+
import * as hubModule from '@sentry/hub';
45
import * as utilsModule from '@sentry/utils'; // for mocking
56
import { getGlobalObject, isNodeEnv, logger } from '@sentry/utils';
67
import * as nodeHttpModule from 'http';
78

9+
import { BrowserTracing } from '../src/browser/browsertracing';
810
import { addExtensionMethods } from '../src/hubextensions';
11+
import { extractTraceparentData, TRACEPARENT_REGEXP } from '../src/utils';
12+
import { addDOMPropertiesToGlobal, getSymbolObjectKeyByName } from './testutils';
913

1014
addExtensionMethods();
1115

1216
const mathRandom = jest.spyOn(Math, 'random');
17+
18+
// we have to add things into the real global object (rather than mocking the return value of getGlobalObject) because
19+
// there are modules which call getGlobalObject as they load, which is seemingly too early for jest to intervene
20+
addDOMPropertiesToGlobal(['XMLHttpRequest', 'Event', 'location', 'document']);
21+
1322
describe('Hub', () => {
1423
beforeEach(() => {
1524
jest.spyOn(logger, 'warn');
@@ -339,12 +348,84 @@ describe('Hub', () => {
339348
expect(child.sampled).toBe(transaction.sampled);
340349
});
341350

342-
it('should propagate sampling decision to child transactions in XHR header', () => {
343-
// TODO fix this and write the test
351+
it('should propagate positive sampling decision to child transactions in XHR header', () => {
352+
const hub = new Hub(
353+
new BrowserClient({
354+
dsn: 'https://[email protected]/1121',
355+
tracesSampleRate: 1,
356+
integrations: [new BrowserTracing()],
357+
}),
358+
);
359+
jest.spyOn(hubModule, 'getCurrentHub').mockReturnValue(hub);
360+
361+
const transaction = hub.startTransaction({ name: 'dogpark' });
362+
hub.configureScope(scope => {
363+
scope.setSpan(transaction);
364+
});
365+
366+
const request = new XMLHttpRequest();
367+
request.open('GET', '/chase-partners');
368+
369+
// mock a response having been received successfully (we have to do it in this roundabout way because readyState
370+
// is readonly and changing it doesn't trigger a readystatechange event)
371+
Object.defineProperty(request, 'readyState', { value: 4 });
372+
request.dispatchEvent(new Event('readystatechange'));
373+
374+
// this looks weird, it's true, but it's really just `request.impl.flag.requestHeaders` - it's just that the
375+
// `impl` key is a symbol rather than a string, and therefore needs to be referred to by reference rather than
376+
// value
377+
const headers = (request as any)[getSymbolObjectKeyByName(request, 'impl') as symbol].flag.requestHeaders;
378+
379+
// check that sentry-trace header is added to request
380+
expect(headers).toEqual(expect.objectContaining({ 'sentry-trace': expect.stringMatching(TRACEPARENT_REGEXP) }));
381+
382+
// check that sampling decision is passed down correctly
383+
expect(transaction.sampled).toBe(true);
384+
expect(extractTraceparentData(headers['sentry-trace'])!.parentSampled).toBe(true);
385+
});
386+
387+
it('should propagate negative sampling decision to child transactions in XHR header', () => {
388+
const hub = new Hub(
389+
new BrowserClient({
390+
dsn: 'https://[email protected]/1121',
391+
tracesSampleRate: 1,
392+
integrations: [new BrowserTracing()],
393+
}),
394+
);
395+
jest.spyOn(hubModule, 'getCurrentHub').mockReturnValue(hub);
396+
397+
const transaction = hub.startTransaction({ name: 'dogpark', sampled: false });
398+
hub.configureScope(scope => {
399+
scope.setSpan(transaction);
400+
});
401+
402+
const request = new XMLHttpRequest();
403+
request.open('GET', '/chase-partners');
404+
405+
// mock a response having been received successfully (we have to do it in this roundabout way because readyState
406+
// is readonly and changing it doesn't trigger a readystatechange event)
407+
Object.defineProperty(request, 'readyState', { value: 4 });
408+
request.dispatchEvent(new Event('readystatechange'));
409+
410+
// this looks weird, it's true, but it's really just `request.impl.flag.requestHeaders` - it's just that the
411+
// `impl` key is a symbol rather than a string, and therefore needs to be referred to by reference rather than
412+
// value
413+
const headers = (request as any)[getSymbolObjectKeyByName(request, 'impl') as symbol].flag.requestHeaders;
414+
415+
// check that sentry-trace header is added to request
416+
expect(headers).toEqual(expect.objectContaining({ 'sentry-trace': expect.stringMatching(TRACEPARENT_REGEXP) }));
417+
418+
// check that sampling decision is passed down correctly
419+
expect(transaction.sampled).toBe(false);
420+
expect(extractTraceparentData(headers['sentry-trace'])!.parentSampled).toBe(false);
421+
});
422+
423+
it('should propagate positive sampling decision to child transactions in fetch header', () => {
424+
// TODO
344425
});
345426

346-
it('should propagate sampling decision to child transactions in fetch header', () => {
347-
// TODO fix this and write the test
427+
it('should propagate negative sampling decision to child transactions in fetch header', () => {
428+
// TODO
348429
});
349430

350431
it("should inherit parent's positive sampling decision if tracesSampler is undefined", () => {

packages/tracing/test/testutils.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { getGlobalObject } from '@sentry/utils';
2+
import { JSDOM } from 'jsdom';
3+
4+
/**
5+
* Injects DOM properties into node global object.
6+
*
7+
* Useful for running tests where some of the tested code applies to @sentry/node and some applies to @sentry/browser
8+
* (e.g. tests in @sentry/tracing or @sentry/hub). Note that not all properties from the browser `window` object are
9+
* available.
10+
*
11+
* @param properties The names of the properties to add
12+
*/
13+
export function addDOMPropertiesToGlobal(properties: string[]): void {
14+
// we have to add things into the real global object (rather than mocking the return value of getGlobalObject)
15+
// because there are modules which call getGlobalObject as they load, which is too early for jest to intervene
16+
const { window } = new JSDOM('', { url: 'http://dogs.are.great/' });
17+
const global = getGlobalObject<NodeJS.Global & Window>();
18+
19+
properties.forEach(prop => {
20+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
21+
(global as any)[prop] = window[prop];
22+
});
23+
}
24+
25+
/**
26+
* Returns the symbol with the given description being used as a key in the given object.
27+
*
28+
* In the case where there are multiple symbols in the object with the same description, it throws an error.
29+
*
30+
* @param obj The object whose symbol-type key you want
31+
* @param description The symbol's descriptor
32+
* @returns The first symbol found in the object with the given description, or undefined if not found.
33+
*/
34+
export function getSymbolObjectKeyByName(obj: Record<string | symbol, any>, description: string): symbol | undefined {
35+
const symbols = Object.getOwnPropertySymbols(obj);
36+
37+
const matches = symbols.filter(sym => sym.toString() === `Symbol(${description})`);
38+
39+
if (matches.length > 1) {
40+
throw new Error(`More than one symbol key found with description '${description}'.`);
41+
}
42+
43+
return matches[0] || undefined;
44+
}

0 commit comments

Comments
 (0)