Skip to content

Commit 5c6d126

Browse files
[test optimization] Add Dynamic Instrumentation to jest retries (#4876)
1 parent d19f3b0 commit 5c6d126

File tree

17 files changed

+444
-26
lines changed

17 files changed

+444
-26
lines changed

LICENSE-3rdparty.csv

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ require,retry,MIT,Copyright 2011 Tim Koschützki Felix Geisendörfer
3131
require,rfdc,MIT,Copyright 2019 David Mark Clements
3232
require,semver,ISC,Copyright Isaac Z. Schlueter and Contributors
3333
require,shell-quote,mit,Copyright (c) 2013 James Halliday
34+
require,source-map,BSD-3-Clause,Copyright (c) 2009-2011, Mozilla Foundation and contributors
3435
dev,@apollo/server,MIT,Copyright (c) 2016-2020 Apollo Graph, Inc. (Formerly Meteor Development Group, Inc.)
3536
dev,@types/node,MIT,Copyright Authors
3637
dev,autocannon,MIT,Copyright 2016 Matteo Collina
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module.exports = function (a, b) {
2+
const localVariable = 2
3+
if (a > 10) {
4+
throw new Error('a is too big')
5+
}
6+
return a + b + localVariable - localVariable
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/* eslint-disable */
2+
const sum = require('./dependency')
3+
4+
// TODO: instead of retrying through jest, this should be retried with auto test retries
5+
jest.retryTimes(1)
6+
7+
describe('dynamic-instrumentation', () => {
8+
it('retries with DI', () => {
9+
expect(sum(11, 3)).toEqual(14)
10+
})
11+
12+
it('is not retried', () => {
13+
expect(sum(1, 2)).toEqual(3)
14+
})
15+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/* eslint-disable */
2+
const sum = require('./dependency')
3+
4+
// TODO: instead of retrying through jest, this should be retried with auto test retries
5+
jest.retryTimes(1)
6+
7+
let count = 0
8+
describe('dynamic-instrumentation', () => {
9+
it('retries with DI', () => {
10+
const willFail = count++ === 0
11+
if (willFail) {
12+
expect(sum(11, 3)).toEqual(14) // only throws the first time
13+
} else {
14+
expect(sum(1, 2)).toEqual(3)
15+
}
16+
})
17+
})

integration-tests/jest/jest.spec.js

+206-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ const {
3333
TEST_SOURCE_START,
3434
TEST_CODE_OWNERS,
3535
TEST_SESSION_NAME,
36-
TEST_LEVEL_EVENT_TYPES
36+
TEST_LEVEL_EVENT_TYPES,
37+
DI_ERROR_DEBUG_INFO_CAPTURED,
38+
DI_DEBUG_ERROR_FILE,
39+
DI_DEBUG_ERROR_SNAPSHOT_ID,
40+
DI_DEBUG_ERROR_LINE
3741
} = require('../../packages/dd-trace/src/plugins/util/test')
3842
const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/env')
3943
const { ERROR_MESSAGE } = require('../../packages/dd-trace/src/constants')
@@ -2399,4 +2403,205 @@ describe('jest CommonJS', () => {
23992403
})
24002404
})
24012405
})
2406+
2407+
context('dynamic instrumentation', () => {
2408+
it('does not activate dynamic instrumentation if DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED is not set', (done) => {
2409+
receiver.setSettings({
2410+
itr_enabled: false,
2411+
code_coverage: false,
2412+
tests_skipping: false,
2413+
flaky_test_retries_enabled: true,
2414+
early_flake_detection: {
2415+
enabled: false
2416+
}
2417+
// di_enabled: true // TODO
2418+
})
2419+
const eventsPromise = receiver
2420+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
2421+
const events = payloads.flatMap(({ payload }) => payload.events)
2422+
2423+
const tests = events.filter(event => event.type === 'test').map(event => event.content)
2424+
const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true')
2425+
2426+
assert.equal(retriedTests.length, 1)
2427+
const [retriedTest] = retriedTests
2428+
2429+
assert.notProperty(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED)
2430+
assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_FILE)
2431+
assert.notProperty(retriedTest.metrics, DI_DEBUG_ERROR_LINE)
2432+
assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_SNAPSHOT_ID)
2433+
})
2434+
const logsPromise = receiver
2435+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => {
2436+
if (payloads.length > 0) {
2437+
throw new Error('Unexpected logs')
2438+
}
2439+
}, 5000)
2440+
2441+
childProcess = exec(runTestsWithCoverageCommand,
2442+
{
2443+
cwd,
2444+
env: {
2445+
...getCiVisAgentlessConfig(receiver.port),
2446+
TESTS_TO_RUN: 'dynamic-instrumentation/test-hit-breakpoint'
2447+
},
2448+
stdio: 'inherit'
2449+
}
2450+
)
2451+
2452+
childProcess.on('exit', (code) => {
2453+
Promise.all([eventsPromise, logsPromise]).then(() => {
2454+
assert.equal(code, 0)
2455+
done()
2456+
}).catch(done)
2457+
})
2458+
})
2459+
2460+
it('runs retries with dynamic instrumentation', (done) => {
2461+
receiver.setSettings({
2462+
itr_enabled: false,
2463+
code_coverage: false,
2464+
tests_skipping: false,
2465+
flaky_test_retries_enabled: true,
2466+
early_flake_detection: {
2467+
enabled: false
2468+
}
2469+
// di_enabled: true // TODO
2470+
})
2471+
let snapshotIdByTest, snapshotIdByLog
2472+
let spanIdByTest, spanIdByLog, traceIdByTest, traceIdByLog
2473+
const eventsPromise = receiver
2474+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
2475+
const events = payloads.flatMap(({ payload }) => payload.events)
2476+
2477+
const tests = events.filter(event => event.type === 'test').map(event => event.content)
2478+
const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true')
2479+
2480+
assert.equal(retriedTests.length, 1)
2481+
const [retriedTest] = retriedTests
2482+
2483+
assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true')
2484+
assert.propertyVal(
2485+
retriedTest.meta,
2486+
DI_DEBUG_ERROR_FILE,
2487+
'ci-visibility/dynamic-instrumentation/dependency.js'
2488+
)
2489+
assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4)
2490+
assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID])
2491+
2492+
snapshotIdByTest = retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID]
2493+
spanIdByTest = retriedTest.span_id.toString()
2494+
traceIdByTest = retriedTest.trace_id.toString()
2495+
2496+
const notRetriedTest = tests.find(test => test.meta[TEST_NAME].includes('is not retried'))
2497+
2498+
assert.notProperty(notRetriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED)
2499+
})
2500+
2501+
const logsPromise = receiver
2502+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => {
2503+
const [{ logMessage: [diLog] }] = payloads
2504+
assert.deepInclude(diLog, {
2505+
ddsource: 'dd_debugger',
2506+
level: 'error'
2507+
})
2508+
assert.equal(diLog.debugger.snapshot.language, 'javascript')
2509+
assert.deepInclude(diLog.debugger.snapshot.captures.lines['4'].locals, {
2510+
a: {
2511+
type: 'number',
2512+
value: '11'
2513+
},
2514+
b: {
2515+
type: 'number',
2516+
value: '3'
2517+
},
2518+
localVariable: {
2519+
type: 'number',
2520+
value: '2'
2521+
}
2522+
})
2523+
spanIdByLog = diLog.dd.span_id
2524+
traceIdByLog = diLog.dd.trace_id
2525+
snapshotIdByLog = diLog.debugger.snapshot.id
2526+
})
2527+
2528+
childProcess = exec(runTestsWithCoverageCommand,
2529+
{
2530+
cwd,
2531+
env: {
2532+
...getCiVisAgentlessConfig(receiver.port),
2533+
TESTS_TO_RUN: 'dynamic-instrumentation/test-hit-breakpoint',
2534+
DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true'
2535+
},
2536+
stdio: 'inherit'
2537+
}
2538+
)
2539+
2540+
childProcess.on('exit', () => {
2541+
Promise.all([eventsPromise, logsPromise]).then(() => {
2542+
assert.equal(snapshotIdByTest, snapshotIdByLog)
2543+
assert.equal(spanIdByTest, spanIdByLog)
2544+
assert.equal(traceIdByTest, traceIdByLog)
2545+
done()
2546+
}).catch(done)
2547+
})
2548+
})
2549+
2550+
it('does not crash if the retry does not hit the breakpoint', (done) => {
2551+
receiver.setSettings({
2552+
itr_enabled: false,
2553+
code_coverage: false,
2554+
tests_skipping: false,
2555+
flaky_test_retries_enabled: true,
2556+
early_flake_detection: {
2557+
enabled: false
2558+
}
2559+
// di_enabled: true // TODO
2560+
})
2561+
const eventsPromise = receiver
2562+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
2563+
const events = payloads.flatMap(({ payload }) => payload.events)
2564+
2565+
const tests = events.filter(event => event.type === 'test').map(event => event.content)
2566+
const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true')
2567+
2568+
assert.equal(retriedTests.length, 1)
2569+
const [retriedTest] = retriedTests
2570+
2571+
assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true')
2572+
assert.propertyVal(
2573+
retriedTest.meta,
2574+
DI_DEBUG_ERROR_FILE,
2575+
'ci-visibility/dynamic-instrumentation/dependency.js'
2576+
)
2577+
assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4)
2578+
assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID])
2579+
})
2580+
const logsPromise = receiver
2581+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => {
2582+
if (payloads.length > 0) {
2583+
throw new Error('Unexpected logs')
2584+
}
2585+
}, 5000)
2586+
2587+
childProcess = exec(runTestsWithCoverageCommand,
2588+
{
2589+
cwd,
2590+
env: {
2591+
...getCiVisAgentlessConfig(receiver.port),
2592+
TESTS_TO_RUN: 'dynamic-instrumentation/test-not-hit-breakpoint',
2593+
DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true'
2594+
},
2595+
stdio: 'inherit'
2596+
}
2597+
)
2598+
2599+
childProcess.on('exit', (code) => {
2600+
Promise.all([eventsPromise, logsPromise]).then(() => {
2601+
assert.equal(code, 0)
2602+
done()
2603+
}).catch(done)
2604+
})
2605+
})
2606+
})
24022607
})

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
"rfdc": "^1.3.1",
114114
"semver": "^7.5.4",
115115
"shell-quote": "^1.8.1",
116+
"source-map": "^0.7.4",
116117
"tlhunter-sorted-set": "^0.1.0"
117118
},
118119
"devDependencies": {

packages/datadog-instrumentations/src/jest.js

+10-4
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,6 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
237237
name: removeEfdStringFromTestName(testName),
238238
suite: this.testSuite,
239239
testSourceFile: this.testSourceFile,
240-
runner: 'jest-circus',
241240
displayName: this.displayName,
242241
testParameters,
243242
frameworkVersion: jestVersion,
@@ -274,13 +273,18 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
274273
}
275274
}
276275
if (event.name === 'test_done') {
276+
const probe = {}
277277
const asyncResource = asyncResources.get(event.test)
278278
asyncResource.runInAsyncScope(() => {
279279
let status = 'pass'
280280
if (event.test.errors && event.test.errors.length) {
281281
status = 'fail'
282-
const formattedError = formatJestError(event.test.errors[0])
283-
testErrCh.publish(formattedError)
282+
const numRetries = this.global[RETRY_TIMES]
283+
const numTestExecutions = event.test?.invocations
284+
const willBeRetried = numRetries > 0 && numTestExecutions - 1 < numRetries
285+
286+
const error = formatJestError(event.test.errors[0])
287+
testErrCh.publish({ error, willBeRetried, probe, numTestExecutions })
284288
}
285289
testRunFinishCh.publish({
286290
status,
@@ -302,6 +306,9 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
302306
}
303307
}
304308
})
309+
if (probe.setProbePromise) {
310+
await probe.setProbePromise
311+
}
305312
}
306313
if (event.name === 'test_skip' || event.name === 'test_todo') {
307314
const asyncResource = new AsyncResource('bound-anonymous-fn')
@@ -310,7 +317,6 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
310317
name: getJestTestName(event.test),
311318
suite: this.testSuite,
312319
testSourceFile: this.testSourceFile,
313-
runner: 'jest-circus',
314320
displayName: this.displayName,
315321
frameworkVersion: jestVersion,
316322
testStartLine: getTestLineStart(event.test.asyncError, this.testSuite)

0 commit comments

Comments
 (0)