Skip to content

Commit cf412d9

Browse files
authored
fix(core): Rethrow caught promise rejections in startSpan, startSpanManual, trace (#9958)
Our previous implementation of `startSpan` et.al. assumed that because we were "cloning" the original promise, unhandled promise rejections would still bubble up to the global `onunhandledrejection` handler so that Sentry would catch the error. However, we tried investigating this and found out that our "cloning" mechanism didn't work correctly and because we already added a rejection handler, the promise rejection would _not always_ make it to the global handler. After adding multiple integration tests, I further narrowed the buggy behaviour down to a rather special case: The `startSpan` call is not `await`ed which for some reason will lead to the SDK not catching the error. Unless, we apply the fix in this PR. This patch removes this cloning mechanism in favour of directly attaching a rejection handler to the promise. In this handler, we rethrow the error which should trigger the global handlers.
1 parent 948e7d3 commit cf412d9

File tree

12 files changed

+263
-13
lines changed

12 files changed

+263
-13
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
async function run() {
2+
Sentry.startSpan({ name: 'parent_span' }, () => {
3+
Sentry.startSpan({ name: 'child_span' }, () => {
4+
// whatever a user would do here
5+
});
6+
});
7+
}
8+
9+
run();
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
6+
7+
sentryTest('should send a transaction in an envelope', async ({ getLocalTestPath, page }) => {
8+
if (shouldSkipTracingTest()) {
9+
sentryTest.skip();
10+
}
11+
12+
const url = await getLocalTestPath({ testDir: __dirname });
13+
const transaction = await getFirstSentryEnvelopeRequest<Event>(page, url);
14+
15+
expect(transaction.transaction).toBe('parent_span');
16+
expect(transaction.spans).toBeDefined();
17+
});
18+
19+
sentryTest('should report finished spans as children of the root transaction', async ({ getLocalTestPath, page }) => {
20+
if (shouldSkipTracingTest()) {
21+
sentryTest.skip();
22+
}
23+
24+
const url = await getLocalTestPath({ testDir: __dirname });
25+
const transaction = await getFirstSentryEnvelopeRequest<Event>(page, url);
26+
27+
const rootSpanId = transaction?.contexts?.trace?.spanId;
28+
29+
expect(transaction.spans).toHaveLength(1);
30+
31+
const span_1 = transaction.spans?.[0];
32+
expect(span_1?.description).toBe('child_span');
33+
expect(span_1?.parentSpanId).toEqual(rootSpanId);
34+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title></title>
6+
</head>
7+
<body>
8+
<button id="button1" type="button">Button 1</button>
9+
10+
<script>
11+
async function run() {
12+
await Sentry.startSpan({ name: 'parent_span', op: 'test' }, async () => {
13+
Promise.reject('Async Promise Rejection');
14+
});
15+
}
16+
17+
const button = document.getElementById('button1');
18+
button.addEventListener('click', async () => {
19+
await run();
20+
});
21+
</script>
22+
</body>
23+
</html>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers';
6+
7+
sentryTest(
8+
'should capture a promise rejection within an async startSpan callback',
9+
async ({ getLocalTestPath, page }) => {
10+
if (shouldSkipTracingTest()) {
11+
sentryTest.skip();
12+
}
13+
14+
const url = await getLocalTestPath({ testDir: __dirname });
15+
const envelopePromise = getMultipleSentryEnvelopeRequests<Event>(page, 2);
16+
17+
await page.goto(url);
18+
19+
const clickPromise = page.getByText('Button 1').click();
20+
21+
const [, events] = await Promise.all([clickPromise, envelopePromise]);
22+
const txn = events.find(event => event.type === 'transaction');
23+
const err = events.find(event => !event.type);
24+
25+
expect(txn).toMatchObject({ transaction: 'parent_span' });
26+
27+
expect(err?.exception?.values?.[0]?.value).toBe(
28+
'Non-Error promise rejection captured with value: Async Promise Rejection',
29+
);
30+
},
31+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title></title>
6+
</head>
7+
<body>
8+
<button id="button1" type="button">Button 1</button>
9+
10+
<script type="module">
11+
async function run() {
12+
Sentry.startSpan({ name: 'parent_span', op: 'test' }, async () => {
13+
throw new Error('Async Thrown Error');
14+
});
15+
}
16+
17+
const button = document.getElementById('button1');
18+
button.addEventListener('click', () => {
19+
void run();
20+
});
21+
</script>
22+
</body>
23+
</html>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers';
6+
7+
sentryTest(
8+
"should capture a thrown error within an async startSpan callback that's not awaited properly",
9+
async ({ getLocalTestPath, page }) => {
10+
if (shouldSkipTracingTest()) {
11+
sentryTest.skip();
12+
}
13+
const envelopePromise = getMultipleSentryEnvelopeRequests<Event>(page, 2);
14+
15+
const url = await getLocalTestPath({ testDir: __dirname });
16+
await page.goto(url);
17+
18+
const clickPromise = page.getByText('Button 1').click();
19+
20+
// awaiting both events simultaneously to avoid race conditions
21+
const [, events] = await Promise.all([clickPromise, envelopePromise]);
22+
const txn = events.find(event => event.type === 'transaction');
23+
const err = events.find(event => !event.type);
24+
25+
expect(txn).toMatchObject({ transaction: 'parent_span' });
26+
expect(err?.exception?.values?.[0]?.value).toBe('Async Thrown Error');
27+
},
28+
);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title></title>
6+
</head>
7+
<body>
8+
<button id="button1" type="button">Button 1</button>
9+
10+
<script type="module">
11+
async function run() {
12+
await Promise.resolve();
13+
await Sentry.startSpan({ name: 'parent_span', op: 'test' }, async () => {
14+
throw new Error('Async Thrown Error');
15+
});
16+
}
17+
18+
const button = document.getElementById('button1');
19+
button.addEventListener('click', () => {
20+
void run();
21+
});
22+
</script>
23+
</body>
24+
</html>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers';
6+
7+
sentryTest('should capture a thrown error within an async startSpan callback', async ({ getLocalTestPath, page }) => {
8+
if (shouldSkipTracingTest()) {
9+
sentryTest.skip();
10+
}
11+
const envelopePromise = getMultipleSentryEnvelopeRequests<Event>(page, 2);
12+
13+
const url = await getLocalTestPath({ testDir: __dirname });
14+
await page.goto(url);
15+
16+
const clickPromise = page.getByText('Button 1').click();
17+
18+
// awaiting both events simultaneously to avoid race conditions
19+
const [, events] = await Promise.all([clickPromise, envelopePromise]);
20+
const txn = events.find(event => event.type === 'transaction');
21+
const err = events.find(event => !event.type);
22+
23+
expect(txn).toMatchObject({ transaction: 'parent_span' });
24+
expect(err?.exception?.values?.[0]?.value).toBe('Async Thrown Error');
25+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
function run() {
2+
Sentry.startSpan({ name: 'parent_span' }, () => {
3+
throw new Error('Sync Error');
4+
});
5+
}
6+
7+
// using `setTimeout` here because otherwise the thrown error will be
8+
// thrown as a generic "Script Error." instead of the actual error".
9+
setTimeout(run);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers';
6+
7+
sentryTest('should capture an error within a sync startSpan callback', async ({ getLocalTestPath, page }) => {
8+
if (shouldSkipTracingTest()) {
9+
sentryTest.skip();
10+
}
11+
12+
const url = await getLocalTestPath({ testDir: __dirname });
13+
const gotoPromise = page.goto(url);
14+
const envelopePromise = getMultipleSentryEnvelopeRequests<Event>(page, 2);
15+
16+
const [_, events] = await Promise.all([gotoPromise, envelopePromise]);
17+
const txn = events.find(event => event.type === 'transaction');
18+
const err = events.find(event => !event.type);
19+
20+
expect(txn).toMatchObject({ transaction: 'parent_span' });
21+
expect(err?.exception?.values?.[0]?.value).toBe('Sync Error');
22+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/* eslint-disable no-unused-vars */
2+
import * as Sentry from '@sentry/browser';
3+
// biome-ignore lint/nursery/noUnusedImports: Need to import tracing for side effect
4+
import * as _ from '@sentry/tracing';
5+
6+
window.Sentry = Sentry;
7+
8+
Sentry.init({
9+
dsn: 'https://[email protected]/1337',
10+
tracesSampleRate: 1.0,
11+
normalizeDepth: 10,
12+
debug: true,
13+
});

packages/core/src/tracing/trace.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,23 +55,25 @@ export function trace<T>(
5555
}
5656

5757
if (isThenable(maybePromiseResult)) {
58-
Promise.resolve(maybePromiseResult).then(
59-
() => {
58+
// @ts-expect-error - the isThenable check returns the "wrong" type here
59+
return maybePromiseResult.then(
60+
res => {
6061
finishAndSetSpan();
6162
afterFinish();
63+
return res;
6264
},
6365
e => {
6466
activeSpan && activeSpan.setStatus('internal_error');
6567
onError(e, activeSpan);
6668
finishAndSetSpan();
6769
afterFinish();
70+
throw e;
6871
},
6972
);
70-
} else {
71-
finishAndSetSpan();
72-
afterFinish();
7373
}
7474

75+
finishAndSetSpan();
76+
afterFinish();
7577
return maybePromiseResult;
7678
}
7779

@@ -89,6 +91,7 @@ export function trace<T>(
8991
export function startSpan<T>(context: TransactionContext, callback: (span: Span | undefined) => T): T {
9092
const ctx = normalizeContext(context);
9193

94+
// @ts-expect-error - isThenable returns the wrong type
9295
return withScope(scope => {
9396
const hub = getCurrentHub();
9497
const parentSpan = scope.getSpan();
@@ -110,19 +113,20 @@ export function startSpan<T>(context: TransactionContext, callback: (span: Span
110113
}
111114

112115
if (isThenable(maybePromiseResult)) {
113-
Promise.resolve(maybePromiseResult).then(
114-
() => {
116+
return maybePromiseResult.then(
117+
res => {
115118
finishAndSetSpan();
119+
return res;
116120
},
117-
() => {
121+
e => {
118122
activeSpan && activeSpan.setStatus('internal_error');
119123
finishAndSetSpan();
124+
throw e;
120125
},
121126
);
122-
} else {
123-
finishAndSetSpan();
124127
}
125128

129+
finishAndSetSpan();
126130
return maybePromiseResult;
127131
});
128132
}
@@ -149,6 +153,7 @@ export function startSpanManual<T>(
149153
): T {
150154
const ctx = normalizeContext(context);
151155

156+
// @ts-expect-error - isThenable returns the wrong type
152157
return withScope(scope => {
153158
const hub = getCurrentHub();
154159
const parentSpan = scope.getSpan();
@@ -169,9 +174,13 @@ export function startSpanManual<T>(
169174
}
170175

171176
if (isThenable(maybePromiseResult)) {
172-
Promise.resolve(maybePromiseResult).then(undefined, () => {
173-
activeSpan && activeSpan.setStatus('internal_error');
174-
});
177+
return maybePromiseResult.then(
178+
res => res,
179+
e => {
180+
activeSpan && activeSpan.setStatus('internal_error');
181+
throw e;
182+
},
183+
);
175184
}
176185

177186
return maybePromiseResult;

0 commit comments

Comments
 (0)