From 5c6d12624b3cfca29a49c9ef57d6890a71ea5555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Wed, 27 Nov 2024 11:40:46 +0100 Subject: [PATCH] =?UTF-8?q?[test=20optimization]=C2=A0Add=20Dynamic=20Inst?= =?UTF-8?q?rumentation=20to=20jest=20retries=20=20(#4876)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE-3rdparty.csv | 1 + .../dynamic-instrumentation/dependency.js | 7 + .../test-hit-breakpoint.js | 15 ++ .../test-not-hit-breakpoint.js | 17 ++ integration-tests/jest/jest.spec.js | 207 +++++++++++++++++- package.json | 1 + packages/datadog-instrumentations/src/jest.js | 14 +- packages/datadog-plugin-jest/src/index.js | 77 ++++++- .../dynamic-instrumentation/index.js | 9 +- .../dynamic-instrumentation/worker/index.js | 38 +++- .../exporters/ci-visibility-exporter.js | 28 ++- .../src/debugger/devtools_client/state.js | 2 +- packages/dd-trace/src/plugin_manager.js | 6 +- packages/dd-trace/src/plugins/ci_plugin.js | 6 + packages/dd-trace/src/plugins/util/test.js | 35 ++- packages/dd-trace/src/proxy.js | 5 + yarn.lock | 2 +- 17 files changed, 444 insertions(+), 26 deletions(-) create mode 100644 integration-tests/ci-visibility/dynamic-instrumentation/dependency.js create mode 100644 integration-tests/ci-visibility/dynamic-instrumentation/test-hit-breakpoint.js create mode 100644 integration-tests/ci-visibility/dynamic-instrumentation/test-not-hit-breakpoint.js diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index f078d0aa4ae..f8147f23e35 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -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 diff --git a/integration-tests/ci-visibility/dynamic-instrumentation/dependency.js b/integration-tests/ci-visibility/dynamic-instrumentation/dependency.js new file mode 100644 index 00000000000..b53ebf22f97 --- /dev/null +++ b/integration-tests/ci-visibility/dynamic-instrumentation/dependency.js @@ -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 +} diff --git a/integration-tests/ci-visibility/dynamic-instrumentation/test-hit-breakpoint.js b/integration-tests/ci-visibility/dynamic-instrumentation/test-hit-breakpoint.js new file mode 100644 index 00000000000..fdecdb06edb --- /dev/null +++ b/integration-tests/ci-visibility/dynamic-instrumentation/test-hit-breakpoint.js @@ -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) + }) +}) diff --git a/integration-tests/ci-visibility/dynamic-instrumentation/test-not-hit-breakpoint.js b/integration-tests/ci-visibility/dynamic-instrumentation/test-not-hit-breakpoint.js new file mode 100644 index 00000000000..a4a75aab832 --- /dev/null +++ b/integration-tests/ci-visibility/dynamic-instrumentation/test-not-hit-breakpoint.js @@ -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) + } + }) +}) diff --git a/integration-tests/jest/jest.spec.js b/integration-tests/jest/jest.spec.js index 27b70329533..c1f13db9c4d 100644 --- a/integration-tests/jest/jest.spec.js +++ b/integration-tests/jest/jest.spec.js @@ -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') @@ -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) + }) + }) + }) }) diff --git a/package.json b/package.json index 26fe1a5fabe..f39bcd5a68a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/datadog-instrumentations/src/jest.js b/packages/datadog-instrumentations/src/jest.js index b17a4137c96..440021f03de 100644 --- a/packages/datadog-instrumentations/src/jest.js +++ b/packages/datadog-instrumentations/src/jest.js @@ -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, @@ -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, @@ -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') @@ -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) diff --git a/packages/datadog-plugin-jest/src/index.js b/packages/datadog-plugin-jest/src/index.js index 4362094b0be..0b3f87f0e6e 100644 --- a/packages/datadog-plugin-jest/src/index.js +++ b/packages/datadog-plugin-jest/src/index.js @@ -22,7 +22,14 @@ const { TEST_EARLY_FLAKE_ABORT_REASON, JEST_DISPLAY_NAME, TEST_IS_RUM_ACTIVE, - TEST_BROWSER_DRIVER + TEST_BROWSER_DRIVER, + getFileAndLineNumberFromError, + DI_ERROR_DEBUG_INFO_CAPTURED, + DI_DEBUG_ERROR_SNAPSHOT_ID, + DI_DEBUG_ERROR_FILE, + DI_DEBUG_ERROR_LINE, + getTestSuitePath, + TEST_NAME } = require('../../dd-trace/src/plugins/util/test') const { COMPONENT } = require('../../dd-trace/src/constants') const id = require('../../dd-trace/src/id') @@ -39,6 +46,7 @@ const { } = require('../../dd-trace/src/ci-visibility/telemetry') const isJestWorker = !!process.env.JEST_WORKER_ID +const debuggerParameterPerTest = new Map() // https://github.com/facebook/jest/blob/d6ad15b0f88a05816c2fe034dd6900d28315d570/packages/jest-worker/src/types.ts#L38 const CHILD_MESSAGE_END = 2 @@ -301,6 +309,29 @@ class JestPlugin extends CiPlugin { const span = this.startTestSpan(test) this.enter(span, store) + + const { name: testName } = test + + const debuggerParameters = debuggerParameterPerTest.get(testName) + + // If we have a debugger probe, we need to add the snapshot id to the span + if (debuggerParameters) { + const spanContext = span.context() + + // TODO: handle race conditions with this.retriedTestIds + this.retriedTestIds = { + spanId: spanContext.toSpanId(), + traceId: spanContext.toTraceId() + } + const { snapshotId, file, line } = debuggerParameters + + // TODO: should these be added on test:end if and only if the probe is hit? + // Sync issues: `hitProbePromise` might be resolved after the test ends + span.setTag(DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + span.setTag(DI_DEBUG_ERROR_SNAPSHOT_ID, snapshotId) + span.setTag(DI_DEBUG_ERROR_FILE, file) + span.setTag(DI_DEBUG_ERROR_LINE, line) + } }) this.addSub('ci:jest:test:finish', ({ status, testStartLine }) => { @@ -326,13 +357,19 @@ class JestPlugin extends CiPlugin { finishAllTraceSpans(span) }) - this.addSub('ci:jest:test:err', (error) => { + this.addSub('ci:jest:test:err', ({ error, willBeRetried, probe }) => { if (error) { const store = storage.getStore() if (store && store.span) { const span = store.span span.setTag(TEST_STATUS, 'fail') span.setTag('error', error) + if (willBeRetried && this.di) { + // if we use numTestExecutions, we have to remove the breakpoint after each execution + const testName = span.context()._tags[TEST_NAME] + const debuggerParameters = this.addDiProbe(error, probe) + debuggerParameterPerTest.set(testName, debuggerParameters) + } } } }) @@ -344,11 +381,43 @@ class JestPlugin extends CiPlugin { }) } + // TODO: If the test finishes and the probe is not hit, we should remove the breakpoint + addDiProbe (err, probe) { + const [file, line] = getFileAndLineNumberFromError(err) + + const relativePath = getTestSuitePath(file, this.repositoryRoot) + + const [ + snapshotId, + setProbePromise, + hitProbePromise + ] = this.di.addLineProbe({ file: relativePath, line }) + + probe.setProbePromise = setProbePromise + + hitProbePromise.then(({ snapshot }) => { + // TODO: handle race conditions for this.retriedTestIds + const { traceId, spanId } = this.retriedTestIds + this.tracer._exporter.exportDiLogs(this.testEnvironmentMetadata, { + debugger: { snapshot }, + dd: { + trace_id: traceId, + span_id: spanId + } + }) + }) + + return { + snapshotId, + file: relativePath, + line + } + } + startTestSpan (test) { const { suite, name, - runner, displayName, testParameters, frameworkVersion, @@ -360,7 +429,7 @@ class JestPlugin extends CiPlugin { } = test const extraTags = { - [JEST_TEST_RUNNER]: runner, + [JEST_TEST_RUNNER]: 'jest-circus', [TEST_PARAMETERS]: testParameters, [TEST_FRAMEWORK_VERSION]: frameworkVersion } diff --git a/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js b/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js index 97323d02407..ef65489e60d 100644 --- a/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js +++ b/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js @@ -73,8 +73,7 @@ class TestVisDynamicInstrumentation { // Allow the parent to exit even if the worker is still running this.worker.unref() - this.breakpointSetChannel.port2.on('message', (message) => { - const { probeId } = message + this.breakpointSetChannel.port2.on('message', ({ probeId }) => { const resolve = probeIdToResolveBreakpointSet.get(probeId) if (resolve) { resolve() @@ -82,8 +81,7 @@ class TestVisDynamicInstrumentation { } }).unref() - this.breakpointHitChannel.port2.on('message', (message) => { - const { snapshot } = message + this.breakpointHitChannel.port2.on('message', ({ snapshot }) => { const { probe: { id: probeId } } = snapshot const resolve = probeIdToResolveBreakpointHit.get(probeId) if (resolve) { @@ -91,6 +89,9 @@ class TestVisDynamicInstrumentation { probeIdToResolveBreakpointHit.delete(probeId) } }).unref() + + this.worker.on('error', (err) => log.error(err)) + this.worker.on('messageerror', (err) => log.error(err)) } } diff --git a/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js b/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js index 4bef76e6343..fbcb52da239 100644 --- a/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js +++ b/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js @@ -1,6 +1,8 @@ 'use strict' - +const sourceMap = require('source-map') +const path = require('path') const { workerData: { breakpointSetChannel, breakpointHitChannel } } = require('worker_threads') + // TODO: move debugger/devtools_client/session to common place const session = require('../../../debugger/devtools_client/session') // TODO: move debugger/devtools_client/snapshot to common place @@ -69,14 +71,20 @@ async function addBreakpoint (snapshotId, probe) { const script = findScriptFromPartialPath(file) if (!script) throw new Error(`No loaded script found for ${file}`) - const [path, scriptId] = script + const [path, scriptId, sourceMapURL] = script log.debug(`Adding breakpoint at ${path}:${line}`) + let generatedPosition = { line } + + if (sourceMapURL && sourceMapURL.startsWith('data:')) { + generatedPosition = await processScriptWithInlineSourceMap({ file, line, sourceMapURL }) + } + const { breakpointId } = await session.post('Debugger.setBreakpoint', { location: { scriptId, - lineNumber: line - 1 + lineNumber: generatedPosition.line } }) @@ -88,3 +96,27 @@ function start () { sessionStarted = true return session.post('Debugger.enable') // return instead of await to reduce number of promises created } + +async function processScriptWithInlineSourceMap (params) { + const { file, line, sourceMapURL } = params + + // Extract the base64-encoded source map + const base64SourceMap = sourceMapURL.split('base64,')[1] + + // Decode the base64 source map + const decodedSourceMap = Buffer.from(base64SourceMap, 'base64').toString('utf8') + + // Parse the source map + const consumer = await new sourceMap.SourceMapConsumer(decodedSourceMap) + + // Map to the generated position + const generatedPosition = consumer.generatedPositionFor({ + source: path.basename(file), // this needs to be the file, not the filepath + line, + column: 0 + }) + + consumer.destroy() + + return generatedPosition +} diff --git a/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js b/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js index f555603e0cb..0a12d5f8c5a 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +++ b/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js @@ -73,6 +73,9 @@ class CiVisibilityExporter extends AgentInfoExporter { if (this._coverageWriter) { this._coverageWriter.flush() } + if (this._logsWriter) { + this._logsWriter.flush() + } }) } @@ -302,13 +305,28 @@ class CiVisibilityExporter extends AgentInfoExporter { if (!this._isInitialized) { return done() } - this._writer.flush(() => { - if (this._coverageWriter) { - this._coverageWriter.flush(done) - } else { + + // TODO: safe to do them at once? Or do we want to do them one by one? + const writers = [ + this._writer, + this._coverageWriter, + this._logsWriter + ].filter(writer => writer) + + let remaining = writers.length + + if (remaining === 0) { + return done() + } + + const onFlushComplete = () => { + remaining -= 1 + if (remaining === 0) { done() } - }) + } + + writers.forEach(writer => writer.flush(onFlushComplete)) } exportUncodedCoverages () { diff --git a/packages/dd-trace/src/debugger/devtools_client/state.js b/packages/dd-trace/src/debugger/devtools_client/state.js index c409a69f6b7..a69a37067f4 100644 --- a/packages/dd-trace/src/debugger/devtools_client/state.js +++ b/packages/dd-trace/src/debugger/devtools_client/state.js @@ -57,6 +57,6 @@ module.exports = { session.on('Debugger.scriptParsed', ({ params }) => { scriptUrls.set(params.scriptId, params.url) if (params.url.startsWith('file:')) { - scriptIds.push([params.url, params.scriptId]) + scriptIds.push([params.url, params.scriptId, params.sourceMapURL]) } }) diff --git a/packages/dd-trace/src/plugin_manager.js b/packages/dd-trace/src/plugin_manager.js index e9daea9b60b..74cc656048b 100644 --- a/packages/dd-trace/src/plugin_manager.js +++ b/packages/dd-trace/src/plugin_manager.js @@ -138,7 +138,8 @@ module.exports = class PluginManager { clientIpEnabled, memcachedCommandEnabled, ciVisibilityTestSessionName, - ciVisAgentlessLogSubmissionEnabled + ciVisAgentlessLogSubmissionEnabled, + isTestDynamicInstrumentationEnabled } = this._tracerConfig const sharedConfig = { @@ -149,7 +150,8 @@ module.exports = class PluginManager { url, headers: headerTags || [], ciVisibilityTestSessionName, - ciVisAgentlessLogSubmissionEnabled + ciVisAgentlessLogSubmissionEnabled, + isTestDynamicInstrumentationEnabled } if (logInjection !== undefined) { diff --git a/packages/dd-trace/src/plugins/ci_plugin.js b/packages/dd-trace/src/plugins/ci_plugin.js index d4c9f32bc68..f6692fa4b23 100644 --- a/packages/dd-trace/src/plugins/ci_plugin.js +++ b/packages/dd-trace/src/plugins/ci_plugin.js @@ -180,6 +180,12 @@ module.exports = class CiPlugin extends Plugin { configure (config) { super.configure(config) + + if (config.isTestDynamicInstrumentationEnabled) { + const testVisibilityDynamicInstrumentation = require('../ci-visibility/dynamic-instrumentation') + this.di = testVisibilityDynamicInstrumentation + } + this.testEnvironmentMetadata = getTestEnvironmentMetadata(this.constructor.id, this.config) const { diff --git a/packages/dd-trace/src/plugins/util/test.js b/packages/dd-trace/src/plugins/util/test.js index 6c0dde70cfb..8719c916915 100644 --- a/packages/dd-trace/src/plugins/util/test.js +++ b/packages/dd-trace/src/plugins/util/test.js @@ -106,6 +106,13 @@ const TEST_LEVEL_EVENT_TYPES = [ 'test_session_end' ] +// Dynamic instrumentation - Test optimization integration tags +const DI_ERROR_DEBUG_INFO_CAPTURED = 'error.debug_info_captured' +// TODO: for the moment we'll only use a single snapshot id, so `0` is hardcoded +const DI_DEBUG_ERROR_SNAPSHOT_ID = '_dd.debug.error.0.snapshot_id' +const DI_DEBUG_ERROR_FILE = '_dd.debug.error.0.file' +const DI_DEBUG_ERROR_LINE = '_dd.debug.error.0.line' + module.exports = { TEST_CODE_OWNERS, TEST_SESSION_NAME, @@ -181,7 +188,12 @@ module.exports = { TEST_BROWSER_VERSION, getTestSessionName, TEST_LEVEL_EVENT_TYPES, - getNumFromKnownTests + getNumFromKnownTests, + getFileAndLineNumberFromError, + DI_ERROR_DEBUG_INFO_CAPTURED, + DI_DEBUG_ERROR_SNAPSHOT_ID, + DI_DEBUG_ERROR_FILE, + DI_DEBUG_ERROR_LINE } // Returns pkg manager and its version, separated by '-', e.g. npm-8.15.0 or yarn-1.22.19 @@ -637,3 +649,24 @@ function getNumFromKnownTests (knownTests) { return totalNumTests } + +function getFileAndLineNumberFromError (error) { + // Split the stack trace into individual lines + const stackLines = error.stack.split('\n') + + // The top frame is usually the second line + const topFrame = stackLines[1] + + // Regular expression to match the file path, line number, and column number + const regex = /\s*at\s+(?:.*\()?(.+):(\d+):(\d+)\)?/ + const match = topFrame.match(regex) + + if (match) { + const filePath = match[1] + const lineNumber = Number(match[2]) + const columnNumber = Number(match[3]) + + return [filePath, lineNumber, columnNumber] + } + return [] +} diff --git a/packages/dd-trace/src/proxy.js b/packages/dd-trace/src/proxy.js index 5c113399601..81d003eebb7 100644 --- a/packages/dd-trace/src/proxy.js +++ b/packages/dd-trace/src/proxy.js @@ -181,6 +181,11 @@ class Tracer extends NoopProxy { ) } } + + if (config.isTestDynamicInstrumentationEnabled) { + const testVisibilityDynamicInstrumentation = require('./ci-visibility/dynamic-instrumentation') + testVisibilityDynamicInstrumentation.start() + } } catch (e) { log.error(e) } diff --git a/yarn.lock b/yarn.lock index 2e4b4c17ce5..0efe56a17c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4530,7 +4530,7 @@ source-map@^0.6.0, source-map@^0.6.1: source-map@^0.7.4: version "0.7.4" - resolved "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== spawn-wrap@^2.0.0: