Skip to content

Commit

Permalink
[test optimization] Add Dynamic Instrumentation to jest retries (#4876)
Browse files Browse the repository at this point in the history
  • Loading branch information
juan-fernandez authored Nov 27, 2024
1 parent d19f3b0 commit 5c6d126
Show file tree
Hide file tree
Showing 17 changed files with 444 additions and 26 deletions.
1 change: 1 addition & 0 deletions LICENSE-3rdparty.csv
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ require,retry,MIT,Copyright 2011 Tim Koschützki Felix Geisendörfer
require,rfdc,MIT,Copyright 2019 David Mark Clements
require,semver,ISC,Copyright Isaac Z. Schlueter and Contributors
require,shell-quote,mit,Copyright (c) 2013 James Halliday
require,source-map,BSD-3-Clause,Copyright (c) 2009-2011, Mozilla Foundation and contributors
dev,@apollo/server,MIT,Copyright (c) 2016-2020 Apollo Graph, Inc. (Formerly Meteor Development Group, Inc.)
dev,@types/node,MIT,Copyright Authors
dev,autocannon,MIT,Copyright 2016 Matteo Collina
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = function (a, b) {
const localVariable = 2
if (a > 10) {
throw new Error('a is too big')
}
return a + b + localVariable - localVariable
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/* eslint-disable */
const sum = require('./dependency')

// TODO: instead of retrying through jest, this should be retried with auto test retries
jest.retryTimes(1)

describe('dynamic-instrumentation', () => {
it('retries with DI', () => {
expect(sum(11, 3)).toEqual(14)
})

it('is not retried', () => {
expect(sum(1, 2)).toEqual(3)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/* eslint-disable */
const sum = require('./dependency')

// TODO: instead of retrying through jest, this should be retried with auto test retries
jest.retryTimes(1)

let count = 0
describe('dynamic-instrumentation', () => {
it('retries with DI', () => {
const willFail = count++ === 0
if (willFail) {
expect(sum(11, 3)).toEqual(14) // only throws the first time
} else {
expect(sum(1, 2)).toEqual(3)
}
})
})
207 changes: 206 additions & 1 deletion integration-tests/jest/jest.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ const {
TEST_SOURCE_START,
TEST_CODE_OWNERS,
TEST_SESSION_NAME,
TEST_LEVEL_EVENT_TYPES
TEST_LEVEL_EVENT_TYPES,
DI_ERROR_DEBUG_INFO_CAPTURED,
DI_DEBUG_ERROR_FILE,
DI_DEBUG_ERROR_SNAPSHOT_ID,
DI_DEBUG_ERROR_LINE
} = require('../../packages/dd-trace/src/plugins/util/test')
const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/env')
const { ERROR_MESSAGE } = require('../../packages/dd-trace/src/constants')
Expand Down Expand Up @@ -2399,4 +2403,205 @@ describe('jest CommonJS', () => {
})
})
})

context('dynamic instrumentation', () => {
it('does not activate dynamic instrumentation if DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED is not set', (done) => {
receiver.setSettings({
itr_enabled: false,
code_coverage: false,
tests_skipping: false,
flaky_test_retries_enabled: true,
early_flake_detection: {
enabled: false
}
// di_enabled: true // TODO
})
const eventsPromise = receiver
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
const events = payloads.flatMap(({ payload }) => payload.events)

const tests = events.filter(event => event.type === 'test').map(event => event.content)
const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true')

assert.equal(retriedTests.length, 1)
const [retriedTest] = retriedTests

assert.notProperty(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED)
assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_FILE)
assert.notProperty(retriedTest.metrics, DI_DEBUG_ERROR_LINE)
assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_SNAPSHOT_ID)
})
const logsPromise = receiver
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => {
if (payloads.length > 0) {
throw new Error('Unexpected logs')
}
}, 5000)

childProcess = exec(runTestsWithCoverageCommand,
{
cwd,
env: {
...getCiVisAgentlessConfig(receiver.port),
TESTS_TO_RUN: 'dynamic-instrumentation/test-hit-breakpoint'
},
stdio: 'inherit'
}
)

childProcess.on('exit', (code) => {
Promise.all([eventsPromise, logsPromise]).then(() => {
assert.equal(code, 0)
done()
}).catch(done)
})
})

it('runs retries with dynamic instrumentation', (done) => {
receiver.setSettings({
itr_enabled: false,
code_coverage: false,
tests_skipping: false,
flaky_test_retries_enabled: true,
early_flake_detection: {
enabled: false
}
// di_enabled: true // TODO
})
let snapshotIdByTest, snapshotIdByLog
let spanIdByTest, spanIdByLog, traceIdByTest, traceIdByLog
const eventsPromise = receiver
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
const events = payloads.flatMap(({ payload }) => payload.events)

const tests = events.filter(event => event.type === 'test').map(event => event.content)
const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true')

assert.equal(retriedTests.length, 1)
const [retriedTest] = retriedTests

assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true')
assert.propertyVal(
retriedTest.meta,
DI_DEBUG_ERROR_FILE,
'ci-visibility/dynamic-instrumentation/dependency.js'
)
assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4)
assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID])

