Skip to content

Commit b771888

Browse files
[test optimization] Add Dynamic Instrumentation support for Vitest (#4959)
1 parent 3296eb8 commit b771888

File tree

7 files changed

+288
-10
lines changed

7 files changed

+288
-10
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function sum (a, b) {
2+
const localVar = 10
3+
if (a > 10) {
4+
throw new Error('a is too large')
5+
}
6+
return a + b + localVar - localVar
7+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, test, expect } from 'vitest'
2+
import { sum } from './bad-sum'
3+
4+
let numAttempt = 0
5+
6+
describe('dynamic instrumentation', () => {
7+
test('can sum', () => {
8+
const shouldFail = numAttempt++ === 0
9+
if (shouldFail) {
10+
expect(sum(11, 2)).to.equal(13)
11+
} else {
12+
expect(sum(1, 2)).to.equal(3)
13+
}
14+
})
15+
test('is not retried', () => {
16+
expect(sum(1, 2)).to.equal(3)
17+
})
18+
})
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { describe, test, expect } from 'vitest'
2+
import { sum } from './bad-sum'
3+
4+
describe('dynamic instrumentation', () => {
5+
test('can sum', () => {
6+
expect(sum(11, 2)).to.equal(13)
7+
})
8+
test('is not retried', () => {
9+
expect(sum(1, 2)).to.equal(3)
10+
})
11+
})

integration-tests/vitest/vitest.spec.js

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ const {
2424
TEST_NAME,
2525
TEST_EARLY_FLAKE_ENABLED,
2626
TEST_EARLY_FLAKE_ABORT_REASON,
27-
TEST_SUITE
27+
TEST_SUITE,
28+
DI_ERROR_DEBUG_INFO_CAPTURED,
29+
DI_DEBUG_ERROR_FILE,
30+
DI_DEBUG_ERROR_LINE,
31+
DI_DEBUG_ERROR_SNAPSHOT_ID
2832
} = require('../../packages/dd-trace/src/plugins/util/test')
2933
const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/env')
3034

@@ -896,5 +900,204 @@ versions.forEach((version) => {
896900
})
897901
})
898902
})
903+
904+
// dynamic instrumentation only supported from >=2.0.0
905+
if (version === 'latest') {
906+
context('dynamic instrumentation', () => {
907+
it('does not activate it if DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED is not set', (done) => {
908+
receiver.setSettings({
909+
itr_enabled: false,
910+
code_coverage: false,
911+
tests_skipping: false,
912+
flaky_test_retries_enabled: false
913+
})
914+
915+
const eventsPromise = receiver
916+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
917+
const events = payloads.flatMap(({ payload }) => payload.events)
918+
919+
const tests = events.filter(event => event.type === 'test').map(event => event.content)
920+
const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true')
921+
922+
assert.equal(retriedTests.length, 1)
923+
const [retriedTest] = retriedTests
924+
925+
assert.notProperty(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED)
926+
assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_FILE)
927+
assert.notProperty(retriedTest.metrics, DI_DEBUG_ERROR_LINE)
928+
assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_SNAPSHOT_ID)
929+
})
930+
931+
const logsPromise = receiver
932+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => {
933+
if (payloads.length > 0) {
934+
throw new Error('Unexpected logs')
935+
}
936+
}, 5000)
937+
938+
childProcess = exec(
939+
'./node_modules/.bin/vitest run --retry=1',
940+
{
941+
cwd,
942+
env: {
943+
...getCiVisAgentlessConfig(receiver.port),
944+
TEST_DIR: 'ci-visibility/vitest-tests/dynamic-instrumentation*',
945+
NODE_OPTIONS: '--import dd-trace/register.js -r dd-trace/ci/init'
946+
},
947+
stdio: 'pipe'
948+
}
949+
)
950+
951+
childProcess.on('exit', () => {
952+
Promise.all([eventsPromise, logsPromise]).then(() => {
953+
done()
954+
}).catch(done)
955+
})
956+
})
957+
958+
it('runs retries with dynamic instrumentation', (done) => {
959+
receiver.setSettings({
960+
itr_enabled: false,
961+
code_coverage: false,
962+
tests_skipping: false,
963+
flaky_test_retries_enabled: false,
964+
early_flake_detection: {
965+
enabled: false
966+
}
967+
// di_enabled: true // TODO
968+
})
969+
970+
let snapshotIdByTest, snapshotIdByLog
971+
let spanIdByTest, spanIdByLog, traceIdByTest, traceIdByLog
972+
973+
const eventsPromise = receiver
974+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
975+
const events = payloads.flatMap(({ payload }) => payload.events)
976+
977+
const tests = events.filter(event => event.type === 'test').map(event => event.content)
978+
const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true')
979+
980+
assert.equal(retriedTests.length, 1)
981+
const [retriedTest] = retriedTests
982+
983+
assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true')
984+
assert.propertyVal(
985+
retriedTest.meta,
986+
DI_DEBUG_ERROR_FILE,
987+
'ci-visibility/vitest-tests/bad-sum.mjs'
988+
)
989+
assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4)
990+
assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID])
991+
992+
snapshotIdByTest = retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID]
993+
spanIdByTest = retriedTest.span_id.toString()
994+
traceIdByTest = retriedTest.trace_id.toString()
995+
996+
const notRetriedTest = tests.find(test => test.meta[TEST_NAME].includes('is not retried'))
997+
998+
assert.notProperty(notRetriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED)
999+
})
1000+
1001+
const logsPromise = receiver
1002+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => {
1003+
const [{ logMessage: [diLog] }] = payloads
1004+
assert.deepInclude(diLog, {
1005+
ddsource: 'dd_debugger',
1006+
level: 'error'
1007+
})
1008+
assert.equal(diLog.debugger.snapshot.language, 'javascript')
1009+
assert.deepInclude(diLog.debugger.snapshot.captures.lines['4'].locals, {
1010+
a: {
1011+
type: 'number',
1012+
value: '11'
1013+
},
1014+
b: {
1015+
type: 'number',
1016+
value: '2'
1017+
},
1018+
localVar: {
1019+
type: 'number',
1020+
value: '10'
1021+
}
1022+
})
1023+
spanIdByLog = diLog.dd.span_id
1024+
traceIdByLog = diLog.dd.trace_id
1025+
snapshotIdByLog = diLog.debugger.snapshot.id
1026+
}, 5000)
1027+
1028+
childProcess = exec(
1029+
'./node_modules/.bin/vitest run --retry=1',
1030+
{
1031+
cwd,
1032+
env: {
1033+
...getCiVisAgentlessConfig(receiver.port),
1034+
TEST_DIR: 'ci-visibility/vitest-tests/dynamic-instrumentation*',
1035+
NODE_OPTIONS: '--import dd-trace/register.js -r dd-trace/ci/init',
1036+
DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: '1'
1037+
},
1038+
stdio: 'pipe'
1039+
}
1040+
)
1041+
1042+
childProcess.on('exit', () => {
1043+
Promise.all([eventsPromise, logsPromise]).then(() => {
1044+
assert.equal(snapshotIdByTest, snapshotIdByLog)
1045+
assert.equal(spanIdByTest, spanIdByLog)
1046+
assert.equal(traceIdByTest, traceIdByLog)
1047+
done()
1048+
}).catch(done)
1049+
})
1050+
})
1051+
1052+
it('does not crash if the retry does not hit the breakpoint', (done) => {
1053+
const eventsPromise = receiver
1054+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
1055+
const events = payloads.flatMap(({ payload }) => payload.events)
1056+
1057+
const tests = events.filter(event => event.type === 'test').map(event => event.content)
1058+
const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true')
1059+
1060+
assert.equal(retriedTests.length, 1)
1061+
const [retriedTest] = retriedTests
1062+
1063+
assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true')
1064+
assert.propertyVal(
1065+
retriedTest.meta,
1066+
DI_DEBUG_ERROR_FILE,
1067+
'ci-visibility/vitest-tests/bad-sum.mjs'
1068+
)
1069+
assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4)
1070+
assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID])
1071+
})
1072+
1073+
const logsPromise = receiver
1074+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => {
1075+
if (payloads.length > 0) {
1076+
throw new Error('Unexpected logs')
1077+
}
1078+
}, 5000)
1079+
1080+
childProcess = exec(
1081+
'./node_modules/.bin/vitest run --retry=1',
1082+
{
1083+
cwd,
1084+
env: {
1085+
...getCiVisAgentlessConfig(receiver.port),
1086+
TEST_DIR: 'ci-visibility/vitest-tests/breakpoint-not-hit*',
1087+
NODE_OPTIONS: '--import dd-trace/register.js -r dd-trace/ci/init',
1088+
DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: '1'
1089+
},
1090+
stdio: 'pipe'
1091+
}
1092+
)
1093+
1094+
childProcess.on('exit', () => {
1095+
Promise.all([eventsPromise, logsPromise]).then(() => {
1096+
done()
1097+
}).catch(done)
1098+
})
1099+
})
1100+
})
1101+
}
8991102
})
9001103
})

