Skip to content

Commit 5feacb2

Browse files
author
Luca Forstner
committed
Merge remote-tracking branch 'origin/develop' into lforst-unify-linked-errors
2 parents 6269499 + c55943f commit 5feacb2

File tree

24 files changed

+673
-301
lines changed

24 files changed

+673
-301
lines changed

packages/browser-integration-tests/suites/replay/slowClick/multiClick/test.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { expect } from '@playwright/test';
22

33
import { sentryTest } from '../../../../utils/fixtures';
4-
import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
4+
import {
5+
getCustomRecordingEvents,
6+
shouldSkipReplayTest,
7+
waitForReplayRequest,
8+
waitForReplayRequests,
9+
} from '../../../../utils/replayHelpers';
510

611
sentryTest('captures multi click when not detecting slow click', async ({ getLocalTestUrl, page }) => {
712
if (shouldSkipReplayTest()) {
@@ -58,3 +63,97 @@ sentryTest('captures multi click when not detecting slow click', async ({ getLoc
5863
},
5964
]);
6065
});
66+
67+
sentryTest('captures multiple multi clicks', async ({ getLocalTestUrl, page, forceFlushReplay }) => {
68+
if (shouldSkipReplayTest()) {
69+
sentryTest.skip();
70+
}
71+
72+
const reqPromise0 = waitForReplayRequest(page, 0);
73+
74+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
75+
return route.fulfill({
76+
status: 200,
77+
contentType: 'application/json',
78+
body: JSON.stringify({ id: 'test-id' }),
79+
});
80+
});
81+
82+
const url = await getLocalTestUrl({ testDir: __dirname });
83+
84+
await page.goto(url);
85+
await reqPromise0;
86+
87+
let multiClickBreadcrumbCount = 0;
88+
89+
const reqsPromise = waitForReplayRequests(page, (_event, res) => {
90+
const { breadcrumbs } = getCustomRecordingEvents(res);
91+
const count = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.multiClick').length;
92+
93+
multiClickBreadcrumbCount += count;
94+
95+
if (multiClickBreadcrumbCount === 2) {
96+
return true;
97+
}
98+
99+
return false;
100+
});
101+
102+
await page.click('#mutationButtonImmediately', { clickCount: 4 });
103+
await forceFlushReplay();
104+
105+
// Ensure we waited at least 1s, which is the threshold to create a new ui.click breadcrumb
106+
await new Promise(resolve => setTimeout(resolve, 1001));
107+
108+
await page.click('#mutationButtonImmediately', { clickCount: 2 });
109+
await forceFlushReplay();
110+
111+
const responses = await reqsPromise;
112+
113+
const slowClickBreadcrumbs = responses
114+
.flatMap(res => getCustomRecordingEvents(res).breadcrumbs)
115+
.filter(breadcrumb => breadcrumb.category === 'ui.multiClick');
116+
117+
expect(slowClickBreadcrumbs).toEqual([
118+
{
119+
category: 'ui.multiClick',
120+
type: 'default',
121+
data: {
122+
clickCount: 6,
123+
metric: true,
124+
node: {
125+
attributes: {
126+
id: 'mutationButtonImmediately',
127+
},
128+
id: expect.any(Number),
129+
tagName: 'button',
130+
textContent: '******* ******** ***********',
131+
},
132+
nodeId: expect.any(Number),
133+
url: 'http://sentry-test.io/index.html',
134+
},
135+
message: 'body > button#mutationButtonImmediately',
136+
timestamp: expect.any(Number),
137+
},
138+
{
139+
category: 'ui.multiClick',
140+
type: 'default',
141+
data: {
142+
clickCount: 2,
143+
metric: true,
144+
node: {
145+
attributes: {
146+
id: 'mutationButtonImmediately',
147+
},
148+
id: expect.any(Number),
149+
tagName: 'button',
150+
textContent: '******* ******** ***********',
151+
},
152+
nodeId: expect.any(Number),
153+
url: 'http://sentry-test.io/index.html',
154+
},
155+
message: 'body > button#mutationButtonImmediately',
156+
timestamp: expect.any(Number),
157+
},
158+
]);
159+
});

packages/browser-integration-tests/utils/replayHelpers.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,46 @@ export function waitForReplayRequest(
101101
);
102102
}
103103