snapshotIdByTest = retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID]
spanIdByTest = retriedTest.span_id.toString()
traceIdByTest = retriedTest.trace_id.toString()

const notRetriedTest = tests.find(test => test.meta[TEST_NAME].includes('is not retried'))

assert.notProperty(notRetriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED)
})

const logsPromise = receiver
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => {
const [{ logMessage: [diLog] }] = payloads
assert.deepInclude(diLog, {
ddsource: 'dd_debugger',
level: 'error'
})
assert.equal(diLog.debugger.snapshot.language, 'javascript')
assert.deepInclude(diLog.debugger.snapshot.captures.lines['4'].locals, {
a: {
type: 'number',
value: '11'
},
b: {
type: 'number',
value: '3'
},
localVariable: {
type: 'number',
value: '2'
}
})
spanIdByLog = diLog.dd.span_id
traceIdByLog = diLog.dd.trace_id
snapshotIdByLog = diLog.debugger.snapshot.id
})

childProcess = exec(runTestsWithCoverageCommand,
{
cwd,
env: {
...getCiVisAgentlessConfig(receiver.port),
TESTS_TO_RUN: 'dynamic-instrumentation/test-hit-breakpoint',
DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true'
},
stdio: 'inherit'
}
)

childProcess.on('exit', () => {
Promise.all([eventsPromise, logsPromise]).then(() => {
assert.equal(snapshotIdByTest, snapshotIdByLog)
assert.equal(spanIdByTest, spanIdByLog)
assert.equal(traceIdByTest, traceIdByLog)
done()
}).catch(done)
})
})

it('does not crash if the retry does not hit the breakpoint', (done) => {
receiver.setSettings({
itr_enabled: false,
code_coverage: false,
tests_skipping: false,
flaky_test_retries_enabled: true,
early_flake_detection: {
enabled: false
}
// di_enabled: true // TODO
})
const eventsPromise = receiver
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
const events = payloads.flatMap(({ payload }) => payload.events)

const tests = events.filter(event => event.type === 'test').map(event => event.content)
const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true')

assert.equal(retriedTests.length, 1)
const [retriedTest] = retriedTests

assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true')
assert.propertyVal(
retriedTest.meta,
DI_DEBUG_ERROR_FILE,
'ci-visibility/dynamic-instrumentation/dependency.js'
)
assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4)
assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID])
})
const logsPromise = receiver
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => {
if (payloads.length > 0) {
throw new Error('Unexpected logs')
}
}, 5000)

childProcess = exec(runTestsWithCoverageCommand,
{
cwd,
env: {
...getCiVisAgentlessConfig(receiver.port),
TESTS_TO_RUN: 'dynamic-instrumentation/test-not-hit-breakpoint',
DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true'
},
stdio: 'inherit'
}
)

childProcess.on('exit', (code) => {
Promise.all([eventsPromise, logsPromise]).then(() => {
assert.equal(code, 0)
done()
}).catch(done)
})
})
})
})
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
"rfdc": "^1.3.1",
"semver": "^7.5.4",
"shell-quote": "^1.8.1",
"source-map": "^0.7.4",
"tlhunter-sorted-set": "^0.1.0"
},
"devDependencies": {
Expand Down
14 changes: 10 additions & 4 deletions packages/datadog-instrumentations/src/jest.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,6 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
name: removeEfdStringFromTestName(testName),
suite: this.testSuite,
testSourceFile: this.testSourceFile,
runner: 'jest-circus',
displayName: this.displayName,
testParameters,
frameworkVersion: jestVersion,
Expand Down Expand Up @@ -274,13 +273,18 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
}
}
if (event.name === 'test_done') {
const probe = {}
const asyncResource = asyncResources.get(event.test)
asyncResource.runInAsyncScope(() => {
let status = 'pass'
if (event.test.errors && event.test.errors.length) {
status = 'fail'
const formattedError = formatJestError(event.test.errors[0])
testErrCh.publish(formattedError)
const numRetries = this.global[RETRY_TIMES]
const numTestExecutions = event.test?.invocations
const willBeRetried = numRetries > 0 && numTestExecutions - 1 < numRetries

const error = formatJestError(event.test.errors[0])
testErrCh.publish({ error, willBeRetried, probe, numTestExecutions })
}
testRunFinishCh.publish({
status,
Expand All @@ -302,6 +306,9 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
}
}
})
if (probe.setProbePromise) {
await probe.setProbePromise
}
}
if (event.name === 'test_skip' || event.name === 'test_todo') {
const asyncResource = new AsyncResource('bound-anonymous-fn')
Expand All @@ -310,7 +317,6 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
name: getJestTestName(event.test),
suite: this.testSuite,
testSourceFile: this.testSourceFile,
runner: 'jest-circus',
displayName: this.displayName,
frameworkVersion: jestVersion,
testStartLine: getTestLineStart(event.test.asyncError, this.testSuite)
Expand Down
Loading

0 comments on commit 5c6d126

Please sign in to comment.