packages/datadog-instrumentations/src/vitest.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,12 +316,17 @@ addHook({
316316

317317
// We finish the previous test here because we know it has failed already
318318
if (numAttempt > 0) {
319+
const probe = {}
319320
const asyncResource = taskToAsync.get(task)
320321
const testError = task.result?.errors?.[0]
321322
if (asyncResource) {
322323
asyncResource.runInAsyncScope(() => {
323-
testErrorCh.publish({ error: testError })
324+
testErrorCh.publish({ error: testError, willBeRetried: true, probe })
324325
})
326+
// We wait for the probe to be set
327+
if (probe.setProbePromise) {
328+
await probe.setProbePromise
329+
}
325330
}
326331
}
327332

packages/datadog-plugin-vitest/src/index.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ const {
1717
TEST_SOURCE_START,
1818
TEST_IS_NEW,
1919
TEST_EARLY_FLAKE_ENABLED,
20-
TEST_EARLY_FLAKE_ABORT_REASON
20+
TEST_EARLY_FLAKE_ABORT_REASON,
21+
TEST_NAME,
22+
DI_ERROR_DEBUG_INFO_CAPTURED,
23+
DI_DEBUG_ERROR_SNAPSHOT_ID,
24+
DI_DEBUG_ERROR_FILE,
25+
DI_DEBUG_ERROR_LINE
2126
} = require('../../dd-trace/src/plugins/util/test')
2227
const { COMPONENT } = require('../../dd-trace/src/constants')
2328
const {
@@ -31,6 +36,8 @@ const {
3136
// This is because there's some loss of resolution.
3237
const MILLISECONDS_TO_SUBTRACT_FROM_FAILED_TEST_DURATION = 5
3338

39+
const debuggerParameterPerTest = new Map()
40+
3441
class VitestPlugin extends CiPlugin {
3542
static get id () {
3643
return 'vitest'
@@ -81,6 +88,26 @@ class VitestPlugin extends CiPlugin {
8188
extraTags
8289
)
8390

91+
const debuggerParameters = debuggerParameterPerTest.get(testName)
92+
93+
if (debuggerParameters) {
94+
const spanContext = span.context()
95+
96+
// TODO: handle race conditions with this.retriedTestIds
97+
this.retriedTestIds = {
98+
spanId: spanContext.toSpanId(),
99+
traceId: spanContext.toTraceId()
100+
}
101+
const { snapshotId, file, line } = debuggerParameters
102+
103+
// TODO: should these be added on test:end if and only if the probe is hit?
104+
// Sync issues: `hitProbePromise` might be resolved after the test ends
105+
span.setTag(DI_ERROR_DEBUG_INFO_CAPTURED, 'true')
106+
span.setTag(DI_DEBUG_ERROR_SNAPSHOT_ID, snapshotId)
107+
span.setTag(DI_DEBUG_ERROR_FILE, file)
108+
span.setTag(DI_DEBUG_ERROR_LINE, line)
109+
}
110+
84111
this.enter(span, store)
85112
})
86113

@@ -110,11 +137,16 @@ class VitestPlugin extends CiPlugin {
110137
}
111138
})
112139

113-
this.addSub('ci:vitest:test:error', ({ duration, error }) => {
140+
this.addSub('ci:vitest:test:error', ({ duration, error, willBeRetried, probe }) => {
114141
const store = storage.getStore()
115142
const span = store?.span
116143

117144
if (span) {
145+
if (willBeRetried && this.di) {
146+
const testName = span.context()._tags[TEST_NAME]
147+
const debuggerParameters = this.addDiProbe(error, probe)
148+
debuggerParameterPerTest.set(testName, debuggerParameters)
149+
}
118150
this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'test', {
119151
hasCodeowners: !!span.context()._tags[TEST_CODE_OWNERS]
120152
})

packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,20 @@ async function addBreakpoint (snapshotId, probe) {
7575

7676
log.debug(`Adding breakpoint at ${path}:${line}`)
7777

78-
let generatedPosition = { line }
79-
let hasSourceMap = false
78+
let lineNumber = line
8079

8180
if (sourceMapURL && sourceMapURL.startsWith('data:')) {
82-
hasSourceMap = true
83-
generatedPosition = await processScriptWithInlineSourceMap({ file, line, sourceMapURL })
81+
try {
82+
lineNumber = await processScriptWithInlineSourceMap({ file, line, sourceMapURL })
83+
} catch (err) {
84+
log.error(err)
85+
}
8486
}
8587

8688
const { breakpointId } = await session.post('Debugger.setBreakpoint', {
8789
location: {
8890
scriptId,
89-
lineNumber: hasSourceMap ? generatedPosition.line : generatedPosition.line - 1
91+
lineNumber: lineNumber - 1
9092
}
9193
})
9294

@@ -120,5 +122,5 @@ async function processScriptWithInlineSourceMap (params) {
120122

121123
consumer.destroy()
122124

123-
return generatedPosition
125+
return generatedPosition.line
124126
}

0 commit comments

Comments
 (0)