104+
/**
105+
* Wait until a callback returns true, collecting all replay responses along the way.
106+
* This can be useful when you don't know if stuff will be in one or multiple replay requests.
107+
*/
108+
export function waitForReplayRequests(
109+
page: Page,
110+
callback: (event: ReplayEvent, res: Response) => boolean,
111+
timeout?: number,
112+
): Promise<Response[]> {
113+
const responses: Response[] = [];
114+
115+
return new Promise<Response[]>(resolve => {
116+
void page.waitForResponse(
117+
res => {
118+
const req = res.request();
119+
120+
const event = getReplayEventFromRequest(req);
121+
122+
if (!event) {
123+
return false;
124+
}
125+
126+
responses.push(res);
127+
128+
try {
129+
if (callback(event, res)) {
130+
resolve(responses);
131+
return true;
132+
}
133+
134+
return false;
135+
} catch {
136+
return false;
137+
}
138+
},
139+
timeout ? { timeout } : undefined,
140+
);
141+
});
142+
}
143+
104144
export function isReplayEvent(event: Event): event is ReplayEvent {
105145
return event.type === 'replay_event';
106146
}

packages/core/src/metadata.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Event, StackParser } from '@sentry/types';
2+
import { GLOBAL_OBJ } from '@sentry/utils';
3+
4+
/** Keys are source filename/url, values are metadata objects. */
5+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6+
const filenameMetadataMap = new Map<string, any>();
7+
/** Set of stack strings that have already been parsed. */
8+
const parsedStacks = new Set<string>();
9+
10+
function ensureMetadataStacksAreParsed(parser: StackParser): void {
11+
if (!GLOBAL_OBJ._sentryModuleMetadata) {
12+
return;
13+
}
14+
15+
for (const stack of Object.keys(GLOBAL_OBJ._sentryModuleMetadata)) {
16+
const metadata = GLOBAL_OBJ._sentryModuleMetadata[stack];
17+
18+
if (parsedStacks.has(stack)) {
19+
continue;
20+
}
21+
22+
// Ensure this stack doesn't get parsed again
23+
parsedStacks.add(stack);
24+
25+
const frames = parser(stack);
26+
27+
// Go through the frames starting from the top of the stack and find the first one with a filename
28+
for (const frame of frames.reverse()) {
29+
if (frame.filename) {
30+
// Save the metadata for this filename
31+
filenameMetadataMap.set(frame.filename, metadata);
32+
break;
33+
}
34+
}
35+
}
36+
}
37+
38+
/**
39+
* Retrieve metadata for a specific JavaScript file URL.
40+
*
41+
* Metadata is injected by the Sentry bundler plugins using the `_experiments.moduleMetadata` config option.
42+
*/
43+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44+
export function getMetadataForUrl(parser: StackParser, filename: string): any | undefined {
45+
ensureMetadataStacksAreParsed(parser);
46+
return filenameMetadataMap.get(filename);
47+
}
48+
49+
/**
50+
* Adds metadata to stack frames.
51+
*
52+
* Metadata is injected by the Sentry bundler plugins using the `_experiments.moduleMetadata` config option.
53+
*/
54+
export function addMetadataToStackFrames(parser: StackParser, event: Event): void {
55+
try {
56+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
57+
event.exception!.values!.forEach(exception => {
58+
if (!exception.stacktrace) {
59+
return;
60+
}
61+
62+
for (const frame of exception.stacktrace.frames || []) {
63+
if (!frame.filename) {
64+
continue;
65+
}
66+
67+
const metadata = getMetadataForUrl(parser, frame.filename);
68+
69+
if (metadata) {
70+
frame.module_metadata = metadata;
71+
}
72+
}
73+
});
74+
} catch (_) {
75+
// To save bundle size we're just try catching here instead of checking for the existence of all the different objects.
76+
}
77+
}
78+
79+
/**
80+
* Strips metadata from stack frames.
81+
*/
82+
export function stripMetadataFromStackFrames(event: Event): void {
83+
try {
84+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
85+
event.exception!.values!.forEach(exception => {
86+
if (!exception.stacktrace) {
87+
return;
88+
}
89+
90+
for (const frame of exception.stacktrace.frames || []) {
91+
delete frame.module_metadata;
92+
}
93+
});
94+
} catch (_) {
95+
// To save bundle size we're just try catching here instead of checking for the existence of all the different objects.
96+
}
97+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Event } from '@sentry/types';
2+
import { createStackParser, GLOBAL_OBJ, nodeStackLineParser } from '@sentry/utils';
3+
4+
import { addMetadataToStackFrames, getMetadataForUrl, stripMetadataFromStackFrames } from '../../src/metadata';
5+
6+
const parser = createStackParser(nodeStackLineParser());
7+
8+
const stack = new Error().stack || '';
9+
10+
const event: Event = {
11+
exception: {
12+
values: [
13+
{
14+
stacktrace: {
15+
frames: [
16+
{
17+
filename: '<anonymous>',
18+
function: 'new Promise',
19+
},
20+
{
21+
filename: '/tmp/utils.js',
22+
function: 'Promise.then.completed',
23+
lineno: 391,
24+
colno: 28,
25+
},
26+
{
27+
filename: __filename,
28+
function: 'Object.<anonymous>',
29+
lineno: 9,
30+
colno: 19,
31+
},
32+
],
33+
},
34+
},
35+
],
36+
},
37+
};
38+
39+
describe('Metadata', () => {
40+
beforeEach(() => {
41+
GLOBAL_OBJ._sentryModuleMetadata = GLOBAL_OBJ._sentryModuleMetadata || {};
42+
GLOBAL_OBJ._sentryModuleMetadata[stack] = { team: 'frontend' };
43+
});
44+
45+
it('is parsed', () => {
46+
const metadata = getMetadataForUrl(parser, __filename);
47+
48+
expect(metadata).toEqual({ team: 'frontend' });
49+
});
50+
51+
it('is added and stripped from stack frames', () => {
52+
addMetadataToStackFrames(parser, event);
53+
54+
expect(event.exception?.values?.[0].stacktrace?.frames).toEqual([
55+
{
56+
filename: '<anonymous>',
57+
function: 'new Promise',
58+
},
59+
{
60+
filename: '/tmp/utils.js',
61+
function: 'Promise.then.completed',
62+
lineno: 391,
63+
colno: 28,
64+
},
65+
{
66+
filename: __filename,
67+
function: 'Object.<anonymous>',
68+
lineno: 9,
69+
colno: 19,
70+
module_metadata: {
71+
team: 'frontend',
72+
},
73+
},
74+
]);
75+
76+
stripMetadataFromStackFrames(event);
77+
78+
expect(event.exception?.values?.[0].stacktrace?.frames).toEqual([
79+
{
80+
filename: '<anonymous>',
81+
function: 'new Promise',
82+
},
83+
{
84+
filename: '/tmp/utils.js',
85+
function: 'Promise.then.completed',
86+
lineno: 391,
87+
colno: 28,
88+
},
89+
{
90+
filename: __filename,
91+
function: 'Object.<anonymous>',
92+
lineno: 9,
93+
colno: 19,
94+
},
95+
]);
96+
});
97+
});

