Skip to content

Commit f26e0e0

Browse files
juan-fernandezBridgeAR
authored andcommitted
fix(instrumentation): wait for asyncEnd on promise settlement (#9538)
1 parent 4d5ba6c commit f26e0e0

4 files changed

Lines changed: 127 additions & 26 deletions

File tree

packages/datadog-instrumentations/src/helpers/rewriter/transforms.js

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,45 +17,77 @@ const { parse, query } = require('./compiler')
1717
module.exports = { waitForAsyncEnd }
1818

1919
/**
20-
* Injects a wait for `ctx.asyncEndPromise` into a generated `tracePromise`
21-
* wrapper's native-Promise fulfillment handler.
20+
* Injects settlement-specific asyncEnd waits into a generated `tracePromise`
21+
* wrapper's native-Promise handlers.
2222
*
2323
* @param {object} _state
2424
* @param {import('estree').CallExpression} node
2525
* @returns {void}
2626
*/
2727
function waitForAsyncEnd (_state, node) {
2828
const onFulfilled = node.arguments[0]
29-
const statements = onFulfilled?.body?.body
29+
const onRejected = node.arguments[1]
3030

31-
if (!statements || query(onFulfilled.body, '[id.name=__apm$asyncEndPromise]').length > 0) {
31+
if (!onFulfilled?.body || !onRejected?.body) {
3232
return
3333
}
3434

35-
const returnIndex = statements.findIndex(statement =>
36-
statement.type === 'ReturnStatement' && statement.argument
35+
injectAsyncEndCallbackWait(onFulfilled.body, 'ReturnStatement', 'resolveCallback')
36+
injectAsyncEndCallbackWait(onRejected.body, 'ThrowStatement', 'rejectCallback')
37+
}
38+
39+
/**
40+
* Injects a settlement-specific callback wait before an exit.
41+
*
42+
* @param {import('estree').BlockStatement} body
43+
* @param {'ReturnStatement'|'ThrowStatement'} exitType
44+
* @param {'resolveCallback'|'rejectCallback'} callbackProperty
45+
* @returns {void}
46+
*/
47+
function injectAsyncEndCallbackWait (body, exitType, callbackProperty) {
48+
const callbackVariable = `__apm$${callbackProperty}`
49+
if (query(body, `[id.name=${callbackVariable}]`).length > 0) {
50+
return
51+
}
52+
53+
const exitIndex = body.body.findIndex(statement =>
54+
statement.type === exitType && statement.argument
3755
)
3856

39-
// The generated fulfillment handler always ends in a return; a miss means the
57+
// The generated settlement handlers always end in a return or throw; a miss means the
4058
// upstream template changed and the caller's try/catch falls back to the
4159
// unwrapped source.
42-
assert(returnIndex !== -1, 'waitForAsyncEnd: no return statement to wait on')
60+
assert(exitIndex !== -1, `waitForAsyncEnd: no ${exitType} to wait on`)
4361

62+
// This runs inside tracePromise's native-Promise settlement handler. The
63+
// Promise adapts subscriber callback completion to that existing chain.
4464
const waitStatements = parse(`
4565
function wrapper () {
46-
const __apm$asyncEndPromise = __apm$ctx.asyncEndPromise;
47-
if (__apm$asyncEndPromise && typeof __apm$asyncEndPromise.then === 'function') {
48-
return __apm$asyncEndPromise.then(() => __apm$result, () => __apm$result);
66+
const ${callbackVariable} = __apm$ctx.${callbackProperty};
67+
if (typeof ${callbackVariable} === 'function') {
68+
return new Promise(${callbackVariable}).then(() => __apm$result, () => __apm$result);
4969
}
5070
}
5171
`).body[0].body.body
5272

53-
// Resolve to whatever the fulfillment handler returns (its return argument),
54-
// so a subscriber that reassigned `__apm$ctx.result` in `asyncEnd` still wins.
55-
const returnArgument = statements[returnIndex].argument
56-
const { arguments: onSettled } = waitStatements[1].consequent.body[0].argument
57-
onSettled[0].body = clone(returnArgument)
58-
onSettled[1].body = clone(returnArgument)
73+
const exitArgument = body.body[exitIndex].argument
74+
const callbackIf = waitStatements[1]
75+
const { arguments: onCallbackSettled } = callbackIf.consequent.body[0].argument
76+
77+
if (exitType === 'ThrowStatement') {
78+
for (const handler of onCallbackSettled) {
79+
handler.body = {
80+
type: 'BlockStatement',
81+
body: [{
82+
type: 'ThrowStatement',
83+
argument: clone(exitArgument),
84+
}],
85+
}
86+
}
87+
} else {
88+
onCallbackSettled[0].body = clone(exitArgument)
89+
onCallbackSettled[1].body = clone(exitArgument)
90+
}
5991

60-
statements.splice(returnIndex, 0, ...waitStatements)
92+
body.body.splice(exitIndex, 0, ...waitStatements)
6193
}

packages/datadog-instrumentations/src/playwright.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1494,7 +1494,8 @@ createRootSuiteCh.subscribe({
14941494
pageGotoCh.subscribe({
14951495
asyncEnd (ctx) {
14961496
// The Page.goto rewriter waits for this so tests closing immediately after navigation still get RUM tags.
1497-
ctx.asyncEndPromise = handlePageGoto(ctx.self)
1497+
const rumDetectionPromise = handlePageGoto(ctx.self)
1498+
ctx.resolveCallback = onDone => rumDetectionPromise.then(onDone, onDone)
14981499
},
14991500
})
15001501

packages/datadog-instrumentations/test/helpers/rewriter/index.spec.js

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -562,19 +562,22 @@ describe('check-require-cache', () => {
562562
assert.ok(subs.start.called)
563563
})
564564

565-
it('should wait for an asyncEnd promise when configured', async () => {
565+
it('should wait for the resolve callback only before resolving', async () => {
566566
const { test } = compileFile('trace-promise-async-end')
567567
const steps = []
568568

569569
subs = {
570570
asyncEnd (ctx) {
571571
steps.push('asyncEnd')
572-
ctx.asyncEndPromise = new Promise(resolve => {
572+
ctx.resolveCallback = onDone => {
573573
setImmediate(() => {
574-
steps.push('asyncEndPromise')
575-
resolve()
574+
steps.push('resolveCallback')
575+
onDone()
576576
})
577-
})
577+
}
578+
ctx.rejectCallback = () => {
579+
assert.fail('reject callback called for a fulfilled promise')
580+
}
578581
},
579582
}
580583

@@ -593,7 +596,68 @@ describe('check-require-cache', () => {
593596
const result = await resultPromise
594597

595598
assert.equal(result, 'result')
596-
assert.deepStrictEqual(steps, ['asyncEnd', 'asyncEndPromise', 'resolved'])
599+
assert.deepStrictEqual(steps, ['asyncEnd', 'resolveCallback', 'resolved'])
600+
})
601+
602+
it('should wait for the reject callback only before preserving a rejection', async () => {
603+
const { test } = compileFile('trace-promise-async-end')
604+
const error = new Error('test rejection')
605+
const steps = []
606+
607+
subs = {
608+
asyncEnd (ctx) {
609+
steps.push('asyncEnd')
610+
ctx.resolveCallback = () => {
611+
assert.fail('resolve callback called for a rejected promise')
612+
}
613+
ctx.rejectCallback = onDone => {
614+
setImmediate(() => {
615+
steps.push('rejectCallback')
616+
onDone()
617+
})
618+
}
619+
},
620+
}
621+
622+
ch = tracingChannel('orchestrion:test:trace_promise_async_end')
623+
ch.subscribe(subs)
624+
625+
const resultPromise = test(error)
626+
627+
await Promise.resolve()
628+
629+
assert.deepStrictEqual(steps, ['asyncEnd'])
630+
await assert.rejects(resultPromise, actualError => {
631+
steps.push('rejected')
632+
return actualError === error
633+
})
634+
assert.deepStrictEqual(steps, ['asyncEnd', 'rejectCallback', 'rejected'])
635+
})
636+
637+
it('should preserve promise settlement when its callback throws', async () => {
638+
const { test } = compileFile('trace-promise-async-end')
639+
const error = new Error('test rejection')
640+
641+
subs = {
642+
asyncEnd (ctx) {
643+
ctx.resolveCallback = () => {
644+
throw new Error('resolve callback error')
645+
}
646+
ctx.rejectCallback = () => {
647+
throw new Error('reject callback error')
648+
}
649+
},
650+
}
651+
652+
ch = tracingChannel('orchestrion:test:trace_promise_async_end')
653+
ch.subscribe(subs)
654+
655+
const [result] = await Promise.all([
656+
test(),
657+
assert.rejects(test(error), actualError => actualError === error),
658+
])
659+
660+
assert.equal(result, 'result')
597661
})
598662

599663
it('should use import when rewriting esm modules', () => {

packages/datadog-instrumentations/test/helpers/rewriter/node_modules/test/trace-promise-async-end.js

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)