packages/node/src/handlers.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,12 @@ import type { AddRequestDataToEventOptions } from '@sentry/utils';
1212
import {
1313
addExceptionMechanism,
1414
addRequestDataToTransaction,
15-
baggageHeaderToDynamicSamplingContext,
1615
dropUndefinedKeys,
1716
extractPathForTransaction,
18-
extractTraceparentData,
1917
isString,
2018
logger,
2119
normalize,
20+
tracingContextFromHeaders,
2221
} from '@sentry/utils';
2322
import type * as http from 'http';
2423

@@ -63,11 +62,13 @@ export function tracingHandler(): (
6362
return next();
6463
}
6564

66-
// If there is a trace header set, we extract the data from it (parentSpanId, traceId, and sampling decision)
67-
const traceparentData =
68-
req.headers && isString(req.headers['sentry-trace']) && extractTraceparentData(req.headers['sentry-trace']);
69-
const incomingBaggageHeaders = req.headers?.baggage;
70-
const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(incomingBaggageHeaders);
65+
const sentryTrace = req.headers && isString(req.headers['sentry-trace']) ? req.headers['sentry-trace'] : undefined;
66+
const baggage = req.headers?.baggage;
67+
const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders(
68+
sentryTrace,
69+
baggage,
70+
);
71+
hub.getScope().setPropagationContext(propagationContext);
7172

7273
const [name, source] = extractPathForTransaction(req, { path: true, method: true });
7374
const transaction = startTransaction(

0 commit comments

Comments
 (0)