diff --git a/.github/codeql_config.yml b/.github/codeql_config.yml deleted file mode 100644 index 9a810cb2ed5..00000000000 --- a/.github/codeql_config.yml +++ /dev/null @@ -1,9 +0,0 @@ -name: "CodeQL config" - -paths-ignore: - - 'benchmark' - - 'integration-tests' - - 'node-upstream-tests' - - 'packages/**/test' - - 'scripts' - - 'vendor/dist' diff --git a/.github/workflows/apm-integrations.yml b/.github/workflows/apm-integrations.yml index feb8e411cd1..886e11795f0 100644 --- a/.github/workflows/apm-integrations.yml +++ b/.github/workflows/apm-integrations.yml @@ -334,8 +334,6 @@ jobs: matrix: node-version: [eol] range: - # - '^2.6.12' skipping due to bug with couchbase integration that is blocking CI. - # TODO: diagnose and fix failures. Link to bug issue: https://github.com/DataDog/dd-trace-js/issues/6400 - "^3.0.7" - ">=4.0.0 <4.2.0" include: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 535bb8610a3..00000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,54 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [master, mq-working-branch-master-*] - pull_request: - # The branches below must be a subset of the branches above - branches: [master] - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: ["javascript"] - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 - with: - languages: ${{ matrix.language }} - config-file: .github/codeql_config.yml - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - - name: Autobuild - uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 diff --git a/.gitignore b/.gitignore index 97c1dbc8d36..32d996a5046 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ Temporary Items # Logs logs *.log +node-*-junit.xml npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/integration-tests/ci-visibility/automatic-log-submission-playwright/automatic-log-submission-test.js b/integration-tests/ci-visibility/automatic-log-submission-playwright/automatic-log-submission-test.js index 912f124b872..864ce722ca6 100644 --- a/integration-tests/ci-visibility/automatic-log-submission-playwright/automatic-log-submission-test.js +++ b/integration-tests/ci-visibility/automatic-log-submission-playwright/automatic-log-submission-test.js @@ -10,7 +10,7 @@ test.beforeEach(async ({ page }) => { test.describe('playwright', () => { test('should be able to log to the console', async ({ page }) => { - test.step('log to the console', async () => { + await test.step('log to the console', async () => { logger.log('info', 'Hello simple log!') }) diff --git a/integration-tests/ci-visibility/jest-stack-on-import/jest-stack-on-import-test.js b/integration-tests/ci-visibility/jest-stack-on-import/jest-stack-on-import-test.js new file mode 100644 index 00000000000..e5a93b98c45 --- /dev/null +++ b/integration-tests/ci-visibility/jest-stack-on-import/jest-stack-on-import-test.js @@ -0,0 +1,9 @@ +'use strict' + +const stack = require('./read-stack') + +describe('stack during module import', () => { + it('can read a default error stack while loading the test module', () => { + expect(stack).toContain('Error: stack from module import') + }) +}) diff --git a/integration-tests/ci-visibility/jest-stack-on-import/read-stack.js b/integration-tests/ci-visibility/jest-stack-on-import/read-stack.js new file mode 100644 index 00000000000..7371c9ef7ba --- /dev/null +++ b/integration-tests/ci-visibility/jest-stack-on-import/read-stack.js @@ -0,0 +1,9 @@ +'use strict' + +const stack = new Error('stack from module import').stack + +if (!stack.includes('stack from module import')) { + throw new Error('Expected stack to include the original error message') +} + +module.exports = stack diff --git a/integration-tests/ci-visibility/playwright-efd-duration/efd-duration-test.js b/integration-tests/ci-visibility/playwright-efd-duration/efd-duration-test.js new file mode 100644 index 00000000000..4e9d67649ec --- /dev/null +++ b/integration-tests/ci-visibility/playwright-efd-duration/efd-duration-test.js @@ -0,0 +1,14 @@ +'use strict' + +const { test, expect } = require('@playwright/test') + +test.describe('efd duration retries', () => { + test('instant test', async () => { + expect(1 + 1).toBe(2) + }) + + test('slightly slow test', async () => { + await new Promise(resolve => setTimeout(resolve, 11_000)) + expect(1 + 1).toBe(2) + }) +}) diff --git a/integration-tests/ci-visibility/playwright-efd-projects/project-duration-test.js b/integration-tests/ci-visibility/playwright-efd-projects/project-duration-test.js new file mode 100644 index 00000000000..8d26a09f507 --- /dev/null +++ b/integration-tests/ci-visibility/playwright-efd-projects/project-duration-test.js @@ -0,0 +1,12 @@ +'use strict' + +const { test, expect } = require('@playwright/test') + +test.describe('efd project duration', () => { + test('project scoped test', async ({ browserName }, testInfo) => { + if (browserName && testInfo.project.name === 'second-chromium') { + await new Promise(resolve => setTimeout(resolve, 6_000)) + } + expect(1 + 1).toBe(2) + }) +}) diff --git a/integration-tests/ci-visibility/playwright-efd-repeat-duration/repeat-duration-test.js b/integration-tests/ci-visibility/playwright-efd-repeat-duration/repeat-duration-test.js new file mode 100644 index 00000000000..dfe8d817cd7 --- /dev/null +++ b/integration-tests/ci-visibility/playwright-efd-repeat-duration/repeat-duration-test.js @@ -0,0 +1,13 @@ +'use strict' + +const { test, expect } = require('@playwright/test') + +test.describe('efd repeat duration', () => { + test('repeat-scoped test', async () => { + if (test.info().repeatEachIndex === 0) { + await new Promise(resolve => setTimeout(resolve, 6_000)) + } + + expect(1 + 1).toBe(2) + }) +}) diff --git a/integration-tests/ci-visibility/playwright-efd-repeat/repeat-each-test.js b/integration-tests/ci-visibility/playwright-efd-repeat/repeat-each-test.js new file mode 100644 index 00000000000..3097d051caf --- /dev/null +++ b/integration-tests/ci-visibility/playwright-efd-repeat/repeat-each-test.js @@ -0,0 +1,9 @@ +'use strict' + +const { test, expect } = require('@playwright/test') + +test.describe('efd repeat each', () => { + test('native repeat test', async () => { + expect(1 + 1).toBe(2) + }) +}) diff --git a/integration-tests/ci-visibility/test-optimization-wrong-init.spec.js b/integration-tests/ci-visibility/test-optimization-wrong-init.spec.js index 5b781fffe04..bdda60fdf87 100644 --- a/integration-tests/ci-visibility/test-optimization-wrong-init.spec.js +++ b/integration-tests/ci-visibility/test-optimization-wrong-init.spec.js @@ -19,7 +19,10 @@ const testFrameworks = [ { testFramework: 'jest', command: 'node ./ci-visibility/test-optimization-wrong-init/run-jest.js', - expectedOutput: 'PASS ci-visibility/test-optimization-wrong-init/sum-wrong-init-test.js', + expectedOutput: [ + 'PASS ci-visibility/test-optimization-wrong-init/sum-wrong-init-test.js', + 'Test Suites:\\s+1 passed, 1 total', + ].join('|'), }, { testFramework: 'vitest', diff --git a/integration-tests/cucumber/cucumber.spec.js b/integration-tests/cucumber/cucumber.spec.js index ee8c0346554..1053d90c29e 100644 --- a/integration-tests/cucumber/cucumber.spec.js +++ b/integration-tests/cucumber/cucumber.spec.js @@ -81,6 +81,13 @@ const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/ const { NODE_MAJOR } = require('../../version') const { ERROR_MESSAGE, ERROR_TYPE, ERROR_STACK } = require('../../packages/dd-trace/src/constants') +function assertItrSkippingEnabledTags (events, expected) { + const testSuite = events.find(event => event.type === 'test_suite_end').content + assert.strictEqual(testSuite.meta[TEST_ITR_SKIPPING_ENABLED], expected) + const test = events.find(event => event.type === 'test').content + assert.strictEqual(test.meta[TEST_ITR_SKIPPING_ENABLED], expected) +} + const version = process.env.CUCUMBER_VERSION || 'latest' const onlyLatestIt = version === 'latest' ? it : it.skip @@ -646,6 +653,7 @@ describe(`cucumber@${version} commonJS`, () => { assert.strictEqual(testModule.meta[TEST_ITR_TESTS_SKIPPED], 'false') assert.strictEqual(testModule.meta[TEST_CODE_COVERAGE_ENABLED], 'false') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'false') + assertItrSkippingEnabledTags(payload.events, 'false') }, ({ url }) => url.endsWith('/api/v2/citestcycle')).then(() => done()).catch(done) childProcess = exec( @@ -720,6 +728,7 @@ describe(`cucumber@${version} commonJS`, () => { assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'true') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_TYPE], 'suite') assert.strictEqual(testModule.metrics[TEST_ITR_SKIPPING_COUNT], 1) + assertItrSkippingEnabledTags(eventsRequest.payload.events, 'true') done() }).catch(done) @@ -763,6 +772,7 @@ describe(`cucumber@${version} commonJS`, () => { assert.strictEqual(testModule.meta[TEST_ITR_TESTS_SKIPPED], 'false') assert.strictEqual(testModule.meta[TEST_CODE_COVERAGE_ENABLED], 'true') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'true') + assertItrSkippingEnabledTags(payload.events, 'true') }, ({ url }) => url.endsWith('/api/v2/citestcycle')).then(() => done()).catch(done) childProcess = exec( @@ -962,6 +972,7 @@ describe(`cucumber@${version} commonJS`, () => { assert.strictEqual(testModule.meta[TEST_CODE_COVERAGE_ENABLED], 'true') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'true') assert.strictEqual(testModule.metrics[TEST_ITR_SKIPPING_COUNT], 0) + assertItrSkippingEnabledTags(events, 'true') }, 25000) childProcess = exec( @@ -1130,6 +1141,7 @@ describe(`cucumber@${version} commonJS`, () => { tests.forEach(test => { assert.ok(!test.meta[TEST_SUITE].includes('farewell')) }) + assertItrSkippingEnabledTags(events, 'true') }) childProcess = exec( diff --git a/integration-tests/cypress/cypress-itr.spec.js b/integration-tests/cypress/cypress-itr.spec.js index 05c5694e7a7..620d25321b2 100644 --- a/integration-tests/cypress/cypress-itr.spec.js +++ b/integration-tests/cypress/cypress-itr.spec.js @@ -33,6 +33,13 @@ const oldestVersion = DD_MAJOR >= 6 ? '12.0.0' : '6.7.0' const version = requestedVersion === 'oldest' ? oldestVersion : requestedVersion const hookFile = 'dd-trace/loader-hook.mjs' +function assertItrSkippingEnabledTags (events, expected) { + const testSuite = events.find(event => event.type === 'test_suite_end').content + assert.strictEqual(testSuite.meta[TEST_ITR_SKIPPING_ENABLED], expected) + const test = events.find(event => event.type === 'test').content + assert.strictEqual(test.meta[TEST_ITR_SKIPPING_ENABLED], expected) +} + function shouldTestsRun (type) { if (DD_MAJOR === 5) { if (NODE_MAJOR <= 16) { @@ -270,6 +277,7 @@ moduleTypes.forEach(({ assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'true') assert.strictEqual(testModule.metrics[TEST_ITR_SKIPPING_COUNT], 1) assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_TYPE], 'test') + assertItrSkippingEnabledTags(events, 'true') }, 25000) const coverageRequestPromise = receiver @@ -528,6 +536,7 @@ moduleTypes.forEach(({ assert.strictEqual(testModule.meta[TEST_CODE_COVERAGE_ENABLED], 'true') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'true') assert.strictEqual(testModule.metrics[TEST_ITR_SKIPPING_COUNT], 0) + assertItrSkippingEnabledTags(events, 'true') }, 30000) const skippableRequestPromise = receiver diff --git a/integration-tests/esbuild/package.json b/integration-tests/esbuild/package.json index 84f8ad6904c..e1d4f4f1c45 100644 --- a/integration-tests/esbuild/package.json +++ b/integration-tests/esbuild/package.json @@ -20,13 +20,13 @@ "author": "Thomas Hunter II ", "license": "ISC", "dependencies": { - "@apollo/server": "5.5.0", + "@apollo/server": "5.5.1", "@koa/router": "15.5.0", "aws-sdk": "2.1693.0", "axios": "1.16.0", "express": "4.22.1", "knex": "3.2.10", "koa": "3.2.0", - "openai": "6.35.0" + "openai": "6.37.0" } } diff --git a/integration-tests/jest/jest.core.spec.js b/integration-tests/jest/jest.core.spec.js index 1febf3204f0..c6be5afbcf6 100644 --- a/integration-tests/jest/jest.core.spec.js +++ b/integration-tests/jest/jest.core.spec.js @@ -1108,6 +1108,42 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }) }) + it('keeps default stack formatting when imported modules read error stacks', async () => { + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + const suites = events.filter(event => event.type === 'test_suite_end') + const stackImportSuites = suites.filter( + suite => suite.content.meta[TEST_SUITE] === + 'ci-visibility/jest-stack-on-import/jest-stack-on-import-test.js' + ) + + assert.strictEqual(stackImportSuites.length, 1) + assert.strictEqual(stackImportSuites[0].content.meta[TEST_STATUS], 'pass') + }) + + childProcess = exec(runTestsCommand, { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: 'jest-stack-on-import/jest-stack-on-import-test', + SHOULD_CHECK_RESULTS: 'true', + }, + }) + childProcess.stdout?.on('data', (chunk) => { + testOutput += chunk.toString() + }) + childProcess.stderr?.on('data', (chunk) => { + testOutput += chunk.toString() + }) + + const [exitCode] = await once(childProcess, 'exit') + + assert.strictEqual(exitCode, 0, testOutput) + assert.doesNotMatch(testOutput, /originalPrepareStackTrace is not a function/) + await eventsPromise + }) + it('reports parsing errors in the test file', (done) => { const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { diff --git a/integration-tests/jest/jest.itr-efd.spec.js b/integration-tests/jest/jest.itr-efd.spec.js index a3e4852ad51..d5d02f4000b 100644 --- a/integration-tests/jest/jest.itr-efd.spec.js +++ b/integration-tests/jest/jest.itr-efd.spec.js @@ -61,6 +61,13 @@ const JEST_VERSION = requestedJestVersion === 'oldest' ? oldestJestVersion : req const onlyLatestIt = JEST_VERSION === 'latest' ? it : it.skip const shouldInstallJestEnvironmentJsdom = JEST_VERSION === 'latest' || Number(JEST_VERSION.split('.')[0]) >= 28 +function assertItrSkippingEnabledTags (events, expected) { + const testSuite = events.find(event => event.type === 'test_suite_end').content + assert.strictEqual(testSuite.meta[TEST_ITR_SKIPPING_ENABLED], expected) + const test = events.find(event => event.type === 'test').content + assert.strictEqual(test.meta[TEST_ITR_SKIPPING_ENABLED], expected) +} + // TODO: add ESM tests describe(`jest@${JEST_VERSION} commonJS`, () => { let receiver @@ -233,6 +240,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { assert.strictEqual(testModule.meta[TEST_ITR_TESTS_SKIPPED], 'false') assert.strictEqual(testModule.meta[TEST_CODE_COVERAGE_ENABLED], 'false') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'false') + assertItrSkippingEnabledTags(payload.events, 'false') }, ({ url }) => url === '/api/v2/citestcycle').then(() => done()).catch(done) childProcess = exec( @@ -298,6 +306,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'true') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_TYPE], 'suite') assert.strictEqual(testModule.metrics[TEST_ITR_SKIPPING_COUNT], 1) + assertItrSkippingEnabledTags(eventsRequest.payload.events, 'true') done() }).catch(done) @@ -380,6 +389,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { assert.strictEqual(testModule.meta[TEST_ITR_TESTS_SKIPPED], 'false') assert.strictEqual(testModule.meta[TEST_CODE_COVERAGE_ENABLED], 'true') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'true') + assertItrSkippingEnabledTags(payload.events, 'true') }, ({ url }) => url === '/api/v2/citestcycle').then(() => done()).catch(done) childProcess = exec( @@ -584,6 +594,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { assert.strictEqual(testModule.meta[TEST_ITR_TESTS_SKIPPED], 'false') assert.strictEqual(testModule.meta[TEST_CODE_COVERAGE_ENABLED], 'true') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'true') + assertItrSkippingEnabledTags(events, 'true') }, 25000) childProcess = exec( diff --git a/integration-tests/jest/jest.test-management.spec.js b/integration-tests/jest/jest.test-management.spec.js index 5a1856bc19d..18e58d006c1 100644 --- a/integration-tests/jest/jest.test-management.spec.js +++ b/integration-tests/jest/jest.test-management.spec.js @@ -1383,12 +1383,17 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { .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 suites = events.filter(event => event.type === 'test_suite_end').map(event => event.content) const testSession = events.find(event => event.type === 'test_session_end').content if (isQuarantining) { assert.strictEqual(testSession.meta[TEST_MANAGEMENT_ENABLED], 'true') // test session is passed even though a test fails because the test is quarantined assert.strictEqual(testSession.meta[TEST_STATUS], 'pass') + const quarantinedSuite = suites.find( + suite => suite.meta[TEST_SUITE] === 'ci-visibility/test-management/test-quarantine-1.js' + ) + assert.strictEqual(quarantinedSuite.meta[TEST_STATUS], 'pass') } else { assert.ok(!(TEST_MANAGEMENT_ENABLED in testSession.meta)) assert.strictEqual(testSession.meta[TEST_STATUS], 'fail') diff --git a/integration-tests/mocha/mocha.spec.js b/integration-tests/mocha/mocha.spec.js index b96fa7136e9..b8b11a8145a 100644 --- a/integration-tests/mocha/mocha.spec.js +++ b/integration-tests/mocha/mocha.spec.js @@ -83,6 +83,13 @@ const { } = require('../../packages/dd-trace/src/constants') const { DD_MAJOR, VERSION: ddTraceVersion } = require('../../version') +function assertItrSkippingEnabledTags (events, expected) { + const testSuite = events.find(event => event.type === 'test_suite_end').content + assert.strictEqual(testSuite.meta[TEST_ITR_SKIPPING_ENABLED], expected) + const test = events.find(event => event.type === 'test').content + assert.strictEqual(test.meta[TEST_ITR_SKIPPING_ENABLED], expected) +} + const runTestsCommand = 'node ./ci-visibility/run-mocha.js' const runTestsWithCoverageCommand = `./node_modules/nyc/bin/nyc.js -r=text-summary ${runTestsCommand}` const testFile = 'ci-visibility/run-mocha.js' @@ -1700,6 +1707,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { assert.strictEqual(testModule.meta[TEST_ITR_TESTS_SKIPPED], 'false') assert.strictEqual(testModule.meta[TEST_CODE_COVERAGE_ENABLED], 'false') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'false') + assertItrSkippingEnabledTags(payload.events, 'false') }, ({ url }) => url === '/api/v2/citestcycle').then(() => done()).catch(done) childProcess = exec( @@ -1762,6 +1770,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'true') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_TYPE], 'suite') assert.strictEqual(testModule.metrics[TEST_ITR_SKIPPING_COUNT], 1) + assertItrSkippingEnabledTags(eventsRequest.payload.events, 'true') done() }).catch(done) @@ -1844,6 +1853,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { assert.strictEqual(testModule.meta[TEST_ITR_TESTS_SKIPPED], 'false') assert.strictEqual(testModule.meta[TEST_CODE_COVERAGE_ENABLED], 'true') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'true') + assertItrSkippingEnabledTags(payload.events, 'true') }, ({ url }) => url === '/api/v2/citestcycle').then(() => done()).catch(done) childProcess = exec( @@ -2056,6 +2066,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { assert.strictEqual(testModule.meta[TEST_ITR_TESTS_SKIPPED], 'false') assert.strictEqual(testModule.meta[TEST_CODE_COVERAGE_ENABLED], 'true') assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_ENABLED], 'true') + assertItrSkippingEnabledTags(events, 'true') }, 25000) childProcess = exec( @@ -2171,6 +2182,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { const tests = events.filter(event => event.type === 'test').map(event => event.content) assert.strictEqual(tests.length, 1) assert.strictEqual(tests[0].meta[TEST_STATUS], 'pass') + assertItrSkippingEnabledTags(events, 'true') }) childProcess = exec( diff --git a/integration-tests/playwright.config.js b/integration-tests/playwright.config.js index 0efce106a30..5fb0c46e81a 100644 --- a/integration-tests/playwright.config.js +++ b/integration-tests/playwright.config.js @@ -23,6 +23,15 @@ if (process.env.ADD_EXTRA_PLAYWRIGHT_PROJECT) { }) } +if (process.env.ADD_DUPLICATE_PLAYWRIGHT_PROJECT) { + projects.push({ + name: 'second-chromium', + use: { + ...devices['Desktop Chrome'], + }, + }) +} + const config = { baseURL: process.env.PW_BASE_URL, testDir: process.env.TEST_DIR || './ci-visibility/playwright-tests', diff --git a/integration-tests/playwright/playwright-efd.spec.js b/integration-tests/playwright/playwright-efd.spec.js index cd2058fdeb4..d86f725478e 100644 --- a/integration-tests/playwright/playwright-efd.spec.js +++ b/integration-tests/playwright/playwright-efd.spec.js @@ -22,6 +22,7 @@ const { TEST_RETRY_REASON, TEST_HAS_FAILED_ALL_RETRIES, TEST_NAME, + TEST_BROWSER_NAME, TEST_RETRY_REASON_TYPES, } = require('../../packages/dd-trace/src/plugins/util/test') const { DD_MAJOR } = require('../../version') @@ -29,6 +30,7 @@ const { DD_MAJOR } = require('../../version') const { PLAYWRIGHT_VERSION } = process.env const NUM_RETRIES_EFD = 3 +const PLAYWRIGHT_EFD_GATHER_TIMEOUT = 60000 const latest = 'latest' const oldest = DD_MAJOR >= 6 ? '1.38.0' : '1.18.0' @@ -188,7 +190,7 @@ versions.forEach((version) => { // all but one has been retried assert.strictEqual(totalRetriedTests.length, totalNewTests.length - 2) - }) + }, PLAYWRIGHT_EFD_GATHER_TIMEOUT) childProcess = exec( './node_modules/.bin/playwright test -c playwright.config.js', @@ -200,6 +202,226 @@ versions.forEach((version) => { }, } ) + await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + }) + + it('uses the retry count from the matching slow_test_retries bucket', async () => { + receiver.setSettings({ + early_flake_detection: { + enabled: true, + slow_test_retries: { + '5s': 2, + '10s': 1, + '30s': 0, + }, + faulty_session_threshold: 100, + }, + known_tests_enabled: true, + }) + + receiver.setKnownTests({ + playwright: {}, + }) + + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url === '/api/v2/citestcycle', (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + const tests = events.filter(event => event.type === 'test').map(event => event.content) + + const slowTests = tests.filter(test => + test.meta[TEST_NAME] === 'efd duration retries slightly slow test' + ) + assert.strictEqual(slowTests.length, 1) + assert.strictEqual(slowTests[0].meta[TEST_IS_NEW], 'true') + assert.strictEqual(slowTests[0].meta[TEST_EARLY_FLAKE_ABORT_REASON], 'slow') + assert.ok(!(TEST_IS_RETRY in slowTests[0].meta)) + }, 45_000) + + childProcess = exec( + './node_modules/.bin/playwright test -c playwright.config.js', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TEST_DIR: './ci-visibility/playwright-efd-duration', + PLAYWRIGHT_WORKERS: '2', + }, + } + ) + await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + }) + + it('keeps duration retry counts scoped by Playwright project', async () => { + receiver.setSettings({ + early_flake_detection: { + enabled: true, + slow_test_retries: { + '5s': 2, + '10s': 0, + }, + faulty_session_threshold: 100, + }, + known_tests_enabled: true, + }) + + receiver.setKnownTests({ + playwright: {}, + }) + + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url === '/api/v2/citestcycle', (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const projectTests = tests.filter(test => + test.meta[TEST_NAME] === 'efd project duration project scoped test' + ) + const fastProjectTests = projectTests.filter(test => test.meta[TEST_BROWSER_NAME] === 'chromium') + const slowProjectTests = projectTests.filter(test => test.meta[TEST_BROWSER_NAME] === 'second-chromium') + + assert.strictEqual(fastProjectTests.length, 3) + assert.strictEqual( + fastProjectTests.filter(test => test.meta[TEST_IS_RETRY] === 'true').length, + 2 + ) + assert.strictEqual(slowProjectTests.length, 1) + assert.strictEqual(slowProjectTests[0].meta[TEST_IS_NEW], 'true') + assert.strictEqual(slowProjectTests[0].meta[TEST_EARLY_FLAKE_ABORT_REASON], 'slow') + assert.ok(!(TEST_IS_RETRY in slowProjectTests[0].meta)) + }, 60_000) + + childProcess = exec( + './node_modules/.bin/playwright test -c playwright.config.js', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TEST_DIR: './ci-visibility/playwright-efd-projects', + ADD_DUPLICATE_PLAYWRIGHT_PROJECT: '1', + PLAYWRIGHT_WORKERS: '2', + }, + } + ) + await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + }) + + it('does not treat native repeat-each executions as EFD retries', async () => { + receiver.setSettings({ + early_flake_detection: { + enabled: true, + slow_test_retries: { + '5s': 0, + }, + faulty_session_threshold: 100, + }, + known_tests_enabled: true, + }) + + receiver.setKnownTests({ + playwright: {}, + }) + + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url === '/api/v2/citestcycle', (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const repeatedTests = tests.filter(test => + test.meta[TEST_NAME] === 'efd repeat each native repeat test' + ) + + assert.strictEqual(repeatedTests.length, 3) + for (const repeatedTest of repeatedTests) { + assert.strictEqual(repeatedTest.meta[TEST_IS_NEW], 'true') + assert.ok(!(TEST_IS_RETRY in repeatedTest.meta)) + assert.ok(!(TEST_RETRY_REASON in repeatedTest.meta)) + } + }, 45_000) + + childProcess = exec( + './node_modules/.bin/playwright test -c playwright.config.js --repeat-each=3', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TEST_DIR: './ci-visibility/playwright-efd-repeat', + PLAYWRIGHT_WORKERS: '2', + }, + } + ) + + await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + }) + + it('keeps duration retry counts scoped by native repeat-each index', async () => { + receiver.setSettings({ + early_flake_detection: { + enabled: true, + slow_test_retries: { + '5s': 2, + '10s': 0, + }, + faulty_session_threshold: 100, + }, + known_tests_enabled: true, + }) + + receiver.setKnownTests({ + playwright: {}, + }) + + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url === '/api/v2/citestcycle', (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const repeatedTests = tests.filter(test => + test.meta[TEST_NAME] === 'efd repeat duration repeat-scoped test' + ) + + assert.strictEqual(repeatedTests.length, 4) + for (const repeatedTest of repeatedTests) { + assert.strictEqual(repeatedTest.meta[TEST_IS_NEW], 'true') + } + + const slowRepeatTests = repeatedTests.filter( + test => test.meta[TEST_EARLY_FLAKE_ABORT_REASON] === 'slow' + ) + assert.strictEqual(slowRepeatTests.length, 1) + assert.ok(!(TEST_IS_RETRY in slowRepeatTests[0].meta)) + + const retriedTests = repeatedTests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + assert.strictEqual(retriedTests.length, 2) + for (const retriedTest of retriedTests) { + assert.strictEqual(retriedTest.meta[TEST_RETRY_REASON], TEST_RETRY_REASON_TYPES.efd) + } + + const fastOriginalTests = repeatedTests.filter(test => + !(TEST_IS_RETRY in test.meta) && !(TEST_EARLY_FLAKE_ABORT_REASON in test.meta) + ) + assert.strictEqual(fastOriginalTests.length, 1) + }, 60_000) + + childProcess = exec( + './node_modules/.bin/playwright test -c playwright.config.js --repeat-each=2', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TEST_DIR: './ci-visibility/playwright-efd-repeat-duration', + PLAYWRIGHT_WORKERS: '1', + }, + } + ) await Promise.all([ once(childProcess, 'exit'), @@ -256,7 +478,7 @@ versions.forEach((version) => { const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') assert.strictEqual(retriedTests.length, 0) - }) + }, PLAYWRIGHT_EFD_GATHER_TIMEOUT) childProcess = exec( './node_modules/.bin/playwright test -c playwright.config.js', @@ -328,7 +550,7 @@ versions.forEach((version) => { const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') assert.strictEqual(retriedTests.length, 0) - }) + }, PLAYWRIGHT_EFD_GATHER_TIMEOUT) childProcess = exec( './node_modules/.bin/playwright test -c playwright.config.js', @@ -379,7 +601,7 @@ versions.forEach((version) => { const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') assert.strictEqual(retriedTests.length, 0) - }, 60000) + }, PLAYWRIGHT_EFD_GATHER_TIMEOUT) childProcess = exec( './node_modules/.bin/playwright test -c playwright.config.js', @@ -448,7 +670,7 @@ versions.forEach((version) => { const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') assert.strictEqual(retriedTests.length, 0) - }) + }, PLAYWRIGHT_EFD_GATHER_TIMEOUT) childProcess = exec( './node_modules/.bin/playwright test -c playwright.config.js', @@ -503,7 +725,7 @@ versions.forEach((version) => { const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') assert.strictEqual(retriedTests.length, 0) - }) + }, PLAYWRIGHT_EFD_GATHER_TIMEOUT) childProcess = exec( './node_modules/.bin/playwright test -c playwright.config.js', @@ -564,7 +786,7 @@ versions.forEach((version) => { assert.strictEqual(newTests.length, 0) const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') assert.strictEqual(retriedTests.length, 0) - }), + }, PLAYWRIGHT_EFD_GATHER_TIMEOUT), ]) }) @@ -627,14 +849,8 @@ versions.forEach((version) => { assert.strictEqual(test.meta[TEST_STATUS], 'fail') }) - // Only the last retry should have TEST_HAS_FAILED_ALL_RETRIES set - const lastRetry = newTests[newTests.length - 1] - assert.strictEqual(lastRetry.meta[TEST_HAS_FAILED_ALL_RETRIES], 'true') - - // Earlier attempts should not have the flag - for (let i = 0; i < newTests.length - 1; i++) { - assert.ok(!(TEST_HAS_FAILED_ALL_RETRIES in newTests[i].meta)) - } + const failedAllRetries = newTests.filter(test => test.meta[TEST_HAS_FAILED_ALL_RETRIES] === 'true') + assert.strictEqual(failedAllRetries.length, 1) // --retries works normally for old flaky tests const oldFlakyTests = tests.filter( @@ -647,7 +863,7 @@ versions.forEach((version) => { assert.strictEqual(passedFlakyTests[0].meta[TEST_RETRY_REASON], TEST_RETRY_REASON_TYPES.ext) const failedFlakyTests = oldFlakyTests.filter(test => test.meta[TEST_STATUS] === 'fail') assert.strictEqual(failedFlakyTests.length, 1) - }), + }, PLAYWRIGHT_EFD_GATHER_TIMEOUT), ]) }) @@ -710,14 +926,8 @@ versions.forEach((version) => { assert.strictEqual(test.meta[TEST_STATUS], 'fail') }) - // Only the last retry should have TEST_HAS_FAILED_ALL_RETRIES set - const lastRetry = newTests[newTests.length - 1] - assert.strictEqual(lastRetry.meta[TEST_HAS_FAILED_ALL_RETRIES], 'true') - - // Earlier attempts should not have the flag - for (let i = 0; i < newTests.length - 1; i++) { - assert.ok(!(TEST_HAS_FAILED_ALL_RETRIES in newTests[i].meta)) - } + const failedAllRetries = newTests.filter(test => test.meta[TEST_HAS_FAILED_ALL_RETRIES] === 'true') + assert.strictEqual(failedAllRetries.length, 1) // ATR works normally for old flaky tests const oldFlakyTests = tests.filter( @@ -730,7 +940,7 @@ versions.forEach((version) => { assert.strictEqual(passedFlakyTests[0].meta[TEST_RETRY_REASON], TEST_RETRY_REASON_TYPES.atr) const failedFlakyTests = oldFlakyTests.filter(test => test.meta[TEST_STATUS] === 'fail') assert.strictEqual(failedFlakyTests.length, 1) - }), + }, PLAYWRIGHT_EFD_GATHER_TIMEOUT), ]) }) }) diff --git a/integration-tests/playwright/playwright-impacted-tests.spec.js b/integration-tests/playwright/playwright-impacted-tests.spec.js index a5131a51577..d700e42f32b 100644 --- a/integration-tests/playwright/playwright-impacted-tests.spec.js +++ b/integration-tests/playwright/playwright-impacted-tests.spec.js @@ -201,7 +201,7 @@ versions.forEach((version) => { assert.strictEqual(retriedTestNew, isNew ? NUM_RETRIES_EFD * 2 : 0) assert.strictEqual(retriedTestsWithReason, NUM_RETRIES_EFD * 2) } - }, 25000) + }, 60000) const runImpactedTest = async ( { isModified, isEfd = false, isNew = false }, @@ -264,6 +264,7 @@ versions.forEach((version) => { enabled: true, slow_test_retries: { '5s': NUM_RETRIES_EFD, + '10s': NUM_RETRIES_EFD, }, }, known_tests_enabled: true, diff --git a/integration-tests/playwright/playwright-test-management.spec.js b/integration-tests/playwright/playwright-test-management.spec.js index 30a62521ce4..720c99c8a1a 100644 --- a/integration-tests/playwright/playwright-test-management.spec.js +++ b/integration-tests/playwright/playwright-test-management.spec.js @@ -33,6 +33,8 @@ const { DD_MAJOR } = require('../../version') const { PLAYWRIGHT_VERSION } = process.env +const PLAYWRIGHT_TEST_MANAGEMENT_GATHER_TIMEOUT = 60000 + const latest = 'latest' const oldest = DD_MAJOR >= 6 ? '1.38.0' : '1.18.0' const versions = [oldest, latest] @@ -339,7 +341,7 @@ versions.forEach((version) => { assert.strictEqual(passedFlakyTest.length, 1) assert.strictEqual(failedFlakyTest.length, 1) } - }, 30000) + }, PLAYWRIGHT_TEST_MANAGEMENT_GATHER_TIMEOUT) /** * @param {{ @@ -502,7 +504,7 @@ versions.forEach((version) => { 'ATF test that is in known tests should not be tagged as new' ) } - }) + }, PLAYWRIGHT_TEST_MANAGEMENT_GATHER_TIMEOUT) childProcess = exec( './node_modules/.bin/playwright test -c playwright.config.js attempt-to-fix-test.js', @@ -684,7 +686,7 @@ versions.forEach((version) => { assert.ok(!(TEST_MANAGEMENT_IS_DISABLED in test.meta)) } }) - }, 25000) + }, PLAYWRIGHT_TEST_MANAGEMENT_GATHER_TIMEOUT) const runDisableTest = async (isDisabling, extraEnvVars) => { const testAssertionsPromise = getTestAssertions(isDisabling) @@ -818,7 +820,7 @@ versions.forEach((version) => { assert.ok(!(TEST_MANAGEMENT_IS_QUARANTINED in quarantinedTests[0].meta)) assert.ok(!(TEST_MANAGEMENT_ENABLED in testSession.meta)) } - }, 25000) + }, PLAYWRIGHT_TEST_MANAGEMENT_GATHER_TIMEOUT) /** * @param {{ diff --git a/package.json b/package.json index c44f8934cf1..bb66671cab3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dd-trace", - "version": "5.102.0", + "version": "5.102.1", "description": "Datadog APM tracing client for JavaScript", "main": "index.js", "typings": "index.d.ts", @@ -159,7 +159,7 @@ "version.js" ], "dependencies": { - "dc-polyfill": "^0.1.10", + "dc-polyfill": "^0.1.11", "import-in-the-middle": "^3.0.1" }, "optionalDependencies": { @@ -213,7 +213,7 @@ "mocha-junit-reporter": "^2.2.1", "mocha-multi-reporters": "^1.5.1", "multer": "^2.1.1", - "nock": "^13.5.6", + "nock": "^14.0.14", "node-preload": "^0.2.1", "nyc": "^18.0.0", "octokit": "^5.0.3", diff --git a/packages/datadog-instrumentations/src/couchbase.js b/packages/datadog-instrumentations/src/couchbase.js index a10df013c40..d80f949ae20 100644 --- a/packages/datadog-instrumentations/src/couchbase.js +++ b/packages/datadog-instrumentations/src/couchbase.js @@ -1,6 +1,5 @@ 'use strict' -const { errorMonitor } = require('events') const shimmer = require('../../datadog-shimmer') const { channel, @@ -25,33 +24,6 @@ function wrapAllNames (names, action) { } } -function wrapCallback (callback, ctx, channelPrefix) { - const callbackStartCh = channel(`${channelPrefix}:callback:start`) - const callbackFinishCh = channel(`${channelPrefix}:callback:finish`) - - const wrapped = callbackStartCh.runStores(ctx, () => { - return function (...args) { - return callbackFinishCh.runStores(ctx, () => { - return callback.apply(this, args) - }) - } - }) - Object.defineProperty(wrapped, '_dd_wrapped', { value: true }) - return wrapped -} - -function wrapQuery (query) { - return function (q, params, callback) { - const cb = arguments[arguments.length - 1] - if (typeof cb === 'function') { - const ctx = {} - arguments[arguments.length - 1] = wrapCallback(cb, ctx, 'apm:couchbase:query') - } - - return query.apply(this, arguments) - } -} - function wrapCallbackFinish (callback, thisArg, _args, errorCh, finishCh, ctx, channelPrefix) { const callbackStartCh = channel(`${channelPrefix}:callback:start`) const callbackFinishCh = channel(`${channelPrefix}:callback:finish`) @@ -72,63 +44,6 @@ function wrapCallbackFinish (callback, thisArg, _args, errorCh, finishCh, ctx, c return wrapped } -function wrap (prefix, fn) { - const startCh = channel(prefix + ':start') - const finishCh = channel(prefix + ':finish') - const errorCh = channel(prefix + ':error') - - return function (...args) { - if (!startCh.hasSubscribers) { - return fn.apply(this, args) - } - - const callbackIndex = findCallbackIndex(args, 1) - - if (callbackIndex < 0) return fn.apply(this, args) - - const ctx = { bucket: { name: this.name || this._name }, seedNodes: this._dd_hosts } - return startCh.runStores(ctx, () => { - const cb = args[callbackIndex] - - args[callbackIndex] = shimmer.wrapFunction(cb, (cb) => { - return wrapCallbackFinish(cb, this, args, errorCh, finishCh, ctx, prefix) - }) - - try { - return fn.apply(this, args) - } catch (error) { - ctx.error = error - void error.stack // trigger getting the stack at the original throwing point - errorCh.publish(ctx) - - throw error - } - }) - } -} - -// semver >=2 <3 -function wrapMaybeInvoke (_maybeInvoke, channelPrefix) { - return function (fn, args) { - if (!Array.isArray(args)) return _maybeInvoke.apply(this, arguments) - - const callbackIndex = findCallbackIndex(args, 0) - - if (callbackIndex === -1) return _maybeInvoke.apply(this, arguments) - - const callback = args[callbackIndex] - - if (typeof callback === 'function' && !callback._dd_wrapped) { - const ctx = {} - args[callbackIndex] = wrapCallback(callback, ctx, channelPrefix) - } - - return _maybeInvoke.apply(this, arguments) - } -} - -// semver >=3 - function wrapCBandPromise (fn, name, startData, thisArg, args) { const startCh = channel(`apm:couchbase:${name}:start`) const finishCh = channel(`apm:couchbase:${name}:finish`) @@ -190,73 +105,6 @@ function wrapV3Query (query) { } } -// semver >=2 <3 -addHook({ name: 'couchbase', file: 'lib/bucket.js', versions: ['^2.6.12'] }, Bucket => { - shimmer.wrap(Bucket.prototype, '_maybeInvoke', maybeInvoke => { - return wrapMaybeInvoke(maybeInvoke, 'apm:couchbase:bucket:maybeInvoke') - }) - - const startCh = channel('apm:couchbase:query:start') - const finishCh = channel('apm:couchbase:query:finish') - const errorCh = channel('apm:couchbase:query:error') - - shimmer.wrap(Bucket.prototype, 'query', query => wrapQuery(query)) - - shimmer.wrap(Bucket.prototype, '_n1qlReq', _n1qlReq => function (host, q, adhoc, emitter) { - if (!startCh.hasSubscribers) { - return _n1qlReq.apply(this, arguments) - } - - if (!emitter || !emitter.once) return _n1qlReq.apply(this, arguments) - - const n1qlQuery = getQueryResource(q) - - const ctx = { resource: n1qlQuery, bucket: { name: this.name || this._name }, seedNodes: this._dd_hosts } - return startCh.runStores(ctx, () => { - emitter.once('rows', () => { - finishCh.publish(ctx) - }) - - emitter.once(errorMonitor, (error) => { - if (!error) return - ctx.error = error - errorCh.publish(ctx) - finishCh.publish(ctx) - }) - - try { - return _n1qlReq.apply(this, arguments) - } catch (err) { - void err.stack // trigger getting the stack at the original throwing point - ctx.error = err - errorCh.publish(ctx) - - throw err - } - }) - }) - - wrapAllNames(['upsert', 'insert', 'replace', 'append', 'prepend'], name => { - shimmer.wrap(Bucket.prototype, name, fn => wrap(`apm:couchbase:${name}`, fn)) - }) -}) - -addHook({ name: 'couchbase', file: 'lib/cluster.js', versions: ['^2.6.12'] }, Cluster => { - shimmer.wrap(Cluster.prototype, '_maybeInvoke', maybeInvoke => { - return wrapMaybeInvoke(maybeInvoke, 'apm:couchbase:cluster:maybeInvoke') - }) - - shimmer.wrap(Cluster.prototype, 'query', query => wrapQuery(query)) - shimmer.wrap(Cluster.prototype, 'openBucket', openBucket => { - return function (...args) { - const bucket = openBucket.apply(this, args) - const hosts = this.dsnObj.hosts - bucket._dd_hosts = hosts.map(hostAndPort => hostAndPort.join(':')).join(',') - return bucket - } - }) -}) - // semver >=3 <3.2.0 addHook({ name: 'couchbase', file: 'lib/bucket.js', versions: ['^3.0.7', '^3.1.3'] }, Bucket => { diff --git a/packages/datadog-instrumentations/src/jest.js b/packages/datadog-instrumentations/src/jest.js index 2fcde1ad59c..f29f0ea588c 100644 --- a/packages/datadog-instrumentations/src/jest.js +++ b/packages/datadog-instrumentations/src/jest.js @@ -3,10 +3,13 @@ // Capture real timers at module load time, before any test can install fake timers. const realSetTimeout = setTimeout +const { readFileSync } = require('node:fs') +const { builtinModules } = require('node:module') const path = require('path') const satisfies = require('../../../vendor/dist/semifies') const { DD_MAJOR } = require('../../../version') const shimmer = require('../../datadog-shimmer') +const { getEnvironmentVariable } = require('../../dd-trace/src/config/helper') const log = require('../../dd-trace/src/log') const { getCoveredFilenamesFromCoverage, @@ -74,6 +77,7 @@ const CHILD_MESSAGE_CALL = 1 // Maximum time we'll wait for the tracer to flush const FLUSH_TIMEOUT = 10_000 +const isJestWorker = !!getEnvironmentVariable('JEST_WORKER_ID') // https://github.com/jestjs/jest/blob/41f842a46bb2691f828c3a5f27fc1d6290495b82/packages/jest-circus/src/types.ts#L9C8-L9C54 const RETRY_TIMES = Symbol.for('RETRY_TIMES') @@ -101,6 +105,8 @@ let testManagementTests = {} let testManagementAttemptToFixRetries = 0 let isImpactedTestsEnabled = false let modifiedFiles = {} +let activeTestSuiteAbsolutePath +let isConsoleErrorWrapped = false const testContexts = new WeakMap() const originalTestFns = new WeakMap() @@ -124,7 +130,12 @@ const efdNewTestCandidates = new Set() // Tests that are genuinely new (not in known tests list). const newTests = new Set() const testSuiteAbsolutePathsWithFastCheck = new Set() +const testSuiteFastCheckUsage = new Map() const testSuiteJestObjects = new Map() +const wrappedJestGlobals = new WeakSet() +const wrappedJestObjects = new WeakSet() +const wrappedWorkerInitializers = new WeakSet() +const publishedRuntimeReferenceErrors = new WeakMap() const BREAKPOINT_HIT_GRACE_PERIOD_MS = 200 const ATR_RETRY_SUPPRESSION_FLAG = '_ddDisableAtrRetry' @@ -303,6 +314,26 @@ function getAttemptToFixExecutionsFromJestResults (result) { return executions } +function wrapConsoleErrorForJestReferenceErrors () { + if (isConsoleErrorWrapped) return + + isConsoleErrorWrapped = true + // eslint-disable-next-line no-console + const originalConsoleError = console.error + // eslint-disable-next-line no-console + console.error = function () { + const [message] = arguments + if ( + typeof message === 'string' && + message.includes('Jest environment has been torn down') && + activeTestSuiteAbsolutePath + ) { + publishRuntimeReferenceError({ _testPath: activeTestSuiteAbsolutePath }, message) + } + return originalConsoleError.apply(this, arguments) + } +} + function getWrappedEnvironment (BaseEnvironment, jestVersion) { return class DatadogEnvironment extends BaseEnvironment { constructor (config, context) { @@ -314,6 +345,9 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { this.global._ddtrace = global._ddtrace this.hasSnapshotTests = undefined this.testSuiteAbsolutePath = context.testPath + activeTestSuiteAbsolutePath = this.testSuiteAbsolutePath + wrapConsoleErrorForJestReferenceErrors() + this.globalConfig = config.globalConfig this.displayName = config.projectConfig?.displayName?.name || config.displayName this.testEnvironmentOptions = getTestEnvironmentOptions(config) @@ -423,6 +457,10 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { */ resetMockState () { try { + if (this.moduleMocker?.clearAllMocks) { + this.moduleMocker.clearAllMocks() + return + } const jestObject = testSuiteJestObjects.get(this.testSuiteAbsolutePath) if (jestObject?.clearAllMocks) { jestObject.clearAllMocks() @@ -504,7 +542,7 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { } getShouldStripSeedFromTestName () { - return testSuiteAbsolutePathsWithFastCheck.has(this.testSuiteAbsolutePath) + return doesTestSuiteUseFastCheck(this.testSuiteAbsolutePath) } // At the `add_test` event we don't have the test object yet, so we can't use it @@ -843,8 +881,8 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { const willBeRetriedByFailedTestReplay = numRetries > 0 && numTestExecutions - 1 < numRetries const mightHitBreakpoint = this.isDiEnabled && numTestExecutions >= 2 - // For quarantined tests, suppress errors so Jest doesn't count them as failures. - // This prevents --bail from stopping the test run on quarantined test failures. + // For quarantined tests, track failures so the session can be marked as passing later, + // and suppress errors so Jest does not mark the test suite as failing. // The actual status ('fail') is already captured above for dd-trace reporting. // Only suppress on the final execution — not when ATR/EFD/ATF will retry the test. if (!event.test?.[ATR_RETRY_SUPPRESSION_FLAG] && !willBeRetriedByFailedTestReplay) { @@ -1055,7 +1093,19 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { } } } - return super.teardown() + const clearActiveTestSuite = () => { + realSetTimeout(() => { + if (activeTestSuiteAbsolutePath === this.testSuiteAbsolutePath) { + activeTestSuiteAbsolutePath = undefined + } + }, 0) + } + const result = super.teardown() + if (result?.then) { + return result.finally(clearActiveTestSuite) + } + clearActiveTestSuite() + return result } } } @@ -1557,6 +1607,33 @@ function coverageReporterWrapper (coverageReporter) { return coverageReporter } +function shouldWaitForTestSuiteFinish (environment) { + return isJestWorker && environment.globalConfig?.workerIdleMemoryLimit !== undefined +} + +function publishTestSuiteFinish (payload, waitForFinish) { + if (!testSuiteFinishCh.hasSubscribers) return + + if (!waitForFinish) { + testSuiteFinishCh.publish(payload) + return + } + + return new Promise(resolve => { + testSuiteFinishCh.publish({ + ...payload, + waitForFinish, + onDone: resolve, + }) + }) +} + +function cleanupTestSuiteState (testSuiteAbsolutePath) { + testSuiteMockedFiles.delete(testSuiteAbsolutePath) + testSuiteFastCheckUsage.delete(testSuiteAbsolutePath) + testSuiteJestObjects.delete(testSuiteAbsolutePath) +} + addHook({ name: '@jest/core', file: 'build/TestScheduler.js', @@ -1675,7 +1752,7 @@ function jestAdapterWrapper (jestAdapter, jestVersion) { const getFilesWithPath = (files) => files.map(file => getTestSuitePath(file, root)) const coverageFiles = getFilesWithPath(getCoveredFilenamesFromCoverage(environment.global.__coverage__)) - const mockedFiles = getFilesWithPath(testSuiteMockedFiles.get(environment.testSuiteAbsolutePath) || []) + const mockedFiles = getFilesWithPath(getMockedFiles(environment.testSuiteAbsolutePath)) testSuiteCodeCoverageCh.publish({ coverageFiles, @@ -1684,19 +1761,51 @@ function jestAdapterWrapper (jestAdapter, jestVersion) { testSuiteAbsolutePath: environment.testSuiteAbsolutePath, }) } - testSuiteFinishCh.publish({ status, errorMessage, testSuiteAbsolutePath: environment.testSuiteAbsolutePath }) + const waitForFinish = shouldWaitForTestSuiteFinish(environment) + const finishPayload = { + status, + errorMessage, + testSuiteAbsolutePath: environment.testSuiteAbsolutePath, + } + if (waitForFinish) { + const finishPromise = publishTestSuiteFinish(finishPayload, waitForFinish) + if (finishPromise) { + return finishPromise.then(() => { + // Cleanup per-suite state to avoid memory leaks + cleanupTestSuiteState(environment.testSuiteAbsolutePath) + + return suiteResults + }) + } + } + publishTestSuiteFinish(finishPayload, waitForFinish) // Cleanup per-suite state to avoid memory leaks - testSuiteMockedFiles.delete(environment.testSuiteAbsolutePath) - testSuiteJestObjects.delete(environment.testSuiteAbsolutePath) + cleanupTestSuiteState(environment.testSuiteAbsolutePath) return suiteResults }).catch(error => { - testSuiteFinishCh.publish({ status: 'fail', error, testSuiteAbsolutePath: environment.testSuiteAbsolutePath }) + const waitForFinish = shouldWaitForTestSuiteFinish(environment) + const finishPayload = { + status: 'fail', + error, + testSuiteAbsolutePath: environment.testSuiteAbsolutePath, + } + if (waitForFinish) { + const finishPromise = publishTestSuiteFinish(finishPayload, waitForFinish) + if (finishPromise) { + return finishPromise.then(() => { + // Cleanup per-suite state to avoid memory leaks + cleanupTestSuiteState(environment.testSuiteAbsolutePath) + + throw error + }) + } + } + publishTestSuiteFinish(finishPayload, waitForFinish) // Cleanup per-suite state to avoid memory leaks - testSuiteMockedFiles.delete(environment.testSuiteAbsolutePath) - testSuiteJestObjects.delete(environment.testSuiteAbsolutePath) + cleanupTestSuiteState(environment.testSuiteAbsolutePath) throw error }) @@ -1794,6 +1903,7 @@ const DD_TEST_ENVIRONMENT_OPTION_KEYS = [ '_ddRepositoryRoot', '_ddIsFlakyTestRetriesEnabled', '_ddFlakyTestRetriesCount', + '_ddItrSkippingEnabledTags', '_ddIsDiEnabled', '_ddIsKnownTestsEnabled', '_ddIsTestManagementTestsEnabled', @@ -1904,39 +2014,195 @@ const LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE = new Set([ 'winston', ]) +function recordMockedFile (suiteFilePath, moduleName) { + if (!suiteFilePath || typeof moduleName !== 'string') return + + const existingMockedFiles = testSuiteMockedFiles.get(suiteFilePath) || [] + const suiteDir = path.dirname(suiteFilePath) + const mockPath = path.resolve(suiteDir, moduleName) + existingMockedFiles.push(mockPath) + testSuiteMockedFiles.set(suiteFilePath, existingMockedFiles) +} + +const JEST_STATIC_MOCK_CALL_RE = /\bjest\.(?:mock|doMock|unstable_mockModule)\(\s*(['"`])([^'"`]+)\1/g + +function getStaticMockedFiles (suiteFilePath) { + if (!suiteFilePath) return [] + + const mockedFiles = [] + try { + const source = readFileSync(suiteFilePath, 'utf8') + let match + JEST_STATIC_MOCK_CALL_RE.lastIndex = 0 + while ((match = JEST_STATIC_MOCK_CALL_RE.exec(source)) !== null) { + mockedFiles.push(path.resolve(path.dirname(suiteFilePath), match[2])) + } + } catch { + // ignore errors + } + + return mockedFiles +} + +function getMockedFiles (suiteFilePath) { + const mockedFiles = testSuiteMockedFiles.get(suiteFilePath) + if (mockedFiles?.length) { + return mockedFiles + } + return getStaticMockedFiles(suiteFilePath) +} + +function wrapJestObject (jestObject, suiteFilePath) { + if (!jestObject || !suiteFilePath || wrappedJestObjects.has(jestObject)) return + + testSuiteJestObjects.set(suiteFilePath, jestObject) + wrappedJestObjects.add(jestObject) + + shimmer.wrap(jestObject, 'mock', mock => function (moduleName) { + // If the library is mocked with `jest.mock`, we don't want to bypass jest's own require engine + if (LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE.has(moduleName)) { + LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE.delete(moduleName) + } + recordMockedFile(suiteFilePath, moduleName) + return mock.apply(this, arguments) + }) +} + +function wrapJestGlobalsForRuntime (runtime) { + const jestGlobals = runtime?.jestGlobals + if (!jestGlobals || wrappedJestGlobals.has(jestGlobals) || typeof jestGlobals.jestObjectFor !== 'function') { + return + } + + wrappedJestGlobals.add(jestGlobals) + shimmer.wrap(jestGlobals, 'jestObjectFor', jestObjectFor => function (from) { + const jestObject = jestObjectFor.apply(this, arguments) + wrapJestObject(jestObject, from) + return jestObject + }) +} + +function recordFastCheckUsage (runtime, from, moduleName) { + if (moduleName !== '@fast-check/jest') return + + if (from) { + testSuiteAbsolutePathsWithFastCheck.add(from) + testSuiteFastCheckUsage.set(from, true) + } + if (runtime?._testPath) { + testSuiteAbsolutePathsWithFastCheck.add(runtime._testPath) + testSuiteFastCheckUsage.set(runtime._testPath, true) + } +} + +function doesTestSuiteUseFastCheck (testSuiteAbsolutePath) { + if (!testSuiteAbsolutePath) return false + if (testSuiteFastCheckUsage.has(testSuiteAbsolutePath)) { + return testSuiteFastCheckUsage.get(testSuiteAbsolutePath) + } + + try { + const usesFastCheck = readFileSync(testSuiteAbsolutePath, 'utf8').includes('@fast-check/jest') + testSuiteFastCheckUsage.set(testSuiteAbsolutePath, usesFastCheck) + if (usesFastCheck) { + testSuiteAbsolutePathsWithFastCheck.add(testSuiteAbsolutePath) + } + return usesFastCheck + } catch { + testSuiteFastCheckUsage.set(testSuiteAbsolutePath, false) + return false + } +} + +function getLastLoggedReferenceError (runtime) { + const loggedReferenceErrors = runtime?.loggedReferenceErrors + if (!loggedReferenceErrors?.size) return + return [...loggedReferenceErrors].pop() +} + +function publishRuntimeReferenceError (runtime, errorMessage) { + if (!errorMessage || !runtime?._testPath) return + + let publishedErrors = publishedRuntimeReferenceErrors.get(runtime) + if (!publishedErrors) { + publishedErrors = new Set() + publishedRuntimeReferenceErrors.set(runtime, publishedErrors) + } + if (publishedErrors.has(errorMessage)) return + + publishedErrors.add(errorMessage) + testSuiteErrorCh.publish({ + errorMessage, + testSuiteAbsolutePath: runtime._testPath, + }) +} + +function isBetweenTestsReferenceError (error) { + return error?.name === 'ReferenceError' && + typeof error.message === 'string' && + error.message.includes('outside of the scope of the test code') +} + +function reportBetweenTestsReferenceError (runtime, moduleName, originalErrorMessage) { + if (typeof moduleName !== 'string') return false + + const fallbackErrorMessage = moduleName.startsWith('node:') || builtinModules.includes(moduleName) + ? 'You are trying to access a Node.js module outside of the scope of the test code.' + : 'You are trying to `require` a file after the Jest environment has been torn down.' + const errorMessage = originalErrorMessage || fallbackErrorMessage + + if (typeof runtime._logFormattedReferenceError === 'function') { + runtime._logFormattedReferenceError(errorMessage) + } + publishRuntimeReferenceError(runtime, getLastLoggedReferenceError(runtime) || errorMessage) + process.exitCode = 1 + return true +} + +function requireOutsideJestRequireEngine (runtime, moduleName) { + if (typeof runtime._requireCoreModule === 'function') { + return runtime._requireCoreModule(moduleName) + } + return require(moduleName) +} + +function formatDefaultStackTrace (error, structuredStackTrace) { + const errorString = Error.prototype.toString.call(error) + if (structuredStackTrace.length === 0) return errorString + + return `${errorString}\n at ${structuredStackTrace.join('\n at ')}` +} + addHook({ name: 'jest-runtime', versions: [MINIMUM_JEST_VERSION], }, (runtimePackage) => { const Runtime = runtimePackage.default ?? runtimePackage - shimmer.wrap(Runtime.prototype, '_createJestObjectFor', _createJestObjectFor => function (from) { - const result = _createJestObjectFor.apply(this, arguments) - const suiteFilePath = this._testPath || from + if (typeof Runtime.prototype._createJestObjectFor === 'function') { + shimmer.wrap(Runtime.prototype, '_createJestObjectFor', _createJestObjectFor => function (from) { + const result = _createJestObjectFor.apply(this, arguments) + const suiteFilePath = this._testPath || from - // Store the jest object so we can access it later for resetting mock state - if (suiteFilePath) { - testSuiteJestObjects.set(suiteFilePath, result) - } + wrapJestObject(result, suiteFilePath) + return result + }) + } - shimmer.wrap(result, 'mock', mock => function (moduleName) { - // If the library is mocked with `jest.mock`, we don't want to bypass jest's own require engine - if (LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE.has(moduleName)) { - LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE.delete(moduleName) - } - if (suiteFilePath) { - const existingMockedFiles = testSuiteMockedFiles.get(suiteFilePath) || [] - const suiteDir = path.dirname(suiteFilePath) - const mockPath = path.resolve(suiteDir, moduleName) - existingMockedFiles.push(mockPath) - testSuiteMockedFiles.set(suiteFilePath, existingMockedFiles) + shimmer.wrap(Runtime.prototype, 'requireModule', requireModule => function (from, moduleName) { + wrapJestGlobalsForRuntime(this) + try { + return requireModule.apply(this, arguments) + } catch (error) { + if (isBetweenTestsReferenceError(error)) { + reportBetweenTestsReferenceError(this, moduleName, error.message) } - return mock.apply(this, arguments) - }) - return result + throw error + } }) shimmer.wrap(Runtime.prototype, 'requireModuleOrMock', requireModuleOrMock => function (from, moduleName) { + wrapJestGlobalsForRuntime(this) // `requireModuleOrMock` may log errors to the console. If we don't remove ourselves // from the stack trace, the user might see a useless stack trace rather than the error // that `jest` tries to show. @@ -1945,32 +2211,33 @@ addHook({ const filteredStackTrace = structuredStackTrace .filter(callSite => !callSite.getFileName()?.includes('datadog-instrumentations/src/jest.js')) - return originalPrepareStackTrace(error, filteredStackTrace) + if (typeof originalPrepareStackTrace === 'function') { + return originalPrepareStackTrace(error, filteredStackTrace) + } + return formatDefaultStackTrace(error, filteredStackTrace) } try { // TODO: do this for every library that we instrument if (LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE.has(moduleName)) { // To bypass jest's own require engine - return this._requireCoreModule(moduleName) + return requireOutsideJestRequireEngine(this, moduleName) } // This means that `@fast-check/jest` is used in the test file. - if (moduleName === '@fast-check/jest') { - testSuiteAbsolutePathsWithFastCheck.add(this._testPath) + recordFastCheckUsage(this, from, moduleName) + let returnedValue + try { + returnedValue = requireModuleOrMock.apply(this, arguments) + } catch (error) { + if (isBetweenTestsReferenceError(error)) { + reportBetweenTestsReferenceError(this, moduleName, error.message) + } + throw error } - const returnedValue = requireModuleOrMock.apply(this, arguments) if (process.exitCode === 1) { - if (this.loggedReferenceErrors?.size > 0) { - const errorMessage = [...this.loggedReferenceErrors][0] - testSuiteErrorCh.publish({ - errorMessage, - testSuiteAbsolutePath: this._testPath, - }) - } else { - testSuiteErrorCh.publish({ - errorMessage: 'An error occurred while importing a module', - testSuiteAbsolutePath: this._testPath, - }) - } + publishRuntimeReferenceError( + this, + getLastLoggedReferenceError(this) || 'An error occurred while importing a module' + ) } return returnedValue } finally { @@ -1979,6 +2246,27 @@ addHook({ } }) + if (Runtime.prototype._logFormattedReferenceError) { + shimmer.wrap(Runtime.prototype, '_logFormattedReferenceError', logFormattedReferenceError => function () { + // eslint-disable-next-line no-console + const originalConsoleError = console.error + let loggedReferenceError + // eslint-disable-next-line no-console + console.error = function () { + loggedReferenceError = arguments[0] + return originalConsoleError.apply(this, arguments) + } + try { + const result = logFormattedReferenceError.apply(this, arguments) + publishRuntimeReferenceError(this, getLastLoggedReferenceError(this) || loggedReferenceError) + return result + } finally { + // eslint-disable-next-line no-console + console.error = originalConsoleError + } + }) + } + return runtimePackage }) @@ -2065,11 +2353,23 @@ function wrapWorkerChannel (worker) { shimmer.wrap(workerChannel, worker._child ? 'send' : 'postMessage', sendWrapper) } +function wrapWorkerInitializer (worker) { + if (wrappedWorkerInitializers.has(worker) || typeof worker.initialize !== 'function') return + + wrappedWorkerInitializers.add(worker) + shimmer.wrap(worker, 'initialize', initialize => function () { + const result = initialize.apply(this, arguments) + wrapWorkerChannel(this) + return result + }) +} + function wrapWorker (worker) { // ChildProcessWorker uses _child (child_process), ExperimentalWorker uses _worker (worker_threads) const workerChannel = worker._child || worker._worker if (!workerChannel) return + wrapWorkerInitializer(worker) wrapWorkerChannel(worker) shimmer.wrap(worker, '_onMessage', onMessageWrapper) workerChannel.removeAllListeners('message') diff --git a/packages/datadog-instrumentations/src/mongodb-core.js b/packages/datadog-instrumentations/src/mongodb-core.js index 2023c6190d6..66416b7343a 100644 --- a/packages/datadog-instrumentations/src/mongodb-core.js +++ b/packages/datadog-instrumentations/src/mongodb-core.js @@ -11,6 +11,14 @@ const startCh = channel('apm:mongodb:query:start') const finishCh = channel('apm:mongodb:query:finish') const errorCh = channel('apm:mongodb:query:error') +// Per-Connection cached topology shape (mongodb >= 4). The Connection's `address` is immutable +// for the lifetime of the connection, so we synthesize the `{ s: { options } }` envelope the +// plugin expects only once per connection. A WeakMap keeps the cache off the foreign Connection +// instance — no extra own-key visible to `Reflect.ownKeys`, `Object.freeze`, or another tracer's +// instrumentation walking the connection. +/** @type {WeakMap} */ +const topologyCache = new WeakMap() + addHook({ name: 'mongodb-core', versions: ['2 - 3.1.9'] }, Server => { const serverProto = Server.Server.prototype shimmer.wrap(serverProto, 'command', command => wrapCommand(command, 'command')) @@ -88,21 +96,36 @@ function wrapUnifiedCommand (command, operation, name) { } function wrapConnectionCommand (command, operation, name, instrumentFn = instrument) { + const opts = { name } return function (ns, ops) { if (!startCh.hasSubscribers) { return command.apply(this, arguments) } - const hostParts = typeof this.address === 'string' ? this.address.split(':') : '' - const options = hostParts.length === 2 - ? { host: hostParts[0], port: hostParts[1] } - : {} // no port means the address is a random UUID so no host either - const topology = { s: { options } } - - ns = `${ns.db}.${ns.collection}` - return instrumentFn(operation, command, this, arguments, topology, ns, ops, { name }) + let topology = topologyCache.get(this) + if (topology === undefined) { + topology = synthesizeTopology(this.address) + topologyCache.set(this, topology) + } + return instrumentFn(operation, command, this, arguments, topology, `${ns.db}.${ns.collection}`, ops, opts) } } +/** + * @param {string} address + * @returns {{ s: { options: { host?: string, port?: string } } }} + */ +function synthesizeTopology (address) { + if (typeof address === 'string') { + const colon = address.indexOf(':') + // Match the previous `.split(':')` length-2 check: exactly one colon with non-empty parts on both sides. + if (colon > 0 && colon < address.length - 1 && !address.includes(':', colon + 1)) { + return { s: { options: { host: address.slice(0, colon), port: address.slice(colon + 1) } } } + } + } + // No port means the address is a random UUID, an IPv6 form, or otherwise unparseable, so no host either. + return { s: { options: {} } } +} + function wrapQuery (query, operation, name) { return function (...args) { if (!startCh.hasSubscribers) { @@ -171,6 +194,8 @@ function instrument (operation, command, instance, args, server, ns, ops, option }) } +module.exports = { synthesizeTopology } + function instrumentPromise (operation, command, instance, args, server, ns, ops, options = {}) { const name = options.name || (ops && Object.keys(ops)[0]) diff --git a/packages/datadog-instrumentations/src/playwright.js b/packages/datadog-instrumentations/src/playwright.js index 7f3b3d3aa99..ae02deab234 100644 --- a/packages/datadog-instrumentations/src/playwright.js +++ b/packages/datadog-instrumentations/src/playwright.js @@ -3,6 +3,7 @@ // Capture real timers at module load time, before any test can install fake timers. const realSetTimeout = setTimeout +const { performance } = require('node:perf_hooks') const satisfies = require('../../../vendor/dist/semifies') const shimmer = require('../../datadog-shimmer') @@ -12,6 +13,8 @@ const { PLAYWRIGHT_WORKER_TRACE_PAYLOAD_CODE, getIsFaultyEarlyFlakeDetection, DYNAMIC_NAME_RE, + getEfdRetryCount, + getMaxEfdRetryCount, recordAttemptToFixExecution, logAttemptToFixTestExecution, logTestOptimizationSummary, @@ -68,6 +71,7 @@ let remainingTestsByFile = {} let isKnownTestsEnabled = false let isEarlyFlakeDetectionEnabled = false let earlyFlakeDetectionNumRetries = 0 +let earlyFlakeDetectionSlowTestRetries = {} let isEarlyFlakeDetectionFaulty = false let earlyFlakeDetectionFaultyThreshold = 0 let isFlakyTestRetriesEnabled = false @@ -83,10 +87,19 @@ let testsReportedInGenerateSummary = new Set() const newTestsWithDynamicNames = new Set() const attemptToFixExecutions = new Map() const loggedAttemptToFixTests = new Set() +const efdManagedTestKeys = new Set() +const efdRetryCountByTestKey = new Map() +const efdRetryCountRequestsByTestKey = new Map() +const efdRetryTestsById = new Map() +const efdScheduledOriginalTestKeys = new Set() +const efdStartedOriginalTestKeys = new Set() +const efdSlowAbortedTests = new Set() let rootDir = '' let sessionProjects = [] const MINIMUM_SUPPORTED_VERSION_RANGE_EFD = '>=1.38.0' // TODO: remove this once we drop support for v5 +const EFD_RETRY_COUNT_REQUEST = 'ddEfdRetryCountRequest' +const EFD_RETRY_COUNT_RESPONSE = 'ddEfdRetryCountResponse' function isValidKnownTests (receivedKnownTests) { return !!receivedKnownTests.playwright @@ -97,6 +110,222 @@ function getTestFullyQualifiedName (test) { return `${test._requireFile} ${fullname}` } +/** + * @param {object} test + * @returns {string|undefined} + */ +function getTestProjectKey (test) { + const { _projectIndex, _projectId } = test + if (_projectIndex !== undefined) { + return `index:${_projectIndex}` + } + if (_projectId !== undefined) { + return `id:${_projectId}` + } + + const projectSuite = getSuiteType(test, 'project') + const projectName = projectSuite?._fullProject?.project?.name || + projectSuite?._fullProject?.name || + projectSuite?.title + if (projectName) { + return `name:${projectName}` + } +} + +/** + * @param {object} test + * @returns {number|undefined} + */ +function getTestEfdRepeatEachIndex (test) { + if (Object.hasOwn(test, '_ddEfdOriginalRepeatEachIndex')) { + return test._ddEfdOriginalRepeatEachIndex + } + return test.repeatEachIndex +} + +/** + * @param {object} test + * @returns {string|undefined} + */ +function getTestRepeatEachKey (test) { + const repeatEachIndex = getTestEfdRepeatEachIndex(test) + if (repeatEachIndex !== undefined) { + return `repeat:${repeatEachIndex}` + } +} + +/** + * @param {object} test + * @returns {string} + */ +function getTestEfdKey (test) { + const projectKey = getTestProjectKey(test) + const repeatEachKey = getTestRepeatEachKey(test) + const testFqn = getTestFullyQualifiedName(test) + return [projectKey, repeatEachKey, testFqn].filter(Boolean).join(' ') +} + +function getConfiguredEfdRetryCount () { + if (!earlyFlakeDetectionSlowTestRetries || !Object.keys(earlyFlakeDetectionSlowTestRetries).length) { + return earlyFlakeDetectionNumRetries + } + return getMaxEfdRetryCount(earlyFlakeDetectionSlowTestRetries) +} + +function markEfdManagedTest (test) { + test._ddIsEfdManagedTest = true + test._ddEfdSlowTestRetries = earlyFlakeDetectionSlowTestRetries + efdManagedTestKeys.add(getTestEfdKey(test)) +} + +function markEfdRetryTest (test, retryIndex, originalTest) { + test._ddIsEfdRetry = true + test._ddEfdRetryIndex = retryIndex + if (originalTest) { + test._ddEfdOriginalRepeatEachIndex = getTestEfdRepeatEachIndex(originalTest) + } +} + +function registerEfdRetryTest (test) { + if (!test._ddIsEfdRetry) { + return + } + + efdRetryTestsById.set(test.id, { + retryIndex: test._ddEfdRetryIndex, + testEfdKey: getTestEfdKey(test), + }) +} + +function getTestEfdSlowTestRetries (test) { + return test._ddEfdSlowTestRetries || earlyFlakeDetectionSlowTestRetries +} + +function isTestEfdManaged (test) { + return !!test._ddIsEfdManagedTest || ( + (test._ddIsNew || test._ddIsModified) && + !test._ddIsAttemptToFix && + isEarlyFlakeDetectionEnabled + ) +} + +function getFileSuiteRepeatEachIndex (fileSuite) { + const test = fileSuite.allTests()[0] + return test ? getTestEfdRepeatEachIndex(test) || 0 : 0 +} + +function getEfdRetryRepeatEachIndex (fileSuite, projectSuite, retryIndex, retryCount) { + const nativeRepeatEach = projectSuite._fullProject?.project?.repeatEach || 1 + const originalRepeatEachIndex = getFileSuiteRepeatEachIndex(fileSuite) + return nativeRepeatEach + (originalRepeatEachIndex * retryCount) + retryIndex - 1 +} + +function getEfdRetryCountForTest (test) { + return efdRetryCountByTestKey.get(getTestEfdKey(test)) ?? getConfiguredEfdRetryCount() +} + +function setEfdRetryCountForTest (test, retryCount) { + const testEfdKey = getTestEfdKey(test) + efdRetryCountByTestKey.set(testEfdKey, retryCount) + + const requests = efdRetryCountRequestsByTestKey.get(testEfdKey) + if (requests) { + efdRetryCountRequestsByTestKey.delete(testEfdKey) + for (const resolveRequest of requests) { + resolveRequest(retryCount) + } + } +} + +function sendEfdRetryCountToWorker (workerProcess, testId, retryIndex, retryCount) { + workerProcess.send({ + type: EFD_RETRY_COUNT_RESPONSE, + testId, + isEfdRetry: retryIndex !== undefined, + retryIndex, + retryCount, + }) +} + +function sendEfdRetryCountToWorkerWhenAvailable (workerProcess, testId) { + const efdRetryTest = efdRetryTestsById.get(testId) + if (!efdRetryTest) { + sendEfdRetryCountToWorker(workerProcess, testId) + return + } + + const { retryIndex, testEfdKey } = efdRetryTest + + if (!testEfdKey || !efdManagedTestKeys.has(testEfdKey)) { + sendEfdRetryCountToWorker(workerProcess, testId) + return + } + + const retryCount = efdRetryCountByTestKey.get(testEfdKey) + if (retryCount !== undefined) { + sendEfdRetryCountToWorker(workerProcess, testId, retryIndex, retryCount) + return + } + + if (!efdStartedOriginalTestKeys.has(testEfdKey) && !efdScheduledOriginalTestKeys.has(testEfdKey)) { + sendEfdRetryCountToWorker(workerProcess, testId, retryIndex, 0) + return + } + + if (!efdRetryCountRequestsByTestKey.has(testEfdKey)) { + efdRetryCountRequestsByTestKey.set(testEfdKey, []) + } + efdRetryCountRequestsByTestKey.get(testEfdKey).push((retryCount) => { + sendEfdRetryCountToWorker(workerProcess, testId, retryIndex, retryCount) + }) +} + +/** + * @param {object} test + * @returns {boolean} + */ +function shouldRequestEfdRetryCount (test) { + // The main process remains the source of truth. repeatEachIndex is only used as + // a cheap worker-side filter so first executions do not block on coordination. + return test._ddIsEfdRetry || test.repeatEachIndex > 0 +} + +function waitForEfdRetryCount (test) { + if (!process.send || !shouldRequestEfdRetryCount(test)) { + return Promise.resolve() + } + + const testEfdKey = getTestEfdKey(test) + return new Promise(resolve => { + const messageHandler = (message) => { + if (message?.type === EFD_RETRY_COUNT_RESPONSE && message.testId === test.id) { + if (message.isEfdRetry) { + test._ddIsEfdRetry = true + test._ddEfdRetryIndex = message.retryIndex + test._ddEfdRetryCount = message.retryCount + efdRetryCountByTestKey.set(testEfdKey, message.retryCount) + } + process.removeListener('message', messageHandler) + resolve() + } + } + + process.on('message', messageHandler) + process.send({ + type: EFD_RETRY_COUNT_REQUEST, + testId: test.id, + }) + }) +} + +function shouldSkipEfdRetry (test) { + if (!test._ddIsEfdRetry) { + return false + } + const retryCount = test._ddEfdRetryCount ?? efdRetryCountByTestKey.get(getTestEfdKey(test)) + return retryCount !== undefined && test._ddEfdRetryIndex > retryCount +} + function getTestProperties (test) { const testName = getTestFullname(test) const testSuite = getTestSuitePath(test._requireFile, rootDir) @@ -125,14 +354,17 @@ function getSuiteType (test, type) { } // Copy of Suite#_deepClone but with a function to filter tests -function deepCloneSuite (suite, filterTest, tags = []) { +function deepCloneSuite (suite, filterTest, tags = [], configureCopiedTest) { const copy = suite._clone() for (const entry of suite._entries) { if (entry.constructor.name === 'Suite') { - copy._addSuite(deepCloneSuite(entry, filterTest, tags)) + copy._addSuite(deepCloneSuite(entry, filterTest, tags, configureCopiedTest)) } else { if (filterTest(entry)) { const copiedTest = entry._clone() + if (configureCopiedTest) { + configureCopiedTest(copiedTest, entry) + } for (const tag of tags) { const resolvedTag = typeof tag === 'function' ? tag(entry) : tag @@ -303,6 +535,7 @@ function getFinalStatus ({ isAttemptToFix, hasFailedAllRetries, hasFailedAttemptToFixRetries, + hasPassedAnyEfdAttempt, testStatus, }) { if (!isFinalExecution) { @@ -311,9 +544,12 @@ function getFinalStatus ({ if (isDisabled || isQuarantined || testStatus === 'skip') { return 'skip' } - if (isAtrRetry || isEfdManagedTest) { + if (isAtrRetry) { return hasFailedAllRetries ? 'fail' : 'pass' } + if (isEfdManagedTest) { + return hasPassedAnyEfdAttempt ? 'pass' : 'fail' + } if (isAttemptToFix) { return hasFailedAttemptToFixRetries ? 'fail' : 'pass' } @@ -350,6 +586,14 @@ function testBeginHandler (test, browserName, shouldCreateTestSpan) { if (_type === 'beforeAll' || _type === 'afterAll') { return } + if (shouldSkipEfdRetry(test)) { + test._ddShouldSkipEfdRetry = true + return + } + test._ddStartTime = performance.now() + if (isTestEfdManaged(test) && !test._ddIsEfdRetry) { + efdStartedOriginalTestKeys.add(getTestEfdKey(test)) + } // this means that a skipped test is being handled if (!remainingTestsByFile[testSuiteAbsolutePath].length) { return @@ -391,6 +635,45 @@ function testBeginHandler (test, browserName, shouldCreateTestSpan) { } } +function finishTestSuiteIfDone (testSuiteAbsolutePath, projects) { + if (!shouldFinishTestSuite(testSuiteAbsolutePath)) { + return + } + + const skippedTests = remainingTestsByFile[testSuiteAbsolutePath] + .filter(test => test.expectedStatus === 'skipped') + + for (const test of skippedTests) { + const browserName = getBrowserNameFromProjects(projects, test) + testSkipCh.publish({ + testName: getTestFullname(test), + testSuiteAbsolutePath, + testSourceFileAbsolutePath: test.location.file, + testSourceLine: test.location.line, + browserName, + isNew: test._ddIsNew, + isDisabled: test._ddIsDisabled, + isModified: test._ddIsModified, + isQuarantined: test._ddIsQuarantined, + }) + } + remainingTestsByFile[testSuiteAbsolutePath] = [] + + const testStatuses = testSuiteToTestStatuses.get(testSuiteAbsolutePath) + let testSuiteStatus = 'pass' + if (testStatuses?.includes('fail')) { + testSuiteStatus = 'fail' + } else if (testStatuses?.every(status => status === 'skip')) { + testSuiteStatus = 'skip' + } + + const suiteError = getTestSuiteError(testSuiteAbsolutePath) + const testSuiteCtx = testSuiteToCtx.get(testSuiteAbsolutePath) + if (testSuiteCtx) { + testSuiteFinishCh.publish({ status: testSuiteStatus, error: suiteError, ...testSuiteCtx.currentStore }) + } +} + function testEndHandler ({ test, annotations, @@ -420,11 +703,21 @@ function testEndHandler ({ return } + if (test._ddShouldSkipEfdRetry || shouldSkipEfdRetry(test)) { + test._ddShouldSkipEfdRetry = true + remainingTestsByFile[testSuiteAbsolutePath] = remainingTestsByFile[testSuiteAbsolutePath] + .filter(currentTest => currentTest !== test) + finishTestSuiteIfDone(testSuiteAbsolutePath, projects) + return + } + + const isEfdManagedTest = isTestEfdManaged(test) const testFqn = getTestFullyQualifiedName(test) - const testStatuses = testsToTestStatuses.get(testFqn) || [] + const testStatusKey = isEfdManagedTest ? getTestEfdKey(test) : testFqn + const testStatuses = testsToTestStatuses.get(testStatusKey) || [] if (testStatuses.length === 0) { - testsToTestStatuses.set(testFqn, [testStatus]) + testsToTestStatuses.set(testStatusKey, [testStatus]) if (test._ddIsNew && DYNAMIC_NAME_RE.test(getTestFullname(test))) { newTestsWithDynamicNames.add(`${getTestSuitePath(test._requireFile, rootDir)} › ${getTestFullname(test)}`) } @@ -432,6 +725,17 @@ function testEndHandler ({ testStatuses.push(testStatus) } + const testEfdKey = getTestEfdKey(test) + if (isEfdManagedTest && !test._ddIsEfdRetry && !efdRetryCountByTestKey.has(testEfdKey)) { + const testResult = results.at(-1) + const duration = testResult?.duration > 0 ? testResult.duration : performance.now() - test._ddStartTime + const retryCount = getEfdRetryCount(duration, getTestEfdSlowTestRetries(test)) + setEfdRetryCountForTest(test, retryCount) + if (retryCount === 0) { + efdSlowAbortedTests.add(testEfdKey) + } + } + const testProperties = getTestProperties(test) if (testProperties.attemptToFix) { @@ -460,9 +764,10 @@ function testEndHandler ({ } // Check if all EFD retries failed - if (testStatuses.length === earlyFlakeDetectionNumRetries + 1 && + const efdRetryCount = getEfdRetryCountForTest(test) + if (efdRetryCount > 0 && testStatuses.length === efdRetryCount + 1 && (test._ddIsNew || test._ddIsModified) && - test._ddIsEfdRetry && + isEarlyFlakeDetectionEnabled && testStatuses.every(status => status === 'fail')) { test._ddHasFailedAllRetries = true } @@ -480,9 +785,6 @@ function testEndHandler ({ if (shouldCreateTestSpan) { const testResult = results.at(-1) const testCtx = testToCtx.get(test) - const isEfdManagedTest = (test._ddIsNew || test._ddIsModified) && - !test._ddIsAttemptToFix && - isEarlyFlakeDetectionEnabled const isAtrRetry = testResult?.retry > 0 && isFlakyTestRetriesEnabled && !test._ddIsAttemptToFix && @@ -497,6 +799,7 @@ function testEndHandler ({ isAttemptToFix: test._ddIsAttemptToFix, hasFailedAllRetries: test._ddHasFailedAllRetries, hasFailedAttemptToFixRetries: test._ddHasFailedAttemptToFixRetries, + hasPassedAnyEfdAttempt: testStatuses.includes('pass'), testStatus, }) @@ -520,6 +823,7 @@ function testEndHandler ({ isAtrRetry, isModified: test._ddIsModified, finalStatus, + earlyFlakeAbortReason: efdSlowAbortedTests.has(testEfdKey) ? 'slow' : undefined, ...testCtx.currentStore, }) } @@ -540,38 +844,7 @@ function testEndHandler ({ .filter(currentTest => currentTest !== test) } - if (shouldFinishTestSuite(testSuiteAbsolutePath)) { - const skippedTests = remainingTestsByFile[testSuiteAbsolutePath] - .filter(test => test.expectedStatus === 'skipped') - - for (const test of skippedTests) { - const browserName = getBrowserNameFromProjects(projects, test) - testSkipCh.publish({ - testName: getTestFullname(test), - testSuiteAbsolutePath, - testSourceFileAbsolutePath: test.location.file, - testSourceLine: test.location.line, - browserName, - isNew: test._ddIsNew, - isDisabled: test._ddIsDisabled, - isModified: test._ddIsModified, - isQuarantined: test._ddIsQuarantined, - }) - } - remainingTestsByFile[testSuiteAbsolutePath] = [] - - const testStatuses = testSuiteToTestStatuses.get(testSuiteAbsolutePath) - let testSuiteStatus = 'pass' - if (testStatuses.includes('fail')) { - testSuiteStatus = 'fail' - } else if (testStatuses.every(status => status === 'skip')) { - testSuiteStatus = 'skip' - } - - const suiteError = getTestSuiteError(testSuiteAbsolutePath) - const testSuiteCtx = testSuiteToCtx.get(testSuiteAbsolutePath) - testSuiteFinishCh.publish({ status: testSuiteStatus, error: suiteError, ...testSuiteCtx.currentStore }) - } + finishTestSuiteIfDone(testSuiteAbsolutePath, projects) } function dispatcherRunWrapper (run) { @@ -581,6 +854,39 @@ function dispatcherRunWrapper (run) { } } +function deferEfdRetryGroups (testGroups) { + const groupsWithOriginalTests = [] + const efdRetryOnlyGroups = [] + + for (const group of testGroups) { + const originalTests = [] + const efdRetryTests = [] + + for (const test of group.tests) { + if (test._ddIsEfdRetry) { + efdRetryTests.push(test) + } else { + originalTests.push(test) + if (isTestEfdManaged(test)) { + efdScheduledOriginalTestKeys.add(getTestEfdKey(test)) + } + } + } + + if (efdRetryTests.length && originalTests.length) { + group.tests = [...originalTests, ...efdRetryTests] + } + + if (originalTests.length) { + groupsWithOriginalTests.push(group) + } else { + efdRetryOnlyGroups.push(group) + } + } + + return [...groupsWithOriginalTests, ...efdRetryOnlyGroups] +} + function dispatcherRunWrapperNew (run) { return function (testGroups) { // Filter out disabled tests from testGroups before they get scheduled, @@ -593,12 +899,17 @@ function dispatcherRunWrapperNew (run) { testGroups = testGroups.filter(group => group.tests.length > 0) } + if (isEarlyFlakeDetectionEnabled) { + testGroups = deferEfdRetryGroups(testGroups) + } + if (!this._allTests) { // Removed in https://github.com/microsoft/playwright/commit/1e52c37b254a441cccf332520f60225a5acc14c7 // Not available from >=1.44.0 this._ddAllTests = testGroups.flatMap(g => g.tests) } remainingTestsByFile = getTestsBySuiteFromTestGroups(testGroups) + arguments[0] = testGroups return run.apply(this, arguments) } } @@ -638,7 +949,6 @@ function dispatcherHook (dispatcherExport) { ) } }) - return worker }) return dispatcherExport @@ -690,14 +1000,11 @@ function dispatcherHookNew (dispatcherExport, runWrapper) { // above) and mark the execution final once the count reaches the expected total. // This mirrors how ATF finality is detected and centralizes the decision in the // main process, so workers only need to act on the _ddIsFinalExecution flag. - const isEfdManagedTest = (test._ddIsNew || test._ddIsModified) && - !test._ddIsAttemptToFix && - isEarlyFlakeDetectionEnabled + const isEfdManagedTest = isTestEfdManaged(test) let isFinalExecution if (isEfdManagedTest) { - const testFqn = getTestFullyQualifiedName(test) - const efdTestStatuses = testsToTestStatuses.get(testFqn) || [] - isFinalExecution = efdTestStatuses.length === earlyFlakeDetectionNumRetries + 1 + const efdTestStatuses = testsToTestStatuses.get(getTestEfdKey(test)) || [] + isFinalExecution = efdTestStatuses.length === getEfdRetryCountForTest(test) + 1 } else if (test._ddIsAttemptToFix) { isFinalExecution = !!(test._ddHasPassedAttemptToFixRetries || test._ddHasFailedAttemptToFixRetries) } else { @@ -722,10 +1029,11 @@ function dispatcherHookNew (dispatcherExport, runWrapper) { _ddIsModified: test._ddIsModified, _ddIsFinalExecution: isFinalExecution, _ddIsEfdManagedTest: isEfdManagedTest, + _ddEarlyFlakeAbortReason: efdSlowAbortedTests.has(getTestEfdKey(test)) ? 'slow' : undefined, + _ddHasPassedAnyEfdAttempt: (testsToTestStatuses.get(getTestEfdKey(test)) || []).includes('pass'), }, }) }) - return worker }) return dispatcherExport @@ -751,6 +1059,7 @@ function runAllTestsWrapper (runAllTests, playwrightVersion) { isKnownTestsEnabled = libraryConfig.isKnownTestsEnabled isEarlyFlakeDetectionEnabled = libraryConfig.isEarlyFlakeDetectionEnabled earlyFlakeDetectionNumRetries = libraryConfig.earlyFlakeDetectionNumRetries + earlyFlakeDetectionSlowTestRetries = libraryConfig.earlyFlakeDetectionSlowTestRetries ?? {} earlyFlakeDetectionFaultyThreshold = libraryConfig.earlyFlakeDetectionFaultyThreshold isFlakyTestRetriesEnabled = libraryConfig.isFlakyTestRetriesEnabled flakyTestRetriesCount = libraryConfig.flakyTestRetriesCount @@ -900,6 +1209,13 @@ function runAllTestsWrapper (runAllTests, playwrightVersion) { remainingTestsByFile = {} quarantinedButNotAttemptToFixFqns = new Set() testsReportedInGenerateSummary = new Set() + efdManagedTestKeys.clear() + efdRetryCountByTestKey.clear() + efdRetryCountRequestsByTestKey.clear() + efdRetryTestsById.clear() + efdScheduledOriginalTestKeys.clear() + efdStartedOriginalTestKeys.clear() + efdSlowAbortedTests.clear() // TODO: we can trick playwright into thinking the session passed by returning // 'passed' here. We might be able to use this for both EFD and Test Management tests. @@ -1001,12 +1317,29 @@ addHook({ * - we execute `applyRepeatEachIndex` for each of these cloned file suites * - we add the cloned file suites to the project suite */ -function applyRetriesToTests (fileSuitesWithTestsToRetry, filterTest, tagsToApply, numRetries) { +function applyRetriesToTests ( + fileSuitesWithTestsToRetry, + filterTest, + tagsToApply, + numRetries, + configureCopiedTest, + getRetryRepeatEachIndex +) { for (const [fileSuite, projectSuite] of fileSuitesWithTestsToRetry.entries()) { for (let repeatEachIndex = 1; repeatEachIndex <= numRetries; repeatEachIndex++) { - const copyFileSuite = deepCloneSuite(fileSuite, filterTest, tagsToApply) - applyRepeatEachIndex(projectSuite._fullProject, copyFileSuite, repeatEachIndex + 1) + const copyFileSuite = deepCloneSuite(fileSuite, filterTest, tagsToApply, (copiedTest, originalTest) => { + if (configureCopiedTest) { + configureCopiedTest(copiedTest, originalTest, repeatEachIndex) + } + }) + const retryRepeatEachIndex = getRetryRepeatEachIndex + ? getRetryRepeatEachIndex(fileSuite, projectSuite, repeatEachIndex, numRetries) + : repeatEachIndex + 1 + applyRepeatEachIndex(projectSuite._fullProject, copyFileSuite, retryRepeatEachIndex) projectSuite._addSuite(copyFileSuite) + for (const copiedTest of copyFileSuite.allTests()) { + registerEfdRetryTest(copiedTest) + } } } } @@ -1091,6 +1424,7 @@ addHook({ for (const impactedTest of impactedTests) { impactedTest._ddIsModified = true if (isEarlyFlakeDetectionEnabled && impactedTest.expectedStatus !== 'skipped') { + markEfdManagedTest(impactedTest) const fileSuite = getSuiteType(impactedTest, 'file') if (!fileSuitesWithImpactedTestsToProjects.has(fileSuite)) { fileSuitesWithImpactedTestsToProjects.set(fileSuite, getSuiteType(impactedTest, 'project')) @@ -1106,7 +1440,12 @@ addHook({ '_ddIsEfdRetry', (test) => (isKnownTestsEnabled && isNewTest(test) ? '_ddIsNew' : null), ], - earlyFlakeDetectionNumRetries + getConfiguredEfdRetryCount(), + (copiedTest, originalTest, retryIndex) => { + markEfdRetryTest(copiedTest, retryIndex, originalTest) + markEfdManagedTest(copiedTest) + }, + getEfdRetryRepeatEachIndex ) } @@ -1130,6 +1469,7 @@ addHook({ if (isEarlyFlakeDetectionEnabled && newTest.expectedStatus !== 'skipped' && !newTest._ddIsModified) { // Prevent ATR or `--retries` from retrying new tests if EFD is enabled newTest.retries = 0 + markEfdManagedTest(newTest) const fileSuite = getSuiteType(newTest, 'file') if (!fileSuitesWithNewTestsToProjects.has(fileSuite)) { fileSuitesWithNewTestsToProjects.set(fileSuite, getSuiteType(newTest, 'project')) @@ -1141,7 +1481,12 @@ addHook({ fileSuitesWithNewTestsToProjects, isNewTest, ['_ddIsNew', '_ddIsEfdRetry'], - earlyFlakeDetectionNumRetries + getConfiguredEfdRetryCount(), + (copiedTest, originalTest, retryIndex) => { + markEfdRetryTest(copiedTest, retryIndex, originalTest) + markEfdManagedTest(copiedTest) + }, + getEfdRetryRepeatEachIndex ) } } @@ -1177,6 +1522,10 @@ addHook({ // We add a new listener to `this.process`, which is represents the worker this.process.on('message', (message) => { + if (message?.type === EFD_RETRY_COUNT_REQUEST) { + sendEfdRetryCountToWorkerWhenAvailable(this.process, message.testId) + return + } // These messages are [code, payload]. The payload is test data if (Array.isArray(message) && message[0] === PLAYWRIGHT_WORKER_TRACE_PAYLOAD_CODE) { workerReportCh.publish(message[1]) @@ -1235,9 +1584,15 @@ addHook({ const stepInfoByStepId = {} shimmer.wrap(workerPackage.WorkerMain.prototype, '_runTest', _runTest => async function (test) { + await waitForEfdRetryCount(test) + if (shouldSkipEfdRetry(test)) { + test._ddShouldSkipEfdRetry = true + test.expectedStatus = 'skipped' + } if (test.expectedStatus === 'skipped') { return _runTest.apply(this, arguments) } + test._ddStartTime = performance.now() steps = [] const { @@ -1320,6 +1675,21 @@ addHook({ await res const { status, error, annotations, retry, testId } = testInfo + const testEfdKey = getTestEfdKey(test) + const isEfdManagedTest = isTestEfdManaged(test) + if (isEfdManagedTest && !test._ddIsEfdRetry && !efdRetryCountByTestKey.has(testEfdKey)) { + const duration = test.results?.at(-1)?.duration > 0 + ? test.results.at(-1).duration + : performance.now() - test._ddStartTime + const retryCount = getEfdRetryCount( + duration, + getTestEfdSlowTestRetries(test) + ) + setEfdRetryCountForTest(test, retryCount) + if (retryCount === 0) { + efdSlowAbortedTests.add(testEfdKey) + } + } // testInfo.errors could be better than "error", // which will only include timeout error (even though the test failed because of a different error) @@ -1365,6 +1735,7 @@ addHook({ isAttemptToFix: test._ddIsAttemptToFix, hasFailedAllRetries: test._ddHasFailedAllRetries, hasFailedAttemptToFixRetries: test._ddHasFailedAttemptToFixRetries, + hasPassedAnyEfdAttempt: test._ddHasPassedAnyEfdAttempt, testStatus: STATUS_TO_TEST_STATUS[status], }) @@ -1388,6 +1759,7 @@ addHook({ isModified: test._ddIsModified, onDone, finalStatus, + earlyFlakeAbortReason: test._ddEarlyFlakeAbortReason, ...testCtx.currentStore, }) diff --git a/packages/datadog-plugin-aws-sdk/src/base.js b/packages/datadog-plugin-aws-sdk/src/base.js index bdfd4d50e66..d724ccf1497 100644 --- a/packages/datadog-plugin-aws-sdk/src/base.js +++ b/packages/datadog-plugin-aws-sdk/src/base.js @@ -245,16 +245,15 @@ class BaseAwsSdkPlugin extends ClientPlugin { if (!span || !response.request) return const params = response.request.params const operation = response.request.operation - const extraTags = this.generateTags(params, operation, response) || {} - const tags = { - 'aws.response.request_id': response.requestId, - 'resource.name': operation, - 'span.kind': 'client', - ...extraTags, - } + // `'span.kind': 'client'` is already set by the start-meta; SQS overrides via `generateTags`. + span.setTag('aws.response.request_id', response.requestId) + span.setTag('resource.name', operation) - span.addTags(tags) + const extraTags = this.generateTags(params, operation, response) + if (extraTags) { + span.addTags(extraTags) + } if (this.constructor.isPayloadReporter && this.cloudTaggingConfig.response) { const maxDepth = this.cloudTaggingConfig.maxDepth diff --git a/packages/datadog-plugin-aws-sdk/src/services/cloudwatchlogs.js b/packages/datadog-plugin-aws-sdk/src/services/cloudwatchlogs.js index 5e19f15ede1..0e5151c5a4e 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/cloudwatchlogs.js +++ b/packages/datadog-plugin-aws-sdk/src/services/cloudwatchlogs.js @@ -6,7 +6,7 @@ class CloudwatchLogs extends BaseAwsSdkPlugin { static id = 'cloudwatchlogs' generateTags (params, operation) { - if (!params?.logGroupName) return {} + if (!params?.logGroupName) return return { 'resource.name': `${operation} ${params.logGroupName}`, diff --git a/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js b/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js index 10f82bd6a7a..716fb3ef075 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js +++ b/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js @@ -7,7 +7,7 @@ class EventBridge extends BaseAwsSdkPlugin { static isPayloadReporter = true generateTags (params, operation, response) { - if (!params?.source) return {} + if (!params?.source) return const rulename = params.Name ?? '' return { 'resource.name': operation ? `${operation} ${params.source}` : params.source, diff --git a/packages/datadog-plugin-aws-sdk/src/services/kinesis.js b/packages/datadog-plugin-aws-sdk/src/services/kinesis.js index daee18085a0..c8d102db054 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/kinesis.js +++ b/packages/datadog-plugin-aws-sdk/src/services/kinesis.js @@ -68,7 +68,7 @@ class Kinesis extends BaseAwsSdkPlugin { } generateTags (params, operation, response) { - if (!params || !params.StreamName) return {} + if (!params || !params.StreamName) return return { 'resource.name': `${operation} ${params.StreamName}`, diff --git a/packages/datadog-plugin-aws-sdk/src/services/lambda.js b/packages/datadog-plugin-aws-sdk/src/services/lambda.js index 99c7645b75d..2d3d81e5452 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/lambda.js +++ b/packages/datadog-plugin-aws-sdk/src/services/lambda.js @@ -7,7 +7,7 @@ class Lambda extends BaseAwsSdkPlugin { static id = 'lambda' generateTags (params, operation, response) { - if (!params?.FunctionName) return {} + if (!params?.FunctionName) return return { 'resource.name': `${operation} ${params.FunctionName}`, diff --git a/packages/datadog-plugin-aws-sdk/src/services/redshift.js b/packages/datadog-plugin-aws-sdk/src/services/redshift.js index 7bca0d23005..669c4e72bd4 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/redshift.js +++ b/packages/datadog-plugin-aws-sdk/src/services/redshift.js @@ -6,7 +6,7 @@ class Redshift extends BaseAwsSdkPlugin { static id = 'redshift' generateTags (params, operation, response) { - if (!params?.ClusterIdentifier) return {} + if (!params?.ClusterIdentifier) return return { 'resource.name': `${operation} ${params.ClusterIdentifier}`, diff --git a/packages/datadog-plugin-aws-sdk/src/services/s3.js b/packages/datadog-plugin-aws-sdk/src/services/s3.js index 5593c131f92..282eb6b10a7 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/s3.js +++ b/packages/datadog-plugin-aws-sdk/src/services/s3.js @@ -11,7 +11,7 @@ class S3 extends BaseAwsSdkPlugin { static isPayloadReporter = true generateTags (params, operation, response) { - if (!params?.Bucket) return {} + if (!params?.Bucket) return return { 'resource.name': `${operation} ${params.Bucket}`, diff --git a/packages/datadog-plugin-aws-sdk/src/services/sns.js b/packages/datadog-plugin-aws-sdk/src/services/sns.js index e3691595711..a98793f4407 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/sns.js +++ b/packages/datadog-plugin-aws-sdk/src/services/sns.js @@ -10,9 +10,9 @@ class Sns extends BaseAwsSdkPlugin { static isPayloadReporter = true generateTags (params, operation, response) { - if (!params) return {} + if (!params) return - if (!params.TopicArn && !(response.data && response.data.TopicArn)) return {} + if (!params.TopicArn && !(response.data && response.data.TopicArn)) return const TopicArn = params.TopicArn || response.data.TopicArn // Get the topic name from the last `:`-delimited segment of the ARN diff --git a/packages/datadog-plugin-aws-sdk/src/services/sqs.js b/packages/datadog-plugin-aws-sdk/src/services/sqs.js index 893431aacec..31b37b19752 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/sqs.js +++ b/packages/datadog-plugin-aws-sdk/src/services/sqs.js @@ -99,7 +99,7 @@ class Sqs extends BaseAwsSdkPlugin { } generateTags (params, operation, response) { - if (!params || (!params.QueueName && !params.QueueUrl)) return {} + if (!params || (!params.QueueName && !params.QueueUrl)) return const queueMetadata = extractQueueMetadata(params.QueueUrl) const queueName = queueMetadata?.queueName || params.QueueName diff --git a/packages/datadog-plugin-aws-sdk/src/services/stepfunctions.js b/packages/datadog-plugin-aws-sdk/src/services/stepfunctions.js index bba915e45a5..283ccd22be0 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/stepfunctions.js +++ b/packages/datadog-plugin-aws-sdk/src/services/stepfunctions.js @@ -28,7 +28,7 @@ class Stepfunctions extends BaseAwsSdkPlugin { // } generateTags (params, operation, response) { - if (!params) return {} + if (!params) return const tags = { 'resource.name': params.name ? `${operation} ${params.name}` : `${operation}` } if (operation === 'startExecution' || operation === 'startSyncExecution') { tags.statemachinearn = `${params.stateMachineArn}` diff --git a/packages/datadog-plugin-aws-sdk/test/aws-sdk.spec.js b/packages/datadog-plugin-aws-sdk/test/aws-sdk.spec.js index 997a531a21e..20c1f51f7a9 100644 --- a/packages/datadog-plugin-aws-sdk/test/aws-sdk.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/aws-sdk.spec.js @@ -7,9 +7,8 @@ const semver = require('semver') const { ERROR_MESSAGE, ERROR_STACK, ERROR_TYPE } = require('../../dd-trace/src/constants') const agent = require('../../dd-trace/test/plugins/agent') -const { withVersions } = require('../../dd-trace/test/setup/mocha') const { assertObjectContains } = require('../../../integration-tests/helpers') -const { setup, sort } = require('./spec_helpers') +const { setup, sort, withAwsSdkV2Versions, withAwsSdkVersions } = require('./spec_helpers') describe('Plugin', () => { // TODO: use the Request class directly for generic tests @@ -17,7 +16,7 @@ describe('Plugin', () => { describe('aws-sdk direct import', function () { setup() - withVersions('aws-sdk', ['aws-sdk'], (version) => { + withAwsSdkV2Versions((version) => { if (semver.intersects(version, '>2.3.0')) { const S3 = require(`../../../versions/aws-sdk@${version}`).get('aws-sdk/clients/s3') const s3 = new S3({ endpoint: 'http://127.0.0.1:4566', region: 'us-east-1', s3ForcePathStyle: true }) @@ -74,7 +73,7 @@ describe('Plugin', () => { describe('aws-sdk', function () { setup() - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let AWS let s3 let sqs diff --git a/packages/datadog-plugin-aws-sdk/test/bedrockruntime.spec.js b/packages/datadog-plugin-aws-sdk/test/bedrockruntime.spec.js index 28bb31bd6f6..c2b4be60384 100644 --- a/packages/datadog-plugin-aws-sdk/test/bedrockruntime.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/bedrockruntime.spec.js @@ -4,8 +4,7 @@ const assert = require('node:assert') const { describe, it, before, after } = require('mocha') const agent = require('../../dd-trace/test/plugins/agent') -const { withVersions } = require('../../dd-trace/test/setup/mocha') -const { setup } = require('./spec_helpers') +const { setup, withAwsSdkVersions } = require('./spec_helpers') const { models } = require('./fixtures/bedrockruntime') const serviceName = 'bedrock-service-name-test' @@ -13,7 +12,7 @@ describe('Plugin', () => { describe('aws-sdk (bedrockruntime)', function () { setup() - withVersions('aws-sdk', ['@aws-sdk/smithy-client', 'aws-sdk'], '>=3', (version, moduleName) => { + withAwsSdkVersions('>=3', (version, moduleName) => { let AWS let bedrockRuntimeClient diff --git a/packages/datadog-plugin-aws-sdk/test/dynamodb.spec.js b/packages/datadog-plugin-aws-sdk/test/dynamodb.spec.js index b82999994f8..ed049dd8e0b 100644 --- a/packages/datadog-plugin-aws-sdk/test/dynamodb.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/dynamodb.spec.js @@ -9,10 +9,9 @@ const { after, before, beforeEach, describe, it } = require('mocha') const { DYNAMODB_PTR_KIND, SPAN_POINTER_DIRECTION } = require('../../dd-trace/src/constants') const agent = require('../../dd-trace/test/plugins/agent') -const { withVersions } = require('../../dd-trace/test/setup/mocha') const DynamoDb = require('../src/services/dynamodb') const { generatePointerHash } = require('../src/util') -const { setup } = require('./spec_helpers') +const { setup, withAwsSdkVersions } = require('./spec_helpers') /* eslint-disable no-console */ async function resetLocalStackDynamo () { try { @@ -28,7 +27,7 @@ describe('Plugin', () => { setup() this.timeout(10000) - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let tracer let AWS let dynamo diff --git a/packages/datadog-plugin-aws-sdk/test/eventbridge.spec.js b/packages/datadog-plugin-aws-sdk/test/eventbridge.spec.js index 1177856e45f..ecbb4fe18c3 100644 --- a/packages/datadog-plugin-aws-sdk/test/eventbridge.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/eventbridge.spec.js @@ -7,12 +7,12 @@ const { before, describe, it } = require('mocha') const sinon = require('sinon') const EventBridge = require('../src/services/eventbridge') -const { withVersions } = require('../../dd-trace/test/setup/mocha') const tracer = require('../../dd-trace') +const { withAwsSdkVersions } = require('./spec_helpers') describe('EventBridge', () => { let span - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let traceId let parentId let spanId @@ -70,7 +70,7 @@ describe('EventBridge', () => { const params = { foo: 'bar', } - assert.deepStrictEqual(eventbridge.generateTags(params, 'putEvent', {}), {}) + assert.strictEqual(eventbridge.generateTags(params, 'putEvent', {}), undefined) }) it('injects trace context to Eventbridge putEvents', () => { @@ -126,17 +126,17 @@ describe('EventBridge', () => { assert.deepStrictEqual(request.params, request.params) }) - it('returns an empty object when params is null', () => { + it('returns undefined when params is null', () => { const eventbridge = new EventBridge(tracer) - assert.deepStrictEqual(eventbridge.generateTags(null, 'putEvent', {}), {}) + assert.strictEqual(eventbridge.generateTags(null, 'putEvent', {}), undefined) }) - it('returns an empty object when params.source is an empty string', () => { + it('returns undefined when params.source is an empty string', () => { const eventbridge = new EventBridge(tracer) const params = { source: '', } - assert.deepStrictEqual(eventbridge.generateTags(params, 'putEvent', {}), {}) + assert.strictEqual(eventbridge.generateTags(params, 'putEvent', {}), undefined) }) it('sets rulename as an empty string when params.Name is null', () => { diff --git a/packages/datadog-plugin-aws-sdk/test/integration-test/client.spec.js b/packages/datadog-plugin-aws-sdk/test/integration-test/client.spec.js index d2dcc9243d2..f0959ce7f0c 100644 --- a/packages/datadog-plugin-aws-sdk/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/integration-test/client.spec.js @@ -11,13 +11,13 @@ const { varySandbox, stopProc, } = require('../../../../integration-tests/helpers') -const { withVersions } = require('../../../dd-trace/test/setup/mocha') +const { withAwsSdkV2Versions } = require('../spec_helpers') describe('esm', () => { let agent let proc let variants - withVersions('aws-sdk', ['aws-sdk'], version => { + withAwsSdkV2Versions(version => { useSandbox([`'aws-sdk@${version}'`], false, [ './packages/datadog-plugin-aws-sdk/test/integration-test/*']) diff --git a/packages/datadog-plugin-aws-sdk/test/integration-test/sqs.spec.js b/packages/datadog-plugin-aws-sdk/test/integration-test/sqs.spec.js index f4398267b47..2d5b1029412 100644 --- a/packages/datadog-plugin-aws-sdk/test/integration-test/sqs.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/integration-test/sqs.spec.js @@ -9,13 +9,13 @@ const { spawnPluginIntegrationTestProcAndExpectExit, stopProc, } = require('../../../../integration-tests/helpers') -const { withVersions } = require('../../../dd-trace/test/setup/mocha') +const { withAwsSdkV3Versions } = require('../spec_helpers') describe('recursion regression test', () => { let agent let proc - withVersions('aws-sdk', ['@aws-sdk/smithy-client'], version => { + withAwsSdkV3Versions(version => { useSandbox([`'@aws-sdk/client-sqs'@${version}'`], false, [ './packages/datadog-plugin-aws-sdk/test/integration-test/*']) diff --git a/packages/datadog-plugin-aws-sdk/test/kinesis.dsm.spec.js b/packages/datadog-plugin-aws-sdk/test/kinesis.dsm.spec.js index 07f3bb281c9..c1a12fb1174 100644 --- a/packages/datadog-plugin-aws-sdk/test/kinesis.dsm.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/kinesis.dsm.spec.js @@ -6,20 +6,19 @@ const { afterEach, beforeEach, describe, it } = require('mocha') const sinon = require('sinon') const { assertObjectContains } = require('../../../integration-tests/helpers') -const { withVersions } = require('../../dd-trace/test/setup/mocha') const agent = require('../../dd-trace/test/plugins/agent') const id = require('../../dd-trace/src/id') const { computePathwayHash } = require('../../dd-trace/src/datastreams/pathway') const { ENTRY_PARENT_HASH } = require('../../dd-trace/src/datastreams/processor') const propagationHash = require('../../dd-trace/src/propagation-hash') const helpers = require('./kinesis_helpers') -const { setup } = require('./spec_helpers') +const { setup, withAwsSdkVersions } = require('./spec_helpers') describe('Kinesis', function () { this.timeout(10000) setup() - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let AWS let kinesis let tracer diff --git a/packages/datadog-plugin-aws-sdk/test/kinesis.spec.js b/packages/datadog-plugin-aws-sdk/test/kinesis.spec.js index 70c8a99d2d0..acd0f0ef455 100644 --- a/packages/datadog-plugin-aws-sdk/test/kinesis.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/kinesis.spec.js @@ -5,10 +5,10 @@ const assert = require('node:assert/strict') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') const { assertObjectContains } = require('../../../integration-tests/helpers') -const { withNamingSchema, withVersions } = require('../../dd-trace/test/setup/mocha') +const { withNamingSchema } = require('../../dd-trace/test/setup/mocha') const agent = require('../../dd-trace/test/plugins/agent') const id = require('../../dd-trace/src/id') -const { setup } = require('./spec_helpers') +const { setup, withAwsSdkVersions } = require('./spec_helpers') const helpers = require('./kinesis_helpers') const { rawExpectedSchema } = require('./kinesis-naming') @@ -16,7 +16,7 @@ describe('Kinesis', function () { this.timeout(10000) setup() - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let AWS let kinesis diff --git a/packages/datadog-plugin-aws-sdk/test/lambda.spec.js b/packages/datadog-plugin-aws-sdk/test/lambda.spec.js index f2a1dade09c..f849bc5c5da 100644 --- a/packages/datadog-plugin-aws-sdk/test/lambda.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/lambda.spec.js @@ -6,10 +6,10 @@ const JSZip = require('jszip') const { after, before, describe, it } = require('mocha') const agent = require('../../dd-trace/test/plugins/agent') -const { withNamingSchema, withVersions } = require('../../dd-trace/test/setup/mocha') +const { withNamingSchema } = require('../../dd-trace/test/setup/mocha') const { assertObjectContains } = require('../../../integration-tests/helpers') const { rawExpectedSchema } = require('./lambda-naming') -const { setup } = require('./spec_helpers') +const { setup, withAwsSdkVersions } = require('./spec_helpers') const zip = new JSZip() @@ -20,7 +20,7 @@ describe('Plugin', () => { this.timeout(10000) setup() - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let AWS let lambda let tracer diff --git a/packages/datadog-plugin-aws-sdk/test/s3.spec.js b/packages/datadog-plugin-aws-sdk/test/s3.spec.js index a5062234bb8..815c251efb9 100644 --- a/packages/datadog-plugin-aws-sdk/test/s3.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/s3.spec.js @@ -7,10 +7,10 @@ const { after, before, describe, it } = require('mocha') const { S3_PTR_KIND, SPAN_POINTER_DIRECTION } = require('../../dd-trace/src/constants') const agent = require('../../dd-trace/test/plugins/agent') -const { withNamingSchema, withPeerService, withVersions } = require('../../dd-trace/test/setup/mocha') +const { withNamingSchema, withPeerService } = require('../../dd-trace/test/setup/mocha') const { assertObjectContains } = require('../../../integration-tests/helpers') const { rawExpectedSchema } = require('./s3-naming') -const { setup } = require('./spec_helpers') +const { setup, withAwsSdkVersions } = require('./spec_helpers') const bucketName = 's3-bucket-name-test' @@ -28,7 +28,7 @@ describe('Plugin', () => { describe('aws-sdk (s3)', function () { setup() - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let AWS let s3 let tracer diff --git a/packages/datadog-plugin-aws-sdk/test/serverless-peer-service.spec.js b/packages/datadog-plugin-aws-sdk/test/serverless-peer-service.spec.js index feeea073358..d5550c6ff6a 100644 --- a/packages/datadog-plugin-aws-sdk/test/serverless-peer-service.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/serverless-peer-service.spec.js @@ -6,9 +6,8 @@ const { promisify } = require('node:util') const { after, before, describe, it } = require('mocha') const agent = require('../../dd-trace/test/plugins/agent') -const { withVersions } = require('../../dd-trace/test/setup/mocha') const helpers = require('./kinesis_helpers') -const { setup } = require('./spec_helpers') +const { setup, withAwsSdkVersions } = require('./spec_helpers') describe('Plugin', () => { describe('Serverless', function () { @@ -17,7 +16,7 @@ describe('Plugin', () => { this.timeout(15000) setup() - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let AWS before(async () => { diff --git a/packages/datadog-plugin-aws-sdk/test/sns.dsm.spec.js b/packages/datadog-plugin-aws-sdk/test/sns.dsm.spec.js index a69144fcefa..d897168518f 100644 --- a/packages/datadog-plugin-aws-sdk/test/sns.dsm.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/sns.dsm.spec.js @@ -10,15 +10,14 @@ const { computePathwayHash } = require('../../dd-trace/src/datastreams/pathway') const { ENTRY_PARENT_HASH } = require('../../dd-trace/src/datastreams/processor') const propagationHash = require('../../dd-trace/src/propagation-hash') const agent = require('../../dd-trace/test/plugins/agent') -const { withVersions } = require('../../dd-trace/test/setup/mocha') const { assertObjectContains } = require('../../../integration-tests/helpers') -const { setup } = require('./spec_helpers') +const { setup, withAwsSdkVersions } = require('./spec_helpers') describe('Sns', function () { setup() this.timeout(20000) - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let sns let sqs let subParams diff --git a/packages/datadog-plugin-aws-sdk/test/sns.spec.js b/packages/datadog-plugin-aws-sdk/test/sns.spec.js index f87f7970d56..9bdb25ca5c8 100644 --- a/packages/datadog-plugin-aws-sdk/test/sns.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/sns.spec.js @@ -6,16 +6,16 @@ const { after, before, describe, it } = require('mocha') const semver = require('semver') const { assertObjectContains } = require('../../../integration-tests/helpers') -const { withNamingSchema, withPeerService, withVersions } = require('../../dd-trace/test/setup/mocha') +const { withNamingSchema, withPeerService } = require('../../dd-trace/test/setup/mocha') const agent = require('../../dd-trace/test/plugins/agent') -const { setup } = require('./spec_helpers') +const { setup, withAwsSdkVersions } = require('./spec_helpers') const { rawExpectedSchema } = require('./sns-naming') describe('Sns', function () { setup() this.timeout(20000) - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let sns let sqs let subParams diff --git a/packages/datadog-plugin-aws-sdk/test/spec_helpers.js b/packages/datadog-plugin-aws-sdk/test/spec_helpers.js index 25309929e25..0273b7a9e1b 100644 --- a/packages/datadog-plugin-aws-sdk/test/spec_helpers.js +++ b/packages/datadog-plugin-aws-sdk/test/spec_helpers.js @@ -1,9 +1,76 @@ 'use strict' +const { withVersions } = require('../../dd-trace/test/setup/mocha') +const { NODE_MAJOR } = require('../../../version') + +const AWS_SDK_V3_RANGE = NODE_MAJOR === 18 ? '3.0.0' : '>3.0.0' + const sort = spans => spans.sort((a, b) => a.start.toString() >= b.start.toString() ? 1 : -1) +/** + * @callback AwsSdkVersionCallback + * @param {string} version + * @param {string} moduleName + * @param {string} resolvedVersion + * @returns {void} + */ + +/** + * @param {string|AwsSdkVersionCallback} range + * @param {AwsSdkVersionCallback} [cb] + * @returns {void} + */ +function withAwsSdkV2Versions (range, cb) { + if (typeof range === 'function') { + cb = range + range = undefined + } + + withVersions('aws-sdk', ['aws-sdk'], range, cb) +} + +/** + * @param {string|AwsSdkVersionCallback} range + * @param {AwsSdkVersionCallback} [cb] + * @returns {void} + */ +function withAwsSdkV3Versions (range, cb) { + if (typeof range === 'function') { + cb = range + range = undefined + } + + withVersions('aws-sdk', ['@aws-sdk/smithy-client'], getAwsSdkV3Range(range), cb) +} + +/** + * @param {string|AwsSdkVersionCallback} range + * @param {AwsSdkVersionCallback} [cb] + * @returns {void} + */ +function withAwsSdkVersions (range, cb) { + if (typeof range === 'function') { + cb = range + range = undefined + } + + withAwsSdkV2Versions(range, cb) + withAwsSdkV3Versions(range, cb) +} + +/** + * @param {string|undefined} range + * @returns {string} + */ +function getAwsSdkV3Range (range) { + return range === undefined ? AWS_SDK_V3_RANGE : `${range} ${AWS_SDK_V3_RANGE}` +} + const helpers = { sort, + withAwsSdkV2Versions, + withAwsSdkV3Versions, + withAwsSdkVersions, setup () { before(() => { diff --git a/packages/datadog-plugin-aws-sdk/test/sqs.dsm.spec.js b/packages/datadog-plugin-aws-sdk/test/sqs.dsm.spec.js index bab0deb28f4..a500dff9bb3 100644 --- a/packages/datadog-plugin-aws-sdk/test/sqs.dsm.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/sqs.dsm.spec.js @@ -11,9 +11,8 @@ const { computePathwayHash } = require('../../dd-trace/src/datastreams/pathway') const { ENTRY_PARENT_HASH } = require('../../dd-trace/src/datastreams/processor') const propagationHash = require('../../dd-trace/src/propagation-hash') const agent = require('../../dd-trace/test/plugins/agent') -const { withVersions } = require('../../dd-trace/test/setup/mocha') const { assertObjectContains } = require('../../../integration-tests/helpers') -const { setup } = require('./spec_helpers') +const { setup, withAwsSdkVersions } = require('./spec_helpers') const getQueueParams = (queueName) => { return { @@ -29,7 +28,7 @@ describe('Plugin', () => { this.timeout(10000) setup() - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let AWS let sqs let queueNameDSM diff --git a/packages/datadog-plugin-aws-sdk/test/sqs.spec.js b/packages/datadog-plugin-aws-sdk/test/sqs.spec.js index 23adfc6dbc9..0d37d79c4e9 100644 --- a/packages/datadog-plugin-aws-sdk/test/sqs.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/sqs.spec.js @@ -5,9 +5,9 @@ const { randomUUID } = require('node:crypto') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') const agent = require('../../dd-trace/test/plugins/agent') -const { withNamingSchema, withPeerService, withVersions } = require('../../dd-trace/test/setup/mocha') +const { withNamingSchema, withPeerService } = require('../../dd-trace/test/setup/mocha') const { assertObjectContains } = require('../../../integration-tests/helpers') -const { setup } = require('./spec_helpers') +const { setup, withAwsSdkVersions } = require('./spec_helpers') const { rawExpectedSchema } = require('./sqs-naming') const getQueueParams = (queueName) => { @@ -24,7 +24,7 @@ describe('Plugin', () => { this.timeout(10000) setup() - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let AWS let sqs let queueName diff --git a/packages/datadog-plugin-aws-sdk/test/stepfunctions.spec.js b/packages/datadog-plugin-aws-sdk/test/stepfunctions.spec.js index 22a02aea4ce..d70d196c17c 100644 --- a/packages/datadog-plugin-aws-sdk/test/stepfunctions.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/stepfunctions.spec.js @@ -6,8 +6,7 @@ const { afterEach, before, beforeEach, describe, it } = require('mocha') const semver = require('semver') const agent = require('../../dd-trace/test/plugins/agent') -const { withVersions } = require('../../dd-trace/test/setup/mocha') -const { setup } = require('./spec_helpers') +const { setup, withAwsSdkVersions } = require('./spec_helpers') const helloWorldSMD = { Comment: 'A Hello World example of the Amazon States Language using a Pass state', StartAt: 'HelloWorld', @@ -23,7 +22,7 @@ const helloWorldSMD = { describe('Sfn', () => { let tracer - withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + withAwsSdkVersions((version, moduleName) => { let stateMachineArn let client diff --git a/packages/datadog-plugin-couchbase/src/index.js b/packages/datadog-plugin-couchbase/src/index.js index 9130cb900b6..c462acdbf01 100644 --- a/packages/datadog-plugin-couchbase/src/index.js +++ b/packages/datadog-plugin-couchbase/src/index.js @@ -60,16 +60,9 @@ class CouchBasePlugin extends StoragePlugin { return ctx.currentStore }) - this.addBind('apm:couchbase:bucket:maybeInvoke:callback:start', callbackStart) - this.addBind('apm:couchbase:bucket:maybeInvoke:callback:finish', callbackFinish) - this.addBind('apm:couchbase:cluster:maybeInvoke:callback:start', callbackStart) - this.addBind('apm:couchbase:cluster:maybeInvoke:callback:finish', callbackFinish) - this._addCommandSubs('upsert') this._addCommandSubs('insert') this._addCommandSubs('replace') - this._addCommandSubs('append') - this._addCommandSubs('prepend') } _addCommandSubs (name) { diff --git a/packages/datadog-plugin-couchbase/test/index.spec.js b/packages/datadog-plugin-couchbase/test/index.spec.js index 935637f757d..0cb245a2121 100644 --- a/packages/datadog-plugin-couchbase/test/index.spec.js +++ b/packages/datadog-plugin-couchbase/test/index.spec.js @@ -24,151 +24,6 @@ describe('Plugin', () => { tracer = global.tracer = require('../../dd-trace') }) - withVersions('couchbase', 'couchbase', '<3.0.0', version => { - let N1qlQuery - // skipping tests due to bug with couchbase integration that is blocking CI. - // TODO: diagnose and fix failures. Link to bug issue: https://github.com/DataDog/dd-trace-js/issues/6400 - describe.skip('without configuration', () => { - beforeEach(done => { - agent.load('couchbase').then(() => { - couchbase = proxyquire(`../../../versions/couchbase@${version}`, {}).get() - N1qlQuery = couchbase.N1qlQuery - cluster = new couchbase.Cluster('localhost:8091') - cluster.authenticate('Administrator', 'password') - cluster.enableCbas('localhost:8095') - bucket = cluster.openBucket('datadog-test', (err) => done(err)) - }) - }) - - afterEach(() => { - bucket.disconnect() - }) - - after(() => { - return agent.close({ ritmReset: false }) - }) - - withNamingSchema( - done => cluster.query(N1qlQuery.fromString('SELECT 1+1'), err => err && done(err)), - rawExpectedSchema.query - ) - - it('should run the Query callback in the parent context', done => { - const query = 'SELECT 1+1' - const span = tracer.startSpan('test.query.cb') - - tracer.scope().activate(span, () => { - const n1qlQuery = N1qlQuery.fromString(query) - cluster.query(n1qlQuery, (err, rows) => { - assert.strictEqual(tracer.scope().active(), span) - done(err) - }) - }) - }) - - it('should run any Bucket operations in the parent context', done => { - const span = tracer.startSpan('test') - - tracer.scope().activate(span, () => { - bucket.get('1', () => { - assert.strictEqual(tracer.scope().active(), span) - done() - }) - }) - }) - - describe('queries on cluster', () => { - it('should handle N1QL queries', done => { - const query = 'SELECT 1+1' - - agent - .assertFirstTraceSpan({ - name: expectedSchema.query.opName, - service: expectedSchema.query.serviceName, - resource: query, - type: 'sql', - meta: { - 'span.kind': 'client', - 'couchbase.bucket.name': 'datadog-test', - component: 'couchbase', - '_dd.integration': 'couchbase', - }, - }) - .then(done) - .catch(done) - - const n1qlQuery = N1qlQuery.fromString(query) - cluster.query(n1qlQuery, (err) => { - if (err) done(err) - }) - - if (semver.intersects(version, '2.4.0 - 2.5.0')) { - // Due to bug JSCBC-491 in Couchbase, we have to reconnect to dispatch waiting queries - const triggerBucket = cluster.openBucket('datadog-test', (err) => { - if (err) done(err) - }) - triggerBucket.on('connect', () => triggerBucket.disconnect()) - } - }) - - it('should handle storage queries', done => { - agent - .assertFirstTraceSpan({ - name: expectedSchema.upsert.opName, - service: expectedSchema.upsert.serviceName, - resource: 'couchbase.upsert', - meta: { - 'span.kind': 'client', - 'couchbase.bucket.name': 'datadog-test', - component: 'couchbase', - }, - }) - .then(done) - .catch(done) - - bucket.upsert('testdoc', { name: 'Frank' }, (err, result) => { - if (err) done(err) - }) - }) - - it('should skip instrumentation for invalid arguments', (done) => { - try { - bucket.upsert('testdoc', { name: 'Frank' }) - } catch (e) { - assert.strictEqual(e.message, 'Third argument needs to be an object or callback.') - done() - } - }) - }) - - describe('queries on buckets', () => { - it('should handle N1QL queries', done => { - const query = 'SELECT 1+2' - - agent - .assertFirstTraceSpan({ - name: expectedSchema.query.opName, - service: expectedSchema.query.serviceName, - resource: query, - type: 'sql', - meta: { - 'span.kind': 'client', - 'couchbase.bucket.name': 'datadog-test', - component: 'couchbase', - }, - }) - .then(done) - .catch(done) - - const n1qlQuery = N1qlQuery.fromString(query) - bucket.query(n1qlQuery, (err) => { - if (err) done(err) - }) - }) - }) - }) - }) - withVersions('couchbase', 'couchbase', '>=3.0.0', version => { beforeEach(() => { tracer = global.tracer = require('../../dd-trace') diff --git a/packages/datadog-plugin-cucumber/src/index.js b/packages/datadog-plugin-cucumber/src/index.js index a108fdd3be8..c73d2fea2cc 100644 --- a/packages/datadog-plugin-cucumber/src/index.js +++ b/packages/datadog-plugin-cucumber/src/index.js @@ -141,6 +141,7 @@ class CucumberPlugin extends CiPlugin { 'cucumber' ), ...this.getSessionRequestErrorTags(), + ...this.getSessionItrSkippingEnabledTags(), } if (isUnskippable) { this.telemetry.count(TELEMETRY_ITR_UNSKIPPABLE, { testLevel: 'suite' }) diff --git a/packages/datadog-plugin-cypress/src/cypress-plugin.js b/packages/datadog-plugin-cypress/src/cypress-plugin.js index b3c12382f4c..edae1783d89 100644 --- a/packages/datadog-plugin-cypress/src/cypress-plugin.js +++ b/packages/datadog-plugin-cypress/src/cypress-plugin.js @@ -31,6 +31,7 @@ const { TEST_SKIPPED_BY_ITR, TEST_ITR_UNSKIPPABLE, TEST_ITR_FORCED_RUN, + TEST_ITR_SKIPPING_ENABLED, ITR_CORRELATION_ID, TEST_SOURCE_FILE, TEST_IS_NEW, @@ -52,6 +53,7 @@ const { getPullRequestDiff, getModifiedFilesFromDiff, getSessionRequestErrorTags, + getSessionItrSkippingEnabledTags, DD_CI_LIBRARY_CONFIGURATION_ERROR, TEST_IS_MODIFIED, TEST_HAS_DYNAMIC_NAME, @@ -320,6 +322,7 @@ class CypressPlugin { finishedTestsByFile = {} testStatuses = {} + hasLibraryConfiguration = false isTestsSkipped = false isSuitesSkippingEnabled = false isCodeCoverageEnabled = false @@ -393,6 +396,7 @@ class CypressPlugin { this._isInit = false this.finishedTestsByFile = {} this.testStatuses = {} + this.hasLibraryConfiguration = false this.isTestsSkipped = false this.isSuitesSkippingEnabled = false this.isCodeCoverageEnabled = false @@ -475,6 +479,7 @@ class CypressPlugin { value: 'true', }) } else { + this.hasLibraryConfiguration = true const { libraryConfig: { isSuitesSkippingEnabled, @@ -534,8 +539,10 @@ class CypressPlugin { } getTestSuiteSpan ({ testSuite, testSuiteAbsolutePath }) { - const testSuiteSpanMetadata = - getTestSuiteCommonTags(this.command, this.frameworkVersion, testSuite, TEST_FRAMEWORK_NAME) + const testSuiteSpanMetadata = { + ...getTestSuiteCommonTags(this.command, this.frameworkVersion, testSuite, TEST_FRAMEWORK_NAME), + ...this.getSessionItrSkippingEnabledTags(), + } this.ciVisEvent(TELEMETRY_EVENT_CREATED, 'suite') @@ -588,6 +595,7 @@ class CypressPlugin { if (testSourceFile) { testSpanMetadata[TEST_SOURCE_FILE] = testSourceFile } + Object.assign(testSpanMetadata, this.getSessionItrSkippingEnabledTags()) const codeOwners = this.getTestCodeOwners({ testSuite, testSourceFile }) if (codeOwners) { @@ -637,6 +645,15 @@ class CypressPlugin { return getSessionRequestErrorTags(this.testSessionSpan) } + /** + * Returns ITR skipping-enabled tags from the test session span for propagation to child events. + * + * @returns {Record} + */ + getSessionItrSkippingEnabledTags () { + return getSessionItrSkippingEnabledTags(this.testSessionSpan) + } + ciVisEvent (name, testLevel, tags = {}) { incrementCountMetric(name, { testLevel, @@ -789,6 +806,11 @@ class CypressPlugin { }, integrationName: TEST_FRAMEWORK_NAME, }) + if (this.hasLibraryConfiguration) { + const skippingEnabled = this.isSuitesSkippingEnabled ? 'true' : 'false' + this.testSessionSpan.setTag(TEST_ITR_SKIPPING_ENABLED, skippingEnabled) + this.testModuleSpan.setTag(TEST_ITR_SKIPPING_ENABLED, skippingEnabled) + } this.ciVisEvent(TELEMETRY_EVENT_CREATED, 'module') return details diff --git a/packages/datadog-plugin-google-cloud-pubsub/src/producer.js b/packages/datadog-plugin-google-cloud-pubsub/src/producer.js index e8e5138aa50..86e5cdf5343 100644 --- a/packages/datadog-plugin-google-cloud-pubsub/src/producer.js +++ b/packages/datadog-plugin-google-cloud-pubsub/src/producer.js @@ -54,9 +54,13 @@ class GoogleCloudPubsubProducerPlugin extends ProducerPlugin { * - Inject batch span context + metadata into all message attributes for downstream * consumers to reconstruct the trace and understand batch relationships */ - const spanLinkData = hasTraceContext - ? messages.slice(1).map(msg => this.#extractSpanLink(msg.attributes)).filter(Boolean) - : [] + const spanLinkData = [] + if (hasTraceContext) { + for (let i = 1; i < messageCount; i++) { + const link = this.#extractSpanLink(messages[i].attributes) + if (link) spanLinkData.push(link) + } + } const firstAttrs = messages[0]?.attributes const parentData = firstAttrs?.['x-datadog-trace-id'] && firstAttrs['x-datadog-parent-id'] @@ -107,35 +111,40 @@ class GoogleCloudPubsubProducerPlugin extends ProducerPlugin { )) } - for (let i = 0; i < messages.length; i++) { + const messageCountStr = String(messageCount) + const startTimeStr = String(Math.floor(batchSpan._startTime)) + const dsmEnabled = this.config.dsmEnabled + + for (let i = 0; i < messageCount; i++) { const msg = messages[i] - msg.attributes ??= {} + const attributes = msg.attributes ??= {} if (!hasTraceContext) { - this.tracer.inject(batchSpan, 'text_map', msg.attributes) + this.tracer.inject(batchSpan, 'text_map', attributes) } - Object.assign(msg.attributes, { - '_dd.pubsub_request.trace_id': batchTraceIdHex, - '_dd.pubsub_request.span_id': batchSpanIdHex, - '_dd.batch.size': String(messageCount), - '_dd.batch.index': String(i), - 'gcloud.project_id': projectId, - 'pubsub.topic': topic, - 'x-dd-publish-start-time': String(Math.floor(batchSpan._startTime)), - }) + // Assign keys one-by-one rather than via `Object.assign({...})` so V8 + // keeps the attributes object on its existing hidden class instead of + // allocating a fresh literal per message. + attributes['_dd.pubsub_request.trace_id'] = batchTraceIdHex + attributes['_dd.pubsub_request.span_id'] = batchSpanIdHex + attributes['_dd.batch.size'] = messageCountStr + attributes['_dd.batch.index'] = String(i) + attributes['gcloud.project_id'] = projectId + attributes['pubsub.topic'] = topic + attributes['x-dd-publish-start-time'] = startTimeStr if (batchTraceIdUpper) { - msg.attributes['_dd.pubsub_request.p.tid'] = batchTraceIdUpper + attributes['_dd.pubsub_request.p.tid'] = batchTraceIdUpper } - if (this.config.dsmEnabled) { + if (dsmEnabled) { const dataStreamsContext = this.tracer.setCheckpoint( ['direction:out', `topic:${topic}`, 'type:google-pubsub'], batchSpan, getHeadersSize(msg) ) - DsmPathwayCodec.encode(dataStreamsContext, msg.attributes) + DsmPathwayCodec.encode(dataStreamsContext, attributes) } } diff --git a/packages/datadog-plugin-grpc/src/client.js b/packages/datadog-plugin-grpc/src/client.js index e1f96a37a9f..23bce206cd4 100644 --- a/packages/datadog-plugin-grpc/src/client.js +++ b/packages/datadog-plugin-grpc/src/client.js @@ -86,12 +86,11 @@ class GrpcClientPlugin extends ClientPlugin { // The only scheme we want to support here is ipv[46]:port, although // more are supported by the library // https://github.com/grpc/grpc/blob/v1.60.0/doc/naming.md - const parts = peer.split(':') - if (/^\d+/.test(parts.at(-1))) { - const port = parts.at(-1) - const ip = parts.slice(0, -1).join(':') - span.setTag('network.destination.ip', ip) - span.setTag('network.destination.port', port) + const colonIndex = peer.lastIndexOf(':') + const tail = colonIndex === -1 ? '' : peer.slice(colonIndex + 1) + if (tail && /^\d+$/.test(tail)) { + span.setTag('network.destination.ip', peer.slice(0, colonIndex)) + span.setTag('network.destination.port', tail) } else { span.setTag('network.destination.ip', peer) } @@ -121,7 +120,7 @@ function inject (tracer, span, metadata) { tracer.inject(span, TEXT_MAP, carrier) - for (const key in carrier) { + for (const key of Object.keys(carrier)) { metadata.set(key, carrier[key]) } } diff --git a/packages/datadog-plugin-grpc/src/util.js b/packages/datadog-plugin-grpc/src/util.js index 67afa3502de..b083ebfee72 100644 --- a/packages/datadog-plugin-grpc/src/util.js +++ b/packages/datadog-plugin-grpc/src/util.js @@ -3,46 +3,81 @@ const pick = require('../../datadog-core/src/utils/src/pick') const log = require('../../dd-trace/src/log') +/** + * @typedef {object} ParsedMethodPath + * @property {string} name + * @property {string} service + * @property {string} package + */ + +// Sentinel returned by `getFilter` when the user has not configured a metadata +// filter. `addMetadataTags` short-circuits on this identity to skip the +// `metadata.getMap()` clone in the default no-filter case. function getEmptyObject () { return {} } -module.exports = { - getMethodMetadata (path, kind) { - const tags = { - path, - kind, - name: '', - service: '', - package: '', +/** + * gRPC method paths are stable per service definition (e.g. + * `/pkg.Service/Method`); a service typically only has a small finite set. + * Cache the parsed `{name, service, package}` triple by path so we skip the + * `path.split('/')` + `serviceParts.split('.')` + `serviceParts.pop()` work + * on every call. + * + * @type {Map} + */ +const methodPathCache = new Map() + +/** + * @param {string} path + * @returns {ParsedMethodPath} + */ +function parseMethodPath (path) { + const methodParts = path.split('/') + + if (methodParts.length > 2) { + const serviceParts = methodParts[1].split('.') + return { + name: methodParts[2], + service: serviceParts.pop(), + package: serviceParts.join('.'), } + } - if (typeof path !== 'string') return tags + return { name: methodParts.at(-1), service: '', package: '' } +} - const methodParts = path.split('/') +module.exports = { + getEmptyObject, - if (methodParts.length > 2) { - const serviceParts = methodParts[1].split('.') - const name = methodParts[2] - const service = serviceParts.pop() - const pkg = serviceParts.join('.') + getMethodMetadata (path, kind) { + if (typeof path !== 'string') { + return { path, kind, name: '', service: '', package: '' } + } - tags.name = name - tags.service = service - tags.package = pkg - } else { - tags.name = methodParts.at(-1) + let parsed = methodPathCache.get(path) + if (parsed === undefined) { + parsed = parseMethodPath(path) + methodPathCache.set(path, parsed) } - return tags + return { + path, + kind, + name: parsed.name, + service: parsed.service, + package: parsed.package, + } }, addMetadataTags (span, metadata, filter, type) { if (!metadata || typeof metadata.getMap !== 'function') return + // Default no-op filter: skip the full metadata clone via `getMap()`. + if (filter === getEmptyObject) return const values = filter(metadata.getMap()) - for (const key in values) { + for (const key of Object.keys(values)) { span.setTag(`grpc.${type}.metadata.${key}`, values[key]) } }, diff --git a/packages/datadog-plugin-grpc/test/peer-tags.spec.js b/packages/datadog-plugin-grpc/test/peer-tags.spec.js new file mode 100644 index 00000000000..24fc37f4e35 --- /dev/null +++ b/packages/datadog-plugin-grpc/test/peer-tags.spec.js @@ -0,0 +1,86 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it } = require('mocha') + +require('../../dd-trace/test/setup/core') + +const GrpcClientPlugin = require('../src/client') + +// Exercise the peer-string parser inside `GrpcClientPlugin.prototype.finish` +// directly via `.call(fakeThis, ...)`. The tightened parser only emits +// `network.destination.port` when the last segment is strictly numeric +// *and* a colon is present; the previous `/^\d+/` and `split(':')` shape +// let two malformed-peer cases through. +function tagsFor (peer) { + const tags = {} + const fakeSpan = { + setTag (key, value) { tags[key] = value }, + finish () {}, + } + const fakePlugin = { + config: {}, + addCode () {}, + tagPeerService () {}, + } + GrpcClientPlugin.prototype.finish.call(fakePlugin, { span: fakeSpan, result: {}, peer }) + return tags +} + +describe('grpc client finish peer-string tags', () => { + it('splits a standard `ipv4:port` peer', () => { + assert.deepStrictEqual(tagsFor('127.0.0.1:50051'), { + 'network.destination.ip': '127.0.0.1', + 'network.destination.port': '50051', + }) + }) + + it('splits an IPv6-style peer on the last colon only', () => { + assert.deepStrictEqual(tagsFor('[::1]:50051'), { + 'network.destination.ip': '[::1]', + 'network.destination.port': '50051', + }) + assert.deepStrictEqual(tagsFor('::1:50051'), { + 'network.destination.ip': '::1', + 'network.destination.port': '50051', + }) + }) + + it('drops the port tag when the trailing segment is only partially numeric', () => { + // Regression: `/^\d+/` was unanchored, so the previous parser tagged + // `port='80abc'` and `ip='1.2.3.4'`. The anchored `/^\d+$/` rejects + // the entire tail and falls back to tagging the raw peer. + assert.deepStrictEqual(tagsFor('1.2.3.4:80abc'), { + 'network.destination.ip': '1.2.3.4:80abc', + }) + }) + + it('drops the port tag for pure-digit peers without a colon', () => { + // Regression: `'8080'.split(':') === ['8080']`, the unanchored regex + // matched, and `parts.slice(0, -1).join(':')` produced an empty `ip`, + // so a peer with no host info leaked `ip=''` plus a numeric `port`. + assert.deepStrictEqual(tagsFor('8080'), { 'network.destination.ip': '8080' }) + assert.deepStrictEqual(tagsFor('12abc'), { 'network.destination.ip': '12abc' }) + }) + + it('tags the raw peer for unix-socket peers', () => { + assert.deepStrictEqual(tagsFor('unix:'), { 'network.destination.ip': 'unix:' }) + assert.deepStrictEqual(tagsFor('unix:/tmp/socket'), { 'network.destination.ip': 'unix:/tmp/socket' }) + }) + + it('tags the raw peer when there is no colon and no digits', () => { + assert.deepStrictEqual(tagsFor('localhost'), { 'network.destination.ip': 'localhost' }) + }) + + it('still tags an empty ip when the host half is empty (existing behaviour)', () => { + // Boundary: the parser does not require a non-empty *host* — a malformed + // peer with an empty host but a strictly numeric port still yields the + // (empty, numeric) split. This matches both the previous and the new + // parser; pinning it so a future tightening does not drop it silently. + assert.deepStrictEqual(tagsFor(':50051'), { + 'network.destination.ip': '', + 'network.destination.port': '50051', + }) + }) +}) diff --git a/packages/datadog-plugin-grpc/test/util.spec.js b/packages/datadog-plugin-grpc/test/util.spec.js new file mode 100644 index 00000000000..323b59abb7c --- /dev/null +++ b/packages/datadog-plugin-grpc/test/util.spec.js @@ -0,0 +1,130 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { afterEach, describe, it } = require('mocha') + +const { addMetadataTags, getEmptyObject, getFilter, getMethodMetadata } = require('../src/util') + +describe('grpc util', () => { + describe('getMethodMetadata', () => { + it('parses a fully-qualified method path', () => { + const result = getMethodMetadata('/pkg.sub.Service/Method', 'unary') + assert.deepStrictEqual(result, { + path: '/pkg.sub.Service/Method', + kind: 'unary', + name: 'Method', + service: 'Service', + package: 'pkg.sub', + }) + }) + + it('parses a path without a package', () => { + const result = getMethodMetadata('/Service/Method', 'serverStream') + assert.deepStrictEqual(result, { + path: '/Service/Method', + kind: 'serverStream', + name: 'Method', + service: 'Service', + package: '', + }) + }) + + it('falls back when the path is not fully qualified', () => { + const result = getMethodMetadata('LegacyMethod', 'unary') + assert.deepStrictEqual(result, { + path: 'LegacyMethod', + kind: 'unary', + name: 'LegacyMethod', + service: '', + package: '', + }) + }) + + it('returns empty fields when the path is not a string', () => { + const result = getMethodMetadata(undefined, 'unary') + assert.deepStrictEqual(result, { + path: undefined, + kind: 'unary', + name: '', + service: '', + package: '', + }) + }) + + it('threads the kind through on a cache hit', () => { + const path = '/cache.Hit.Service/Method' + const first = getMethodMetadata(path, 'unary') + const second = getMethodMetadata(path, 'serverStream') + assert.strictEqual(first.kind, 'unary') + assert.strictEqual(second.kind, 'serverStream') + assert.strictEqual(first.name, second.name) + assert.strictEqual(first.service, second.service) + assert.strictEqual(first.package, second.package) + }) + }) + + describe('addMetadataTags', () => { + function fakeMetadata (map) { + return { getMap: () => map } + } + + function fakeSpan () { + const tags = {} + return { + tags, + setTag (key, value) { tags[key] = value }, + } + } + + it('skips the metadata clone when the default empty filter is in use', () => { + const span = fakeSpan() + let called = false + const metadata = { + getMap () { + called = true + return { traceparent: 'should-not-leak' } + }, + } + addMetadataTags(span, metadata, getEmptyObject, 'request') + assert.strictEqual(called, false) + assert.deepStrictEqual(span.tags, {}) + }) + + it('forwards filtered values to setTag on the span', () => { + const span = fakeSpan() + const filter = (map) => ({ 'x-trace-id': map['x-trace-id'] }) + addMetadataTags(span, fakeMetadata({ 'x-trace-id': 'abc', secret: 'shh' }), filter, 'request') + assert.deepStrictEqual(span.tags, { 'grpc.request.metadata.x-trace-id': 'abc' }) + }) + + const objectProto = Object.prototype + + afterEach(() => { + // Defence-in-depth: tests shouldn't leak prototype pollution. + delete objectProto.injected + }) + + it('does not pick up inherited keys when iterating filter output', () => { + const span = fakeSpan() + objectProto.injected = 'leak' + const filter = () => ({ direct: 'kept' }) + addMetadataTags(span, fakeMetadata({}), filter, 'response') + assert.deepStrictEqual(span.tags, { 'grpc.response.metadata.direct': 'kept' }) + }) + }) + + describe('getFilter', () => { + it('returns the same empty-object sentinel when no filter is configured', () => { + const filterA = getFilter({}, 'metadata') + const filterB = getFilter({}, 'metadata') + assert.strictEqual(filterA, getEmptyObject) + assert.strictEqual(filterB, getEmptyObject) + }) + + it('returns the user-provided function as-is', () => { + const userFilter = (input) => input + assert.strictEqual(getFilter({ metadata: userFilter }, 'metadata'), userFilter) + }) + }) +}) diff --git a/packages/datadog-plugin-jest/src/index.js b/packages/datadog-plugin-jest/src/index.js index b56046be72a..3d123ac571f 100644 --- a/packages/datadog-plugin-jest/src/index.js +++ b/packages/datadog-plugin-jest/src/index.js @@ -21,6 +21,7 @@ const { TEST_SOURCE_START, TEST_ITR_UNSKIPPABLE, TEST_ITR_FORCED_RUN, + TEST_ITR_SKIPPING_ENABLED, TEST_CODE_OWNERS, ITR_CORRELATION_ID, TEST_SOURCE_FILE, @@ -107,6 +108,7 @@ class JestPlugin extends CiPlugin { process.on('message', handler) } this.testSuiteSpanPerTestSuiteAbsolutePath = new Map() + this.pendingTestSuiteFinishes = new Set() this.addSub('ci:jest:session:finish', ({ status, @@ -123,58 +125,66 @@ class JestPlugin extends CiPlugin { isTestManagementTestsEnabled, onDone, }) => { - this.testSessionSpan.setTag(TEST_STATUS, status) - this.testModuleSpan.setTag(TEST_STATUS, status) + const finishSession = () => { + this.testSessionSpan.setTag(TEST_STATUS, status) + this.testModuleSpan.setTag(TEST_STATUS, status) - if (error) { - this.testSessionSpan.setTag('error', error) - this.testModuleSpan.setTag('error', error) - } - - addIntelligentTestRunnerSpanTags( - this.testSessionSpan, - this.testModuleSpan, - { - isSuitesSkipped, - isSuitesSkippingEnabled, - isCodeCoverageEnabled, - testCodeCoverageLinesTotal, - skippingType: 'suite', - skippingCount: numSkippedSuites, - hasUnskippableSuites, - hasForcedToRunSuites, + if (error) { + this.testSessionSpan.setTag('error', error) + this.testModuleSpan.setTag('error', error) } - ) - if (isEarlyFlakeDetectionEnabled) { - this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ENABLED, 'true') - } - if (isEarlyFlakeDetectionFaulty) { - this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ABORT_REASON, 'faulty') - } - if (isTestManagementTestsEnabled) { - this.testSessionSpan.setTag(TEST_MANAGEMENT_ENABLED, 'true') - } + addIntelligentTestRunnerSpanTags( + this.testSessionSpan, + this.testModuleSpan, + { + isSuitesSkipped, + isSuitesSkippingEnabled, + isCodeCoverageEnabled, + testCodeCoverageLinesTotal, + skippingType: 'suite', + skippingCount: numSkippedSuites, + hasUnskippableSuites, + hasForcedToRunSuites, + } + ) - this.testModuleSpan.finish() - this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'module') - this.testSessionSpan.finish() - this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'session', { - hasFailedTestReplay: this.libraryConfig?.isDiEnabled || undefined, - }) - finishAllTraceSpans(this.testSessionSpan) + if (isEarlyFlakeDetectionEnabled) { + this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ENABLED, 'true') + } + if (isEarlyFlakeDetectionFaulty) { + this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ABORT_REASON, 'faulty') + } + if (isTestManagementTestsEnabled) { + this.testSessionSpan.setTag(TEST_MANAGEMENT_ENABLED, 'true') + } - this.telemetry.count(TELEMETRY_TEST_SESSION, { - provider: this.ciProviderName, - autoInjected: !!this._tracerConfig.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, - }) + this.testModuleSpan.finish() + this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'module') + this.testSessionSpan.finish() + this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'session', { + hasFailedTestReplay: this.libraryConfig?.isDiEnabled || undefined, + }) + finishAllTraceSpans(this.testSessionSpan) + + this.telemetry.count(TELEMETRY_TEST_SESSION, { + provider: this.ciProviderName, + autoInjected: !!this._tracerConfig.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, + }) + + appClosingTelemetry() + this.tracer._exporter.flush(() => { + if (onDone) { + onDone() + } + }) + } - appClosingTelemetry() - this.tracer._exporter.flush(() => { - if (onDone) { - onDone() - } - }) + if (this.pendingTestSuiteFinishes.size > 0) { + Promise.all(this.pendingTestSuiteFinishes).then(finishSession) + } else { + finishSession() + } }) // Test suites can be run in a different process from jest's main one. @@ -197,6 +207,7 @@ class JestPlugin extends CiPlugin { config._ddIsDiEnabled = this.libraryConfig?.isDiEnabled ?? false config._ddIsKnownTestsEnabled = this.libraryConfig?.isKnownTestsEnabled ?? false config._ddIsImpactedTestsEnabled = this.libraryConfig?.isImpactedTestsEnabled ?? false + config._ddItrSkippingEnabledTags = this.getSessionItrSkippingEnabledTags() } }) @@ -217,6 +228,7 @@ class JestPlugin extends CiPlugin { _ddForcedToRun, _ddUnskippable, _ddTestCodeCoverageEnabled, + _ddItrSkippingEnabledTags: itrSkippingEnabledTags, } = testEnvironmentOptions const testSessionSpanContext = this.tracer.extract('text_map', { @@ -228,6 +240,7 @@ class JestPlugin extends CiPlugin { ...getTestSuiteCommonTags(testCommand, frameworkVersion, testSuite, 'jest'), // requestErrorTags from test env options may be undefined ...(requestErrorTags !== undefined && requestErrorTags !== null ? requestErrorTags : {}), + ...(itrSkippingEnabledTags !== undefined && itrSkippingEnabledTags !== null ? itrSkippingEnabledTags : {}), } if (_ddUnskippable) { @@ -305,10 +318,18 @@ class JestPlugin extends CiPlugin { } }) - this.addSub('ci:jest:test-suite:finish', ({ status, errorMessage, error, testSuiteAbsolutePath }) => { + this.addSub('ci:jest:test-suite:finish', ({ + status, + errorMessage, + error, + testSuiteAbsolutePath, + waitForFinish, + onDone, + }) => { const testSuiteSpan = this.testSuiteSpanPerTestSuiteAbsolutePath.get(testSuiteAbsolutePath) if (!testSuiteSpan) { log.warn('"ci:jest:test-suite:finish": no span found for test suite absolute path %s', testSuiteAbsolutePath) + onDone?.() return } testSuiteSpan.setTag(TEST_STATUS, status) @@ -319,8 +340,13 @@ class JestPlugin extends CiPlugin { testSuiteSpan.setTag('error', new Error(errorMessage)) testSuiteSpan.setTag(TEST_STATUS, 'fail') } - // We need to give the potential error in 'ci:jest:test-suite:error' time to be published - process.nextTick(() => { + let resolvePendingFinish + const pendingFinish = new Promise(resolve => { + resolvePendingFinish = resolve + }) + this.pendingTestSuiteFinishes.add(pendingFinish) + + const finish = () => { testSuiteSpan.finish() this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'suite') // Suites potentially run in a different process than the session, @@ -334,7 +360,19 @@ class JestPlugin extends CiPlugin { } this.removeAllDiProbes() this.testSuiteSpanPerTestSuiteAbsolutePath.delete(testSuiteAbsolutePath) - }) + this.pendingTestSuiteFinishes.delete(pendingFinish) + resolvePendingFinish() + if (onDone) { + onDone() + } + } + + if (waitForFinish) { + // Give late async work time to run before Jest restarts a worker because of workerIdleMemoryLimit. + realSetTimeout(finish) + } else { + process.nextTick(finish) + } }) this.addSub('ci:jest:test-suite:error', ({ error, errorMessage, testSuiteAbsolutePath }) => { @@ -558,6 +596,10 @@ class JestPlugin extends CiPlugin { extraTags[TEST_HAS_DYNAMIC_NAME] = 'true' } const testSuiteSpan = this.testSuiteSpanPerTestSuiteAbsolutePath.get(testSuiteAbsolutePath) || this.testSuiteSpan + const skippingEnabled = testSuiteSpan?.context()._tags?.[TEST_ITR_SKIPPING_ENABLED] + if (skippingEnabled !== undefined) { + extraTags[TEST_ITR_SKIPPING_ENABLED] = skippingEnabled + } return super.startTestSpan(name, suite, testSuiteSpan, extraTags) } diff --git a/packages/datadog-plugin-mocha/src/index.js b/packages/datadog-plugin-mocha/src/index.js index c7a78a2ca8b..6c1e5ae9034 100644 --- a/packages/datadog-plugin-mocha/src/index.js +++ b/packages/datadog-plugin-mocha/src/index.js @@ -105,6 +105,7 @@ class MochaPlugin extends CiPlugin { 'mocha' ), ...this.getSessionRequestErrorTags(), + ...this.getSessionItrSkippingEnabledTags(), } if (isUnskippable) { testSuiteMetadata[TEST_ITR_UNSKIPPABLE] = 'true' diff --git a/packages/datadog-plugin-mongodb-core/src/index.js b/packages/datadog-plugin-mongodb-core/src/index.js index 1bcbe0eda7e..a6975a55edd 100644 --- a/packages/datadog-plugin-mongodb-core/src/index.js +++ b/packages/datadog-plugin-mongodb-core/src/index.js @@ -90,9 +90,8 @@ class MongodbCorePlugin extends DatabasePlugin { } } -function sanitizeBigInt (data) { - return JSON.stringify(data, (_key, value) => typeof value === 'bigint' ? value.toString() : value) -} +const MAX_DEPTH = 10 +const MAX_QUERY_LENGTH = 10_000 function extractQuery (statements) { if (statements.length === 1 && statements[0].q) return statements[0].q @@ -100,7 +99,7 @@ function extractQuery (statements) { const extractedQueries = [] for (let i = 0; i < statements.length; i++) { if (statements[i].q) { - extractedQueries.push(limitDepth(statements[i].q)) + extractedQueries.push(statements[i].q) } } @@ -110,12 +109,12 @@ function extractQuery (statements) { function getQuery (cmd) { if (!cmd || (typeof cmd !== 'object' && !Array.isArray(cmd))) return - if (Array.isArray(cmd)) return sanitizeBigInt(extractQuery(cmd)) - if (cmd.query) return sanitizeBigInt(limitDepth(cmd.query)) - if (cmd.filter) return sanitizeBigInt(limitDepth(cmd.filter)) - if (cmd.pipeline) return sanitizeBigInt(limitDepth(cmd.pipeline)) - if (cmd.deletes) return sanitizeBigInt(extractQuery(cmd.deletes)) - if (cmd.updates) return sanitizeBigInt(extractQuery(cmd.updates)) + if (Array.isArray(cmd)) return sanitiseAndStringify(extractQuery(cmd)) + if (cmd.query) return sanitiseAndStringify(cmd.query) + if (cmd.filter) return sanitiseAndStringify(cmd.filter) + if (cmd.pipeline) return sanitiseAndStringify(cmd.pipeline) + if (cmd.deletes) return sanitiseAndStringify(extractQuery(cmd.deletes)) + if (cmd.updates) return sanitiseAndStringify(extractQuery(cmd.updates)) } function getResource (plugin, ns, query, operationName) { @@ -129,73 +128,40 @@ function getResource (plugin, ns, query, operationName) { } function truncate (input) { - return input.slice(0, Math.min(input.length, 10_000)) -} - -function shouldSimplify (input) { - return !isObject(input) || typeof input.toJSON === 'function' + return input.length > MAX_QUERY_LENGTH ? input.slice(0, MAX_QUERY_LENGTH) : input } -function shouldHide (input) { - return Buffer.isBuffer(input) || typeof input === 'function' || isBinary(input) -} - -function limitDepth (input) { - if (isBSON(input)) { - input = input.toJSON() - } - - if (shouldHide(input)) return '?' - if (shouldSimplify(input)) return input - - const output = {} - const queue = [{ - input, - output, - depth: 0, - }] - - while (queue.length) { - const { - input, output, depth, - } = queue.pop() - const nextDepth = depth + 1 - for (const key of Object.keys(input)) { - let child = input[key] - if (typeof child === 'function') continue - - if (isBSON(child)) { - child = typeof child.toJSON === 'function' ? child.toJSON() : '?' - } - - if (depth >= 10 || shouldHide(child)) { - output[key] = '?' - } else if (shouldSimplify(child)) { - output[key] = child - } else { - output[key] = {} - queue.push({ - input: child, - output: output[key], - depth: nextDepth, - }) +// Single-pass sanitisation. The replacer: +// - skips functions and coerces bigint to its decimal string, +// - returns '?' for Buffer / BSON Binary on the *original* value (JSON.stringify already invoked +// toJSON before calling us; Buffer / Binary do have toJSON outputs we want to suppress), +// - lets JSON.stringify call toJSON on other BSON types (ObjectId, Long, Decimal128, Date, Timestamp, ...) +// so the result lands here as a primitive or plain object, +// - returns '?' for BSON types without toJSON (MinKey, MaxKey) where `value === original`, +// - tracks depth via an ancestor stack so cycles and depth >= MAX_DEPTH collapse to '?'. +function sanitiseAndStringify (input) { + const ancestors = [] + return JSON.stringify(input, function (key, value) { + if (typeof value === 'function') return + if (typeof value === 'bigint') return value.toString() + + const original = key === '' ? value : this[key] + if (typeof original === 'object' && original !== null) { + if (Buffer.isBuffer(original)) return '?' + const bsontype = original._bsontype + if (bsontype !== undefined && (bsontype === 'Binary' || value === original)) { + return '?' } } - } - return output -} + if (value === null || typeof value !== 'object') return value -function isObject (val) { - return val !== null && typeof val === 'object' && !Array.isArray(val) -} - -function isBSON (val) { - return val && val._bsontype && !isBinary(val) -} + while (ancestors.length > 0 && ancestors.at(-1) !== this) ancestors.pop() + if (ancestors.length >= MAX_DEPTH || ancestors.includes(value)) return '?' + ancestors.push(value) -function isBinary (val) { - return val && val._bsontype === 'Binary' + return value + }) } function isHeartbeat (ops, config) { diff --git a/packages/datadog-plugin-mongodb-core/test/core.spec.js b/packages/datadog-plugin-mongodb-core/test/core.spec.js index dcfecaaeb9c..5400cae8c52 100644 --- a/packages/datadog-plugin-mongodb-core/test/core.spec.js +++ b/packages/datadog-plugin-mongodb-core/test/core.spec.js @@ -521,6 +521,61 @@ describe('Plugin', () => { }) }) + describe('with dbmPropagationMode full from tracer configuration', () => { + before(() => { + // Tracer-level config (third arg) only takes effect if the global + // tracer is wiped first; tracer.init() short-circuits once the + // process-wide singleton has been initialized by an earlier load. + agent.wipe() + return agent.load('mongodb-core', {}, { + dbmPropagationMode: 'full', + sampler: { sampleRate: 1 }, + }) + }) + + after(() => { + return agent.close({ ritmReset: false, wipe: true }) + }) + + beforeEach(done => { + const Server = getServer() + + server = new Server({ + host: '127.0.0.1', + port: 27017, + reconnect: false, + }) + + server.on('connect', () => done()) + server.on('error', done) + + server.connect() + + startSpy = sinon.spy(MongodbCorePlugin.prototype, 'start') + }) + + afterEach(() => { + startSpy?.restore() + }) + + it('DBM propagation should inject full mode comment with traceparent', done => { + agent + .assertFirstTraceSpan(span => { + const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') + const spanId = span.span_id.toString(16).padStart(16, '0') + + assert.strictEqual(startSpy.called, true) + const { comment } = startSpy.getCall(0).args[0].ops + assert.ok(comment.includes(`traceparent='00-${traceId}-${spanId}-01'`)) + assert.strictEqual(span.meta['_dd.dbm_trace_injected'], 'true') + }) + .then(done) + .catch(done) + + server.insert(`test.${collection}`, [{ a: 1 }], () => {}) + }) + }) + describe('with dbmPropagationMode service', () => { before(() => { return agent.load('mongodb-core', { dbmPropagationMode: 'service' }) diff --git a/packages/datadog-plugin-mongodb-core/test/limit-depth.spec.js b/packages/datadog-plugin-mongodb-core/test/limit-depth.spec.js index 98715509519..f890f776ec1 100644 --- a/packages/datadog-plugin-mongodb-core/test/limit-depth.spec.js +++ b/packages/datadog-plugin-mongodb-core/test/limit-depth.spec.js @@ -48,4 +48,66 @@ describe('mongodb-core query depth limiter', () => { assert.deepStrictEqual(JSON.parse(query), { outer: { ownNested: 'kept' } }) }) + + it('extracts cmd.filter when no .query is present', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { filter: { user: 'alice' } }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(query), { user: 'alice' }) + }) + + it('extracts cmd.pipeline when no .query / .filter is present', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { pipeline: [{ $match: { user: 'alice' } }, { $count: 'total' }] }, + name: 'aggregate', + }) + + assert.deepStrictEqual(JSON.parse(query), [ + { $match: { user: 'alice' } }, + { $count: 'total' }, + ]) + }) + + it('extracts the inner q from a single cmd.deletes statement', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { deletes: [{ q: { user: 'alice' }, limit: 1 }] }, + name: 'delete', + }) + + assert.deepStrictEqual(JSON.parse(query), { user: 'alice' }) + }) + + it('collects every q from multi-statement cmd.updates', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { + updates: [ + { q: { user: 'alice' }, u: { $set: { a: 1 } } }, + { q: { user: 'bob' }, u: { $set: { b: 2 } } }, + ], + }, + name: 'update', + }) + + assert.deepStrictEqual(JSON.parse(query), [ + { user: 'alice' }, + { user: 'bob' }, + ]) + }) + + it('renders Binary BSON values as "?"', () => { + const binary = { _bsontype: 'Binary', buffer: Buffer.from('payload') } + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { blob: binary } }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(query), { blob: '?' }) + }) }) diff --git a/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js b/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js index 5a7adbb2c73..77d1beec628 100644 --- a/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js +++ b/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js @@ -378,6 +378,42 @@ describe('Plugin', () => { }).toArray() }) + it('should collapse beyond max depth', done => { + let nested = { a: 1 } + for (let i = 0; i < 12; i++) { + nested = { a: nested } + } + + agent + .assertSomeTraces(traces => { + const span = traces[0][0] + assert.strictEqual(span.resource, `find test.${collectionName}`) + // 10 levels of `{"a":` then `"?"`, then 10 closing braces. + assert.strictEqual(span.meta['mongodb.query'], `${'{"a":'.repeat(10)}"?"${'}'.repeat(10)}`) + }) + .then(done) + .catch(done) + + collection.find(nested).toArray().catch(() => {}) + }) + + it('should collapse cyclic queries to ?', done => { + const cyclic = { name: 'foo' } + cyclic.self = cyclic + + agent + .assertSomeTraces(traces => { + const span = traces[0][0] + assert.strictEqual(span.resource, `find test.${collectionName}`) + assert.strictEqual(span.meta['mongodb.query'], '{"name":"foo","self":"?"}') + }) + .then(done) + .catch(done) + + // Driver rejects cyclic structures before the wire write; sanitisation runs before that. + collection.find(cyclic).toArray().catch(() => {}) + }) + it('should skip functions when sanitizing', done => { agent .assertSomeTraces(traces => { diff --git a/packages/datadog-plugin-mongodb-core/test/synthesize-topology.spec.js b/packages/datadog-plugin-mongodb-core/test/synthesize-topology.spec.js new file mode 100644 index 00000000000..24b6bc8814e --- /dev/null +++ b/packages/datadog-plugin-mongodb-core/test/synthesize-topology.spec.js @@ -0,0 +1,44 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it } = require('mocha') + +require('../../dd-trace/test/setup/core') + +const { synthesizeTopology } = require('../../datadog-instrumentations/src/mongodb-core') + +const EMPTY_TOPOLOGY = { s: { options: {} } } + +describe('mongodb-core synthesizeTopology', () => { + it('parses a standard `host:port` address', () => { + assert.deepStrictEqual( + synthesizeTopology('127.0.0.1:27017'), + { s: { options: { host: '127.0.0.1', port: '27017' } } } + ) + }) + + it('returns the empty-options envelope for an IPv6 form (multiple colons)', () => { + assert.deepStrictEqual(synthesizeTopology('[::1]:27017'), EMPTY_TOPOLOGY) + assert.deepStrictEqual(synthesizeTopology('::1:27017'), EMPTY_TOPOLOGY) + }) + + it('returns the empty-options envelope for a random-UUID address (no colon)', () => { + assert.deepStrictEqual(synthesizeTopology('e8a93f01-1234-5678-9abc-def012345678'), EMPTY_TOPOLOGY) + }) + + it('returns the empty-options envelope when the port half is empty', () => { + // Boundary: previous `address.split(':').length === 2` accepted these and + // tagged `host=host, port=''` / `host='', port='27017'`. The tightened + // gate rejects either side being empty. + assert.deepStrictEqual(synthesizeTopology('host:'), EMPTY_TOPOLOGY) + assert.deepStrictEqual(synthesizeTopology(':27017'), EMPTY_TOPOLOGY) + assert.deepStrictEqual(synthesizeTopology(':'), EMPTY_TOPOLOGY) + }) + + it('returns the empty-options envelope for non-string addresses', () => { + assert.deepStrictEqual(synthesizeTopology(undefined), EMPTY_TOPOLOGY) + assert.deepStrictEqual(synthesizeTopology(null), EMPTY_TOPOLOGY) + assert.deepStrictEqual(synthesizeTopology(12_345), EMPTY_TOPOLOGY) + }) +}) diff --git a/packages/datadog-plugin-mysql/src/index.js b/packages/datadog-plugin-mysql/src/index.js index 0911863c655..02c88e42214 100644 --- a/packages/datadog-plugin-mysql/src/index.js +++ b/packages/datadog-plugin-mysql/src/index.js @@ -1,7 +1,7 @@ 'use strict' const { storage } = require('../../datadog-core') -const CLIENT_PORT_KEY = require('../../dd-trace/src/constants') +const { CLIENT_PORT_KEY } = require('../../dd-trace/src/constants') const DatabasePlugin = require('../../dd-trace/src/plugins/database') class MySQLPlugin extends DatabasePlugin { diff --git a/packages/datadog-plugin-mysql/test/index.spec.js b/packages/datadog-plugin-mysql/test/index.spec.js index fd35f75d757..903d74956b5 100644 --- a/packages/datadog-plugin-mysql/test/index.spec.js +++ b/packages/datadog-plugin-mysql/test/index.spec.js @@ -93,6 +93,9 @@ describe('Plugin', () => { component: 'mysql', '_dd.integration': 'mysql', }, + metrics: { + 'network.destination.port': 3306, + }, }, { spanResourceMatch: /SELECT 1 \+ 1 AS solution/ }) .then(done) .catch(done) @@ -427,6 +430,46 @@ describe('Plugin', () => { }) }) }) + + describe('with DBM propagation enabled with service using tracer configurations', () => { + let connection + + before(async () => { + // Tracer-level config (third arg) only takes effect if the global + // tracer is wiped first; tracer.init() short-circuits once the + // process-wide singleton has been initialized by an earlier load. + agent.wipe() + await agent.load('mysql', { service: 'serviced' }, { dbmPropagationMode: 'service' }) + mysql = proxyquire(`../../../versions/mysql@${version}`, {}).get() + + connection = mysql.createConnection({ + host: '127.0.0.1', + user: 'root', + database: 'db', + }) + connection.connect() + }) + + after((done) => { + connection.end(() => { + agent.close({ ritmReset: false, wipe: true }).then(done) + }) + }) + + it('should contain service mode comment in query text', done => { + connection.query('SELECT 1 + 1 AS solution', () => { + try { + assert.strictEqual(connection._protocol._queue[0].sql, + '/*dddb=\'db\',dddbs=\'serviced\',dde=\'tester\',ddh=\'127.0.0.1\',ddps=\'test\',' + + `ddpv='${ddpv}'*/ SELECT 1 + 1 AS solution`) + } catch (e) { + done(e) + } + done() + }) + }) + }) + describe('DBM propagation should handle special characters', () => { let connection diff --git a/packages/datadog-plugin-pg/test/index.spec.js b/packages/datadog-plugin-pg/test/index.spec.js index 948bb93d663..9b924541b18 100644 --- a/packages/datadog-plugin-pg/test/index.spec.js +++ b/packages/datadog-plugin-pg/test/index.spec.js @@ -865,5 +865,55 @@ describe('Plugin', () => { }) }) }) + + // Lives outside `withVersions` so the global-tracer wipe needed to test + // tracer-level config (third `agent.load` arg) does not strand sibling + // describe blocks in the next pg-version iteration. + describe('with DBM propagation enabled with append comment using tracer configuration', () => { + before(async () => { + // Tracer-level config (third arg) only takes effect if the global + // tracer is wiped first; tracer.init() short-circuits once the + // process-wide singleton has been initialized by an earlier load. + agent.wipe() + await agent.load('pg', { + appendComment: true, + service: () => 'serviced', + }, { + dbmPropagationMode: 'service', + }) + pg = require('../../../versions/pg').get() + }) + + after(() => { + return agent.close({ ritmReset: false, wipe: true }) + }) + + beforeEach((done) => { + client = new pg.Client({ + host: '127.0.0.1', + user: 'postgres', + password: 'postgres', + database: 'postgres', + }) + client.connect(err => done(err)) + }) + + afterEach((done) => { + client.end(done) + }) + + it('should append service mode comment in query text', async () => { + const queryQueueName = Object.hasOwn(client, '_queryQueue') ? '_queryQueue' : 'queryQueue' + + const queryPromise = client.query('SELECT $1::text as message', ['Hello world!']) + + assert.strictEqual(client[queryQueueName][0].text, + 'SELECT $1::text as message /*dddb=\'postgres\',dddbs=\'serviced\',dde=\'tester\',' + + `ddh='127.0.0.1',ddps='test',ddpv='${ddpv}'*/` + ) + + await queryPromise + }) + }) }) }) diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index b684fd0070b..281e1b8283a 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -321,6 +321,7 @@ class PlaywrightPlugin extends CiPlugin { isAtrRetry, isModified, finalStatus, + earlyFlakeAbortReason, onDone, }) => { if (!span) return @@ -384,6 +385,9 @@ class PlaywrightPlugin extends CiPlugin { if (finalStatus) { span.setTag(TEST_FINAL_STATUS, finalStatus) } + if (earlyFlakeAbortReason) { + span.setTag(TEST_EARLY_FLAKE_ABORT_REASON, earlyFlakeAbortReason) + } for (const step of steps) { const stepStartTime = step.startTime.getTime() const stepSpan = this.tracer.startSpan('playwright.step', { diff --git a/packages/datadog-plugin-prisma/test/index.spec.js b/packages/datadog-plugin-prisma/test/index.spec.js index 1f3bd2d7c93..92121b20533 100644 --- a/packages/datadog-plugin-prisma/test/index.spec.js +++ b/packages/datadog-plugin-prisma/test/index.spec.js @@ -4,9 +4,11 @@ const assert = require('node:assert/strict') const { execSync } = require('node:child_process') const fs = require('node:fs/promises') const path = require('node:path') + const { after, before, beforeEach, describe, it } = require('mocha') const proxyquire = require('proxyquire') const semifies = require('semifies') + const { assertObjectContains } = require('../../../integration-tests/helpers') const { storage } = require('../../datadog-core') const { ERROR_MESSAGE, ERROR_TYPE, ERROR_STACK } = require('../../dd-trace/src/constants') @@ -82,7 +84,7 @@ async function copySchemaToVersionDir (schemaPath, range) { } function createPrismaClient (prisma, config) { - // With the introduction of v7 prisma now enforces the use of adpaters + // With the introduction of v7 prisma now enforces the use of adapters if (config.v7) { const { PrismaPg } = require('@prisma/adapter-pg') const adapter = new PrismaPg({ connectionString: process.env[TEST_DATABASE_ENV_NAME] }) @@ -94,6 +96,21 @@ function createPrismaClient (prisma, config) { return new prisma.PrismaClient() } +function createEngineDbQuerySpan (queryText) { + return [{ + id: '1', + parentId: null, + name: 'prisma:engine:db_query', + startTime: [1745340876, 436861000], + endTime: [1745340876, 438601541], + kind: 'client', + attributes: { + 'db.system': 'postgresql', + 'db.query.text': queryText, + }, + }] +} + describe('Plugin', () => { let prisma let prismaClient @@ -495,24 +512,63 @@ describe('Plugin', () => { }) const engineSpans = [ - { - id: '1', - parentId: null, - name: 'prisma:engine:db_query', - startTime: [1745340876, 436861000], - endTime: [1745340876, 438601541], - kind: 'client', - attributes: { - 'db.system': 'postgresql', - 'db.query.text': 'SELECT 1', - }, - }, + ...createEngineDbQuerySpan('SELECT 1'), ] tracingHelper.dispatchEngineSpans(engineSpans) await Promise.all([ tracingPromise, ]) }) + + if (config.v7) { + it('should tag db_query spans with the active client adapter metadata in read-replica setups', async () => { + const initialDbUrl = process.env[TEST_DATABASE_ENV_NAME] + process.env[TEST_DATABASE_ENV_NAME] = + 'postgres://postgres:postgres@primary.db.internal:5432/postgres' + const primaryClient = createPrismaClient(prisma, config) + + process.env[TEST_DATABASE_ENV_NAME] = + 'postgres://postgres:postgres@replica.db.internal:5433/postgres' + const replicaClient = createPrismaClient(prisma, config) + + if (initialDbUrl === undefined) { + delete process.env[TEST_DATABASE_ENV_NAME] + } else { + process.env[TEST_DATABASE_ENV_NAME] = initialDbUrl + } + + assert.ok(primaryClient._tracingHelper) + assert.ok(replicaClient._tracingHelper) + + const replicaReadTrace = agent.assertSomeTraces(traces => { + const dbQuerySpan = traces[0].find(span => span.meta['prisma.name'] === 'db_query') + assertObjectContains(dbQuerySpan, { + resource: 'SELECT 1', + meta: { + 'out.host': 'replica.db.internal', + 'network.destination.port': '5433', + }, + }) + }) + replicaClient._tracingHelper.dispatchEngineSpans(createEngineDbQuerySpan('SELECT 1')) + await replicaReadTrace + + const primaryWriteTrace = agent.assertSomeTraces(traces => { + const dbQuerySpan = traces[0].find(span => span.meta['prisma.name'] === 'db_query') + assertObjectContains(dbQuerySpan, { + resource: 'INSERT INTO "User" ("name") VALUES ($1)', + meta: { + 'out.host': 'primary.db.internal', + 'network.destination.port': '5432', + }, + }) + }) + primaryClient._tracingHelper.dispatchEngineSpans(createEngineDbQuerySpan( + 'INSERT INTO "User" ("name") VALUES ($1)' + )) + await primaryWriteTrace + }) + } }) describe('without tracer initialization', () => { diff --git a/packages/datadog-plugin-prisma/test/integration-test/client.spec.js b/packages/datadog-plugin-prisma/test/integration-test/client.spec.js index 0ba0625656c..2e15d955f86 100644 --- a/packages/datadog-plugin-prisma/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-prisma/test/integration-test/client.spec.js @@ -201,6 +201,41 @@ const prismaClientConfigs = [{ 'db.type': 'mssql', }, }, +}, +{ + name: 'prisma-generator v7 pg adapter with OTel TracerProvider registration', + serverFile: 'server-ts-v7-otel.mjs', + schema: `./packages/datadog-plugin-prisma/test/${SCHEMA_FIXTURES.tsEsmV7}`, + configFile: `./packages/datadog-plugin-prisma/test/${SCHEMA_FIXTURES.tsEsmV7Config}`, + env: { + PRISMA_CLIENT_OUTPUT: './generated/prisma', + DATABASE_URL: TEST_DATABASE_URL, + }, + ts: true, + skipMigrateReset: true, + customTest: { + title: 'uses active OTel span IDs in Prisma DBM traceparent comments', + async run ({ agent, config }) { + let stdout = '' + const proc = await spawnPluginIntegrationTestProcAndExpectExit( + sandboxCwd(), + config.serverFile, + agent.port, + { DD_TRACE_FLUSH_INTERVAL: '2000', ...config.env }, + undefined, + data => { + stdout += data.toString() + } + ) + + const marker = stdout.match(/TRACEPARENT_OK:(00-[a-f0-9]{32}-[a-f0-9]{16}-01)/) + assert.ok(marker, `Expected TRACEPARENT_OK output marker, got: ${stdout}`) + assert.notStrictEqual(marker[1], '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01') + assert.notStrictEqual(marker[1], '00-00000000000000000000000000000000-0000000000000000-01') + + return proc + }, + }, }] describe('esm', () => { @@ -238,13 +273,15 @@ describe('esm', () => { useSandbox(deps, false, paths) - before(function () { - variants = varySandbox(config.serverFile, config.ts ? 'PrismaClient' : 'prismaLib', - config.ts ? 'PrismaClient' : undefined, config.importPath, config.ts) - if (!variants[config.variant]) { - throw new Error(`Unknown variant ${config.variant} for ${config.name}`) - } - }) + if (!config.customTest) { + before(function () { + variants = varySandbox(config.serverFile, config.ts ? 'PrismaClient' : 'prismaLib', + config.ts ? 'PrismaClient' : undefined, config.importPath, config.ts) + if (!variants[config.variant]) { + throw new Error(`Unknown variant ${config.variant} for ${config.name}`) + } + }) + } beforeEach(async function () { this.timeout(60000) @@ -303,41 +340,48 @@ describe('esm', () => { await agent?.stop() }) - const variant = config.variant - it(`is instrumented with ${variant} import`, async function () { - this.timeout(60000) - const dbSpanExpectation = config.dbSpan || { - name: config.configFile ? 'pg.query' : 'prisma.engine', - service: config.configFile ? 'node-postgres' : 'node-prisma', - meta: { - 'db.user': 'postgres', - 'db.name': 'postgres', - 'db.type': 'postgres', - }, - } - const res = agent.assertMessageReceived(({ headers, payload }) => { - assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assertObjectContains(payload, [[{ - name: 'prisma.client', - resource: 'User.create', - service: 'node-prisma', - }], [dbSpanExpectation]]) + if (config.customTest) { + it(config.customTest.title, async function () { + this.timeout(60000) + proc = await config.customTest.run({ agent, config }) }) + } else { + const variant = config.variant + it(`is instrumented with ${variant} import`, async function () { + this.timeout(60000) + const dbSpanExpectation = config.dbSpan || { + name: config.configFile ? 'pg.query' : 'prisma.engine', + service: config.configFile ? 'node-postgres' : 'node-prisma', + meta: { + 'db.user': 'postgres', + 'db.name': 'postgres', + 'db.type': 'postgres', + }, + } + const res = agent.assertMessageReceived(({ headers, payload }) => { + assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) + assertObjectContains(payload, [[{ + name: 'prisma.client', + resource: 'User.create', + service: 'node-prisma', + }], [dbSpanExpectation]]) + }) - const procPromise = spawnPluginIntegrationTestProcAndExpectExit( - sandboxCwd(), - variants[variant], - agent.port, - { DD_TRACE_FLUSH_INTERVAL: '2000', ...config.env } - ) + const procPromise = spawnPluginIntegrationTestProcAndExpectExit( + sandboxCwd(), + variants[variant], + agent.port, + { DD_TRACE_FLUSH_INTERVAL: '2000', ...config.env } + ) - await Promise.all([ - procPromise.then((res) => { - proc = res - }), - res, - ]) - }) + await Promise.all([ + procPromise.then((res) => { + proc = res + }), + res, + ]) + }) + } }) }) }) diff --git a/packages/datadog-plugin-prisma/test/integration-test/server-ts-v7-otel.mjs b/packages/datadog-plugin-prisma/test/integration-test/server-ts-v7-otel.mjs new file mode 100644 index 00000000000..138697741a2 --- /dev/null +++ b/packages/datadog-plugin-prisma/test/integration-test/server-ts-v7-otel.mjs @@ -0,0 +1,76 @@ +import ddTrace from 'dd-trace' +import assert from 'node:assert/strict' + +import { trace } from '@opentelemetry/api' +// @ts-expect-error +import { PrismaPg } from '@prisma/adapter-pg' +// @ts-expect-error +import { PrismaClient } from './dist/client.js' + +const placeholderTraceparent = '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01' +const zeroTraceparent = '00-00000000000000000000000000000000-0000000000000000-01' + +ddTrace.init({ + dbmPropagationMode: 'full', + plugins: false, +}) +ddTrace.use('prisma', true) + +const provider = new ddTrace.TracerProvider() +provider.register() + +let observedTraceparent + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }) +const prismaClient = new PrismaClient({ adapter }) +const otelTracer = trace.getTracer('prisma-otel-dbm-integration') +const tracingHelper = prismaClient._tracingHelper || prismaClient._engine?.tracingHelper + +assert.ok(tracingHelper && typeof tracingHelper.getTraceParent === 'function', 'Expected Prisma tracing helper') + +const originalGetTraceParent = tracingHelper.getTraceParent.bind(tracingHelper) +tracingHelper.getTraceParent = function wrappedGetTraceParent (...args) { + observedTraceparent = originalGetTraceParent(...args) + const activeSpan = ddTrace.scope().active() + if (activeSpan) { + const activeContext = activeSpan.context() + const expectedFromActiveSpan = `00-${activeContext.toTraceId(true)}-${activeContext.toSpanId(true)}-01` + assert.strictEqual(observedTraceparent, expectedFromActiveSpan) + } + return observedTraceparent +} + +await new Promise((resolve, reject) => { + otelTracer.startActiveSpan('otel-parent', async (span) => { + try { + const spanContext = span.spanContext() + const unique = `${Date.now()}-${process.pid}` + + await prismaClient.user.create({ + data: { + name: 'John Doe', + email: `john.doe+${unique}@datadoghq.com`, + }, + }) + + await prismaClient.user.findUnique({ + where: { + email: `john.doe+${unique}@datadoghq.com`, + }, + }) + + assert.ok(observedTraceparent, 'Expected Prisma query to include traceparent comment') + assert.ok(observedTraceparent.startsWith(`00-${spanContext.traceId}-`)) + assert.notStrictEqual(observedTraceparent, placeholderTraceparent) + assert.notStrictEqual(observedTraceparent, zeroTraceparent) + process.stdout.write(`TRACEPARENT_OK:${observedTraceparent}\n`) + resolve(undefined) + } catch (error) { + reject(error) + } finally { + span.end() + } + }) +}) + +await prismaClient.$disconnect() diff --git a/packages/datadog-plugin-ws/test/index.spec.js b/packages/datadog-plugin-ws/test/index.spec.js index 5612c69005b..34b0f745f66 100644 --- a/packages/datadog-plugin-ws/test/index.spec.js +++ b/packages/datadog-plugin-ws/test/index.spec.js @@ -4,7 +4,8 @@ const assert = require('node:assert') const { once } = require('node:events') const dc = require('dc-polyfill') -const { after, afterEach, before, beforeEach, describe, it } = require('mocha') +const setSocketCh = dc.channel('tracing:ws:server:connect:setSocket') +const { afterEach, beforeEach, describe, it } = require('mocha') const agent = require('../../dd-trace/test/plugins/agent') const { storage } = require('../../datadog-core') @@ -26,6 +27,7 @@ function findSpan (traces, predicate) { } function closeWsServer (server) { + if (!server) return for (const ws of server.clients) { ws.terminate() } @@ -36,7 +38,7 @@ describe('Plugin', () => { let WebSocket let wsServer let connectionReceived - let clientPort = 6015 + let clientPort let client let messageReceived let route @@ -44,7 +46,10 @@ describe('Plugin', () => { describe('ws', () => { withVersions('ws', 'ws', '>=8.0.0', version => { describe('regression tests', () => { - before(async () => { + let regressionServer + let regressionSocket + + beforeEach(async () => { await agent.load(['ws'], [{ service: 'some', traceWebsocketMessagesEnabled: true, @@ -52,42 +57,53 @@ describe('Plugin', () => { WebSocket = require(`../../../versions/ws@${version}`).get() }) + afterEach(() => { + regressionSocket?.terminate() + regressionSocket = undefined + }) + + afterEach(async () => { + await closeWsServer(regressionServer) + }) + + afterEach(() => { + regressionServer = undefined + }) + + afterEach(async () => { + await agent.close({ ritmReset: false, wipe: true }) + }) + it('should not crash when sending on a socket without spanContext', async () => { - const server = new WebSocket.Server({ port: 16015 }) - const connectionPromise = once(server, 'connection') + regressionServer = new WebSocket.Server({ port: 0 }) + await once(regressionServer, 'listening') + const { port } = regressionServer.address() + const connectionPromise = once(regressionServer, 'connection') - const socket = new WebSocket('ws://localhost:16015') + regressionSocket = new WebSocket(`ws://localhost:${port}`) const [serverSocket] = await connectionPromise - await once(socket, 'open') + await once(regressionSocket, 'open') - assert.strictEqual(socket.spanContext, undefined) + assert.strictEqual(regressionSocket.spanContext, undefined) const messagePromise = once(serverSocket, 'message') await new Promise((resolve, reject) => { - socket.send('test message', {}, (err) => err ? reject(err) : resolve()) + regressionSocket.send('test message', {}, (err) => err ? reject(err) : resolve()) }) await messagePromise - - socket.close() - await once(socket, 'close') - server.close() }) it('should emit original error in case close is called before connection is established', async () => { - const socket = new WebSocket('wss://localhost:12345') + regressionSocket = new WebSocket('wss://localhost:12345') - const errorPromise = once(socket, 'error') - socket.close() + const errorPromise = once(regressionSocket, 'error') + regressionSocket.close() const error = await errorPromise // Some versions emit an array with an error, some directly emit the error assert.strictEqual(error?.[0]?.message, 'WebSocket was closed before the connection was established') }) - - after(async () => { - await agent.close({ ritmReset: false, wipe: true }) - }) }) describe('when using WebSocket', () => { @@ -106,22 +122,27 @@ describe('Plugin', () => { }]) WebSocket = require(`../../../versions/ws@${version}`).get() - wsServer = new WebSocket.Server({ port: clientPort }) + wsServer = new WebSocket.Server({ port: 0 }) await once(wsServer, 'listening') + clientPort = wsServer.address().port }) - afterEach(async () => { - clientPort++ + afterEach(() => { if (client) { client.removeAllListeners('error') client.on('error', () => {}) } + }) + + afterEach(async () => { await closeWsServer(wsServer) + }) + + afterEach(async () => { await agent.close({ ritmReset: false, wipe: true }) }) it('should not retain the connection span during socket setup', async () => { - const setSocketCh = dc.channel('tracing:ws:server:connect:setSocket') let resolve const promise = new Promise((_resolve) => { resolve = _resolve @@ -464,17 +485,23 @@ describe('Plugin', () => { }]) WebSocket = require(`../../../versions/ws@${version}`).get() - wsServer = new WebSocket.Server({ port: clientPort }) + wsServer = new WebSocket.Server({ port: 0 }) await once(wsServer, 'listening') + clientPort = wsServer.address().port }) - afterEach(async () => { - clientPort++ + afterEach(() => { if (client) { client.removeAllListeners('error') client.on('error', () => {}) } + }) + + afterEach(async () => { await closeWsServer(wsServer) + }) + + afterEach(async () => { await agent.close({ ritmReset: false, wipe: true }) }) @@ -576,17 +603,22 @@ describe('Plugin', () => { }]) WebSocket = require(`../../../versions/ws@${version}`).get() - wsServer = new WebSocket.Server({ port: clientPort }) + wsServer = new WebSocket.Server({ port: 0 }) await once(wsServer, 'listening') }) - afterEach(async () => { - clientPort++ + afterEach(() => { if (client) { client.removeAllListeners('error') client.on('error', () => {}) } + }) + + afterEach(async () => { await closeWsServer(wsServer) + }) + + afterEach(async () => { await agent.close({ ritmReset: false, wipe: true }) }) @@ -625,17 +657,23 @@ describe('Plugin', () => { }]) WebSocket = require(`../../../versions/ws@${version}`).get() - wsServer = new WebSocket.Server({ port: clientPort }) + wsServer = new WebSocket.Server({ port: 0 }) await once(wsServer, 'listening') + clientPort = wsServer.address().port }) - afterEach(async () => { - clientPort++ + afterEach(() => { if (client) { client.removeAllListeners('error') client.on('error', () => {}) } + }) + + afterEach(async () => { await closeWsServer(wsServer) + }) + + afterEach(async () => { await agent.close({ ritmReset: false, wipe: true }) }) @@ -721,8 +759,9 @@ describe('Plugin', () => { }]) WebSocket = require(`../../../versions/ws@${version}`).get() - wsServer = new WebSocket.Server({ port: clientPort }) + wsServer = new WebSocket.Server({ port: 0 }) await once(wsServer, 'listening') + clientPort = wsServer.address().port parentHeaders = {} tracer.trace('test.parent', parentSpan => { @@ -730,13 +769,18 @@ describe('Plugin', () => { }) }) - afterEach(async () => { - clientPort++ + afterEach(() => { if (client) { client.removeAllListeners('error') client.on('error', () => {}) } + }) + + afterEach(async () => { await closeWsServer(wsServer) + }) + + afterEach(async () => { await agent.close({ ritmReset: false, wipe: true }) }) diff --git a/packages/dd-trace/src/azure_metadata.js b/packages/dd-trace/src/azure_metadata.js index 1bf59eb03cd..e41f0f638dd 100644 --- a/packages/dd-trace/src/azure_metadata.js +++ b/packages/dd-trace/src/azure_metadata.js @@ -19,7 +19,20 @@ function extractSubscriptionID (ownerName) { } function extractResourceGroup (ownerName) { - return /.+\+(.+)-.+webspace(-Linux)?/.exec(ownerName)?.[1] + // WEBSITE_OWNER_NAME format: `+-webspace[-Linux]`. Region + // names have no `-`; resource groups can. Plain string ops read more directly + // than `/.+\+(.+)-.+webspace(-Linux)?/` and avoid the engine backtracking + // through three `.+` quantifiers to land on the right `-`. + if (typeof ownerName !== 'string') return + const plusIdx = ownerName.indexOf('+') + if (plusIdx === -1) return + let rest = ownerName.slice(plusIdx + 1) + if (rest.endsWith('-Linux')) rest = rest.slice(0, -'-Linux'.length) + if (!rest.endsWith('webspace')) return + rest = rest.slice(0, -'webspace'.length) + const lastDash = rest.lastIndexOf('-') + if (lastDash === -1) return + return rest.slice(0, lastDash) } function buildResourceID (subscriptionID, siteName, resourceGroup) { diff --git a/packages/dd-trace/src/config/index.js b/packages/dd-trace/src/config/index.js index 4c7f521145b..449330db8a7 100644 --- a/packages/dd-trace/src/config/index.js +++ b/packages/dd-trace/src/config/index.js @@ -357,7 +357,7 @@ class Config extends ConfigBase { setAndTrack(this, 'DD_METRICS_OTEL_ENABLED', false) } - if (this.OTEL_TRACES_EXPORTER === 'otlp' && this.protocolVersion && this.protocolVersion !== '0.4') { + if (this.OTEL_TRACES_EXPORTER === 'otlp' && trackedConfigOrigins.has('protocolVersion')) { log.warn('DD_TRACE_AGENT_PROTOCOL_VERSION is set, disabling OTLP traces export') setAndTrack(this, 'OTEL_TRACES_EXPORTER', 'none') } diff --git a/packages/dd-trace/src/plugins/ci_plugin.js b/packages/dd-trace/src/plugins/ci_plugin.js index d318174bf61..6dabe49be94 100644 --- a/packages/dd-trace/src/plugins/ci_plugin.js +++ b/packages/dd-trace/src/plugins/ci_plugin.js @@ -49,6 +49,7 @@ const { getTestSuiteCommonTags, TEST_STATUS, TEST_SKIPPED_BY_ITR, + TEST_ITR_SKIPPING_ENABLED, ITR_CORRELATION_ID, TEST_SOURCE_FILE, TEST_LEVEL_EVENT_TYPES, @@ -64,6 +65,7 @@ const { getModifiedFilesFromDiff, getPullRequestBaseBranch, getSessionRequestErrorTags, + getSessionItrSkippingEnabledTags, DD_CI_LIBRARY_CONFIGURATION_ERROR, TEST_IS_TEST_FRAMEWORK_WORKER, TEST_IS_NEW, @@ -74,6 +76,7 @@ const { TEST_IS_MODIFIED, TEST_IS_RETRY, TEST_RETRY_REASON, + DD_CAPABILITIES_TEST_IMPACT_ANALYSIS, } = require('./util/test') const FRAMEWORK_TO_TRIMMED_COMMAND = { @@ -99,6 +102,21 @@ const TEST_FRAMEWORKS_TO_SKIP_GIT_METADATA_EXTRACTION = new Set([ 'cucumber', ]) +function setItrSkippingEnabledTagFromLibraryConfig (plugin, frameworkVersion) { + const libraryCapabilitiesTags = getLibraryCapabilitiesTags(plugin.constructor.id, frameworkVersion) + + if (!libraryCapabilitiesTags[DD_CAPABILITIES_TEST_IMPACT_ANALYSIS] || + !plugin.libraryConfig || + !plugin.testSessionSpan || + !plugin.testModuleSpan) { + return + } + + const skippingEnabled = plugin.libraryConfig.isSuitesSkippingEnabled ? 'true' : 'false' + plugin.testSessionSpan.setTag(TEST_ITR_SKIPPING_ENABLED, skippingEnabled) + plugin.testModuleSpan.setTag(TEST_ITR_SKIPPING_ENABLED, skippingEnabled) +} + function getTestSuiteLevelVisibilityTags (testSuiteSpan, testFramework) { const testSuiteSpanContext = testSuiteSpan.context() @@ -137,6 +155,7 @@ module.exports = class CiPlugin extends Plugin { this._addRequestErrorTag(DD_CI_LIBRARY_CONFIGURATION_ERROR, err) } else { this.libraryConfig = libraryConfig + setItrSkippingEnabledTagFromLibraryConfig(this, frameworkVersion) } const requestErrorTags = this.testSessionSpan @@ -225,6 +244,7 @@ module.exports = class CiPlugin extends Plugin { }, integrationName: this.constructor.id, }) + setItrSkippingEnabledTagFromLibraryConfig(this, frameworkVersion) // only for vitest // These are added for the worker threads to use if (this.constructor.id === 'vitest') { @@ -246,6 +266,7 @@ module.exports = class CiPlugin extends Plugin { const testSuiteMetadata = { ...getTestSuiteCommonTags(testCommand, frameworkVersion, testSuite, this.constructor.id), ...getSessionRequestErrorTags(this.testSessionSpan), + ...getSessionItrSkippingEnabledTags(this.testSessionSpan), } if (this.itrCorrelationId) { testSuiteMetadata[ITR_CORRELATION_ID] = this.itrCorrelationId @@ -342,6 +363,9 @@ module.exports = class CiPlugin extends Plugin { if (span.name?.startsWith(`${this.constructor.id}.`)) { span.meta[TEST_IS_TEST_FRAMEWORK_WORKER] = 'true' + if (span.name === `${this.constructor.id}.test` || span.name === `${this.constructor.id}.test_suite`) { + Object.assign(span.meta, getSessionItrSkippingEnabledTags(this.testSessionSpan)) + } // augment with git information (since it will not be available in the worker) for (const key in this.testEnvironmentMetadata) { // CAREFUL: this bypasses the metadata/metrics distinction @@ -469,6 +493,15 @@ module.exports = class CiPlugin extends Plugin { return getSessionRequestErrorTags(this.testSessionSpan) } + /** + * Returns ITR skipping-enabled tags from the test session span for propagation to child events. + * + * @returns {Record} + */ + getSessionItrSkippingEnabledTags () { + return getSessionItrSkippingEnabledTags(this.testSessionSpan) + } + /** * @param {import('../config/config-base')} config - Tracer configuration * @param {boolean} shouldGetEnvironmentData - Whether to get environment data @@ -596,6 +629,8 @@ module.exports = class CiPlugin extends Plugin { } } + Object.assign(testTags, getSessionItrSkippingEnabledTags(this.testSessionSpan)) + this.telemetry.ciVisEvent(TELEMETRY_EVENT_CREATED, 'test', { hasCodeOwners: !!codeOwners }) const testSpan = this.tracer diff --git a/packages/dd-trace/src/plugins/database.js b/packages/dd-trace/src/plugins/database.js index 9c38ea0306a..3ee4555d105 100644 --- a/packages/dd-trace/src/plugins/database.js +++ b/packages/dd-trace/src/plugins/database.js @@ -1,13 +1,47 @@ 'use strict' +const { LRUCache } = require('../../../../vendor/dist/lru-cache') const { PEER_SERVICE_KEY, PEER_SERVICE_SOURCE_KEY } = require('../constants') const propagationHash = require('../propagation-hash') const StoragePlugin = require('./storage') +// Unreserved RFC 3986 set that `encodeURIComponent` leaves untouched (a conservative subset: +// `! * ' ( )` are also untouched but rarely appear in db / host / service names so we skip them). +const SAFE_ENCODE_RE = /^[\w\-.~]*$/ + +// Cap `#dbmPrefixCache` so a high-cardinality `db.name` (MongoDB sets it to +// `${database}.${collection}`) cannot grow the map without bound. Steady-state working sets +// fit well below the cap; cache misses cost a few hundred nanoseconds, so an evicted entry +// rebuilds cheaply on the next query. +const DBM_PREFIX_CACHE_MAX = 256 + class DatabasePlugin extends StoragePlugin { static operation = 'query' static peerServicePrecursors = ['db.name'] + // dde / ddps / ddpv are tracer-process constants. They are baked in at `configure()` time as + // two pre-templated fragments — `dbmEnvFragment` splices between dddbs and ddh; `dbmEndFragment` + // trails ddh — so per-query work shrinks to encoding dddb / dddbs / ddh and concatenating. + #dbmEnvFragment + #dbmEndFragment + // Cache the rendered prefix per `${db.name}\0${out.host}\0${dbmService}`. The triple is + // connection-stable for a real workload, so the steady state is one `LRUCache.get` plus the + // optional per-call `,ddprs='...'` suffix. + #dbmPrefixCache = new LRUCache({ max: DBM_PREFIX_CACHE_MAX }) + + /** + * @override + * @param {boolean | import('../config/config-base') & {enabled: boolean}} config + */ + configure (config) { + super.configure(config) + // Match the previous shape exactly: `dde` is `encode`d; `ddps` / `ddpv` are template-literal + // coerced so `undefined` renders as the literal `'undefined'` the way the original did. + this.#dbmEnvFragment = `,dde='${encode(this.tracer._env)}',` + this.#dbmEndFragment = `,ddps='${this.tracer._service ?? ''}',ddpv='${this.tracer._version}'` + this.#dbmPrefixCache.clear() + } + /** * @param {string} serviceName * @param {import('../../../..').Span} span @@ -16,20 +50,21 @@ class DatabasePlugin extends StoragePlugin { */ #createDBMPropagationCommentService (serviceName, span, peerData) { const spanTags = span.context()._tags - const encodedDddb = encode(spanTags['db.name']) - const encodedDddbs = encode(serviceName) - const encodedDde = encode(this.tracer._env) - const encodedDdh = encode(spanTags['out.host']) - const encodedDdps = this.tracer._service ?? '' - const encodedDdpv = this.tracer._version - - let dbmComment = `dddb='${encodedDddb}',dddbs='${encodedDddbs}',dde='${encodedDde}',ddh='${encodedDdh}',` + - `ddps='${encodedDdps}',ddpv='${encodedDdpv}'` + const dddb = spanTags['db.name'] + const ddh = spanTags['out.host'] + const cacheKey = `${dddb ?? ''}\0${ddh ?? ''}\0${serviceName ?? ''}` + + let prefix = this.#dbmPrefixCache.get(cacheKey) + if (prefix === undefined) { + prefix = `dddb='${encode(dddb)}',dddbs='${encode(serviceName)}'${this.#dbmEnvFragment}` + + `ddh='${encode(ddh)}'${this.#dbmEndFragment}` + this.#dbmPrefixCache.set(cacheKey, prefix) + } if (peerData !== undefined && peerData[PEER_SERVICE_SOURCE_KEY] === PEER_SERVICE_KEY) { - dbmComment += `,ddprs='${encode(peerData[PEER_SERVICE_KEY])}'` + return `${prefix},ddprs='${encode(peerData[PEER_SERVICE_KEY])}'` } - return dbmComment + return prefix } /** @@ -118,6 +153,13 @@ class DatabasePlugin extends StoragePlugin { } } -const encode = value => value ? encodeURIComponent(value) : '' +/** + * @param {string | number | undefined | null} value + * @returns {string} + */ +function encode (value) { + if (!value) return '' + return SAFE_ENCODE_RE.test(value) ? value : encodeURIComponent(value) +} module.exports = DatabasePlugin diff --git a/packages/dd-trace/src/plugins/util/stacktrace.js b/packages/dd-trace/src/plugins/util/stacktrace.js index 20303bf1915..d9bf85ed2d9 100644 --- a/packages/dd-trace/src/plugins/util/stacktrace.js +++ b/packages/dd-trace/src/plugins/util/stacktrace.js @@ -14,7 +14,7 @@ const NODE_MODULES_PATTERN_START = `node_modules${sep}` * In production, these frames are already filtered by isNodeModulesFrame. */ const SHOULD_FILTER_DD_TRACE_INSTRUMENTAION = __filename.endsWith( - join(sep, 'dd-trace-js', 'packages', 'dd-trace', 'src', 'plugins', 'util', 'stacktrace.js') + join('packages', 'dd-trace', 'src', 'plugins', 'util', 'stacktrace.js') ) module.exports = { diff --git a/packages/dd-trace/src/plugins/util/test.js b/packages/dd-trace/src/plugins/util/test.js index faa68997115..698b9523612 100644 --- a/packages/dd-trace/src/plugins/util/test.js +++ b/packages/dd-trace/src/plugins/util/test.js @@ -239,6 +239,22 @@ function getSessionRequestErrorTags (sessionSpan) { return {} } +/** + * Returns ITR skipping-enabled tags from a test session span for propagation to child events. + * @param {{ context: () => { _tags?: Record } } | undefined} sessionSpan + * @returns {Record} + */ +function getSessionItrSkippingEnabledTags (sessionSpan) { + const tags = sessionSpan?.context()._tags + if (!tags || typeof tags !== 'object') return {} + if (tags[TEST_ITR_SKIPPING_ENABLED] !== undefined) { + return { + [TEST_ITR_SKIPPING_ENABLED]: tags[TEST_ITR_SKIPPING_ENABLED], + } + } + return {} +} + module.exports = { TEST_CODE_OWNERS, TEST_SESSION_NAME, @@ -350,6 +366,7 @@ module.exports = { TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, getLibraryCapabilitiesTags, getSessionRequestErrorTags, + getSessionItrSkippingEnabledTags, DD_CI_LIBRARY_CONFIGURATION_ERROR, checkShaDiscrepancies, getPullRequestDiff, diff --git a/packages/dd-trace/src/ritm.js b/packages/dd-trace/src/ritm.js index 6038c99c83c..02ea7e3997c 100644 --- a/packages/dd-trace/src/ritm.js +++ b/packages/dd-trace/src/ritm.js @@ -101,7 +101,7 @@ function Hook (modules, options, onrequire) { if (cache[moduleId]) { // require.cache was potentially altered externally const cacheEntry = require.cache[filename] - if (cacheEntry && cacheEntry.exports !== cache[filename].original) { + if (cacheEntry && cacheEntry.exports !== cache[moduleId].original) { return cacheEntry.exports } diff --git a/packages/dd-trace/test/azure_metadata.spec.js b/packages/dd-trace/test/azure_metadata.spec.js index 47b38f12fc3..e32582af94c 100644 --- a/packages/dd-trace/test/azure_metadata.spec.js +++ b/packages/dd-trace/test/azure_metadata.spec.js @@ -157,4 +157,39 @@ describe('Azure metadata', () => { const metadata = getAzureFunctionMetadata() assert.strictEqual(metadata.resourceGroup, 'regular_resource_group') }) + + describe('resource group extraction from WEBSITE_OWNER_NAME', () => { + /** @param {string} ownerName */ + function resourceGroupFor (ownerName) { + process.env.WEBSITE_SITE_NAME = 'site' + process.env.WEBSITE_OWNER_NAME = ownerName + return getAzureAppMetadata().resourceGroup + } + + it('strips the -Linux suffix before splitting on the last dash', () => { + assert.strictEqual(resourceGroupFor('sub+rg-regionwebspace-Linux'), 'rg') + }) + + it('preserves dashes inside the resource group', () => { + assert.strictEqual(resourceGroupFor('sub+with-dashes-regionwebspace'), 'with-dashes') + }) + + it('extracts from the shortest accepted form', () => { + assert.strictEqual(resourceGroupFor('a+b-cwebspace'), 'b') + }) + + it('returns undefined when WEBSITE_OWNER_NAME has no plus separator', () => { + // Suffix is otherwise valid; pins the early `plusIdx === -1` guard. + assert.strictEqual(resourceGroupFor('rg-regionwebspace'), undefined) + }) + + it('returns undefined when WEBSITE_OWNER_NAME does not end in webspace', () => { + // Length is chosen so removing the `webspace` guard would otherwise return `'rg'`. + assert.strictEqual(resourceGroupFor('sub+rg-something-region'), undefined) + }) + + it('returns undefined when no dash precedes the region marker', () => { + assert.strictEqual(resourceGroupFor('sub+regionwebspace'), undefined) + }) + }) }) diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index 0c998f17c49..a4ec1b69d35 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -536,6 +536,20 @@ describe('Config', () => { assert.strictEqual(config.OTEL_TRACES_EXPORTER, 'none') }) + it('should not disable OTLP traces export when DD_TRACE_AGENT_PROTOCOL_VERSION is unset', () => { + process.env.OTEL_TRACES_EXPORTER = 'otlp' + delete process.env.DD_TRACE_AGENT_PROTOCOL_VERSION + const config = getConfig() + assert.strictEqual(config.OTEL_TRACES_EXPORTER, 'otlp') + }) + + it('should disable OTLP traces export when DD_TRACE_AGENT_PROTOCOL_VERSION is set', () => { + process.env.OTEL_TRACES_EXPORTER = 'otlp' + process.env.DD_TRACE_AGENT_PROTOCOL_VERSION = '0.4' + const config = getConfig() + assert.strictEqual(config.OTEL_TRACES_EXPORTER, 'none') + }) + it('should fall back to http/json when OTEL_EXPORTER_OTLP_TRACES_PROTOCOL is unsupported', () => { process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL = 'grpc' const config = getConfig() diff --git a/packages/dd-trace/test/llmobs/plugins/aws-sdk/bedrockruntime.spec.js b/packages/dd-trace/test/llmobs/plugins/aws-sdk/bedrockruntime.spec.js index 73f521d264f..b88b55dfe43 100644 --- a/packages/dd-trace/test/llmobs/plugins/aws-sdk/bedrockruntime.spec.js +++ b/packages/dd-trace/test/llmobs/plugins/aws-sdk/bedrockruntime.spec.js @@ -2,9 +2,8 @@ const { describe, it, before } = require('mocha') -const { withVersions } = require('../../../setup/mocha') - const { assertLlmObsSpanEvent, useLlmObs } = require('../../util') +const { withAwsSdkVersions } = require('../../../../../datadog-plugin-aws-sdk/test/spec_helpers') const { models, modelConfig, @@ -24,7 +23,7 @@ describe('Plugin', () => { const { getEvents } = useLlmObs({ plugin: 'aws-sdk' }) - withVersions('aws-sdk', ['@aws-sdk/smithy-client', 'aws-sdk'], '>=3', (version, moduleName) => { + withAwsSdkVersions('>=3', (version, moduleName) => { let AWS let bedrockRuntimeClient diff --git a/packages/dd-trace/test/plugins/database-dbm-cache.spec.js b/packages/dd-trace/test/plugins/database-dbm-cache.spec.js new file mode 100644 index 00000000000..0ed75c5560b --- /dev/null +++ b/packages/dd-trace/test/plugins/database-dbm-cache.spec.js @@ -0,0 +1,142 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it, beforeEach, afterEach } = require('mocha') +const sinon = require('sinon') + +require('../setup/core') + +const DatabasePlugin = require('../../src/plugins/database') + +function makeSpan (tags = {}) { + return { + context: () => ({ _tags: tags }), + setTag () {}, + _spanContext: { toTraceparent: () => '00-aaa-bbb-01' }, + _processor: { sample () {} }, + } +} + +describe('DatabasePlugin DBM caching', () => { + let plugin + let tracer + let encodeSpy + + beforeEach(() => { + tracer = { + _service: 'svc', + _env: 'tester', + _version: '1.0.0', + } + plugin = new DatabasePlugin(tracer, {}) + plugin._tracerConfig = {} + plugin.configure({ dbmPropagationMode: 'service', enabled: true }) + }) + + afterEach(() => { + encodeSpy?.restore() + encodeSpy = undefined + }) + + it('captures dde / ddps / ddpv at configure time, not on every query', () => { + const span = makeSpan({ 'db.name': 'mydb', 'out.host': 'host1' }) + const first = plugin.createDbmComment(span, 'svc') + + assert.strictEqual( + first, + "dddb='mydb',dddbs='svc',dde='tester',ddh='host1',ddps='svc',ddpv='1.0.0'" + ) + + // Mutating the tracer's globals after configure must not affect the comment — the + // immutable suffix is baked in once. The original implementation re-read every field + // on every query. + tracer._env = 'shifted-env' + tracer._service = 'shifted-svc' + tracer._version = '2.0.0' + + assert.strictEqual(plugin.createDbmComment(span, 'svc'), first) + }) + + it('reuses the cached prefix across queries on the same (db, host, dbmService)', () => { + // Non-ASCII forces the `encodeURIComponent` path; the fast path skips ASCII inputs + // entirely, so we'd otherwise observe zero calls regardless of caching. + encodeSpy = sinon.spy(globalThis, 'encodeURIComponent') + const span = makeSpan({ 'db.name': 'müll', 'out.host': 'höst' }) + + plugin.createDbmComment(span, 'sërvice') + const callsAfterMiss = encodeSpy.callCount + assert.ok(callsAfterMiss >= 3, 'first call encodes db.name, host, and serviceName') + + plugin.createDbmComment(span, 'sërvice') + plugin.createDbmComment(span, 'sërvice') + assert.strictEqual(encodeSpy.callCount, callsAfterMiss, + 'cache hit must not encode again on identical (db, host, dbmService)') + + plugin.createDbmComment(makeSpan({ 'db.name': 'andërer', 'out.host': 'höst' }), 'sërvice') + assert.ok(encodeSpy.callCount > callsAfterMiss, + 'cache miss when db.name changes must re-encode') + }) + + it('skips encodeURIComponent for unreserved RFC 3986 characters', () => { + encodeSpy = sinon.spy(globalThis, 'encodeURIComponent') + const span = makeSpan({ 'db.name': 'safe-name.~_db', 'out.host': '127.0.0.1' }) + + const comment = plugin.createDbmComment(span, 'safe-svc') + assert.match(comment, /dddb='safe-name\.~_db'/) + assert.match(comment, /ddh='127\.0\.0\.1'/) + assert.match(comment, /dddbs='safe-svc'/) + assert.strictEqual(encodeSpy.callCount, 0, 'fast path bypasses encodeURIComponent') + }) + + it('falls back to encodeURIComponent on reserved characters', () => { + const span = makeSpan({ 'db.name': 'a&b', 'out.host': 'h$' }) + const comment = plugin.createDbmComment(span, 'sv c') + + assert.match(comment, /dddb='a%26b'/) + assert.match(comment, /ddh='h%24'/) + assert.match(comment, /dddbs='sv%20c'/) + }) + + it('configure() rebuilds the immutable suffix so reconfigured tracer fields take effect', () => { + const span = makeSpan({ 'db.name': 'mydb', 'out.host': 'host1' }) + assert.match(plugin.createDbmComment(span, 'svc'), /dde='tester'/) + + tracer._env = 'newenv' + plugin.configure({ dbmPropagationMode: 'service', enabled: true }) + + assert.match(plugin.createDbmComment(span, 'svc'), /dde='newenv'/) + }) + + it('appends ddprs= when peer-service is in scope', () => { + const span = makeSpan({ 'db.name': 'mydb', 'out.host': 'host1' }) + plugin.getPeerService = () => ({ + 'peer.service': 'downstream-svc', + '_dd.peer.service.source': 'peer.service', + }) + + const comment = plugin.createDbmComment(span, 'svc') + assert.match(comment, /,ddprs='downstream-svc'$/) + }) + + it('bounds the prefix cache so high-cardinality db.name cannot grow it without bound', () => { + // Boundary test for the LRU cap (DBM_PREFIX_CACHE_MAX = 256 in database.js). Insert + // CAP + 1 unique keys without re-accessing intermediate ones, so the first-inserted key + // is the LRU and gets evicted when the CAP-th unique insertion lands. + const CAP = 256 + encodeSpy = sinon.spy(globalThis, 'encodeURIComponent') + + for (let i = 0; i <= CAP; i++) { + plugin.createDbmComment(makeSpan({ 'db.name': `dëb-${i}`, 'out.host': 'höst' }), 'svc') + } + const callsAfterFill = encodeSpy.callCount + + plugin.createDbmComment(makeSpan({ 'db.name': `dëb-${CAP}`, 'out.host': 'höst' }), 'svc') + assert.strictEqual(encodeSpy.callCount, callsAfterFill, + 'most-recently inserted key must remain cached') + + plugin.createDbmComment(makeSpan({ 'db.name': 'dëb-0', 'out.host': 'höst' }), 'svc') + assert.ok(encodeSpy.callCount > callsAfterFill, + 'least-recently-used key must have been evicted at the cap') + }) +}) diff --git a/packages/dd-trace/test/plugins/util/stacktrace.spec.js b/packages/dd-trace/test/plugins/util/stacktrace.spec.js index 54b74749b0c..6c2983549f3 100644 --- a/packages/dd-trace/test/plugins/util/stacktrace.spec.js +++ b/packages/dd-trace/test/plugins/util/stacktrace.spec.js @@ -1,7 +1,7 @@ 'use strict' const assert = require('node:assert') -const { join } = require('node:path') +const { join, sep } = require('node:path') const { describe, it } = require('mocha') @@ -409,6 +409,19 @@ describe('stacktrace utils', () => { }) }) + describe('dd-trace instrumentation frames', () => { + it('should filter instrumentation frames regardless of the repo directory name', () => { + // Regression: previously only filtered when directory was named exactly 'dd-trace-js' + for (const repoName of ['dd-trace-js', 'dd-trace-js-1', 'my-dd-trace-fork', 'tracer']) { + const instrFile = join(`${sep}${repoName}`, 'packages', 'datadog-instrumentations', 'src', 'express.js') + const instrumentationFrame = ` at wrappedUse (${instrFile}:144:16)` + const userFrame = ' at testCase (/user/app/test.js:10:5)' + const stack = `Error: test\n${instrumentationFrame}\n${userFrame}` + assert.deepStrictEqual(parseUserLandFrames(stack).length, 1, `failed for repo name: ${repoName}`) + } + }) + }) + describe('user-land frame', () => { it('should should only return user-land frames', () => { const stack = genStackTraceWithManyNonUserLandFramesAnd( diff --git a/packages/dd-trace/test/plugins/versions/package.json b/packages/dd-trace/test/plugins/versions/package.json index d0910154f18..f8dcef5e1cc 100644 --- a/packages/dd-trace/test/plugins/versions/package.json +++ b/packages/dd-trace/test/plugins/versions/package.json @@ -27,7 +27,7 @@ "durable-functions": "3.3.0", "@azure/service-bus": "7.9.5", "@confluentinc/kafka-javascript": "1.8.0", - "@cucumber/cucumber": "12.5.0", + "@cucumber/cucumber": "12.8.2", "@datadog/openfeature-node-server": "0.3.1", "@elastic/elasticsearch": "9.3.2", "@elastic/transport": "9.3.3", @@ -44,11 +44,11 @@ "@hapi/hapi": "21.4.4", "@happy-dom/jest-environment": "20.3.1", "@hono/node-server": "1.19.9", - "@jest/core": "30.2.0", - "@jest/globals": "30.2.0", - "@jest/reporters": "30.2.0", - "@jest/test-sequencer": "30.2.0", - "@jest/transform": "30.2.0", + "@jest/core": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/reporters": "30.4.1", + "@jest/test-sequencer": "30.4.1", + "@jest/transform": "30.4.1", "@koa/router": "15.2.0", "@langchain/anthropic": "1.3.10", "@langchain/classic": "1.0.9", @@ -70,7 +70,7 @@ "@opentelemetry/instrumentation-express": "0.58.0", "@opentelemetry/instrumentation-http": "0.210.0", "@opentelemetry/sdk-node": "0.210.0", - "@playwright/test": "1.57.0", + "@playwright/test": "1.59.1", "@prisma/client": "7.2.0", "@prisma/adapter-pg": "7.2.0", "@prisma/adapter-mariadb": "7.2.0", @@ -91,7 +91,7 @@ "avsc": "5.7.9", "aws-sdk": "2.1693.0", "axios": "1.13.2", - "babel-jest": "30.2.0", + "babel-jest": "30.4.1", "azure-functions-core-tools": "4.6.0", "bluebird": "3.7.2", "body-parser": "2.2.2", @@ -126,15 +126,15 @@ "hono": "4.11.7", "ioredis": "5.9.2", "iovalkey": "0.3.3", - "jest": "30.2.0", - "jest-circus": "30.2.0", - "jest-config": "30.2.0", - "jest-environment-jsdom": "30.2.0", - "jest-environment-node": "30.2.0", + "jest": "30.4.1", + "jest-circus": "30.4.1", + "jest-config": "30.4.1", + "jest-environment-jsdom": "30.4.1", + "jest-environment-node": "30.4.1", "jest-image-snapshot": "6.5.1", - "jest-jasmine2": "30.2.0", - "jest-runtime": "30.2.0", - "jest-worker": "30.2.0", + "jest-jasmine2": "30.4.1", + "jest-runtime": "30.4.1", + "jest-worker": "30.4.1", "kafkajs": "2.2.4", "knex": "3.1.0", "koa": "3.1.1", @@ -179,8 +179,8 @@ "pg-query-stream": "4.11.1", "pino": "10.2.0", "pino-pretty": "13.1.3", - "playwright": "1.57.0", - "playwright-core": "1.57.0", + "playwright": "1.59.1", + "playwright-core": "1.59.1", "pnpm": "10.28.0", "prisma": "7.2.0", "promise": "8.3.0", diff --git a/packages/dd-trace/test/ritm.spec.js b/packages/dd-trace/test/ritm.spec.js index 7cabc9e60c8..81022fc0e6f 100644 --- a/packages/dd-trace/test/ritm.spec.js +++ b/packages/dd-trace/test/ritm.spec.js @@ -124,4 +124,29 @@ describe('Ritm', () => { assert.equal(startListener.callCount, 1) assert.equal(endListener.callCount, 1) }) + + it('should use moduleId as cache key for node:-prefixed built-ins', () => { + // Populate RITM cache keyed by normalized moduleId 'util' + require('util') + + // Simulate require.cache having an entry for the node:-prefixed filename. + // In Node.js 18+, Module._resolveFilename('node:util') returns 'node:util', + // so filename differs from moduleId ('util'). Before the fix, the cache + // comparison accessed cache[filename] ('node:util') which was undefined, + // causing: TypeError: undefined is not an object (evaluating 'cache[filename].original') + const prefixedKey = 'node:util' + const saved = require.cache[prefixedKey] + require.cache[prefixedKey] = { exports: { patched: true } } + + try { + const result = require('node:util') + assert.deepStrictEqual(result, { patched: true }) + } finally { + if (saved) { + require.cache[prefixedKey] = saved + } else { + delete require.cache[prefixedKey] + } + } + }) }) diff --git a/scripts/install_plugin_modules.js b/scripts/install_plugin_modules.js index 0e3ad14af7a..d500e25acd1 100644 --- a/scripts/install_plugin_modules.js +++ b/scripts/install_plugin_modules.js @@ -17,7 +17,7 @@ const exec = require('./helpers/exec') const requirePackageJsonPath = require.resolve('../packages/dd-trace/src/require-package-json') // Can remove aerospike after removing support for aerospike < 5.2.0 (for Node.js 22, v5.12.1 is required) -// Can remove couchbase after removing support for couchbase <= 3.2.0 +// Can remove couchbase after removing support for couchbase < 3.2.2 const excludeList = arch() === 'arm64' ? ['aerospike', 'couchbase', 'grpc', 'oracledb'] : [] const workspaces = new Set() const externalDeps = new Map() diff --git a/supported_versions_output.json b/supported_versions_output.json index 933bb2c7a15..8d0c19525df 100644 --- a/supported_versions_output.json +++ b/supported_versions_output.json @@ -52,7 +52,7 @@ "dependency": "@cucumber/cucumber", "integration": "cucumber", "minimum_tracer_supported": "7.0.0", - "max_tracer_supported": "12.5.0", + "max_tracer_supported": "12.8.2", "auto-instrumented": "True" }, { @@ -115,21 +115,21 @@ "dependency": "@jest/core", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.2.0", + "max_tracer_supported": "30.4.1", "auto-instrumented": "True" }, { "dependency": "@jest/test-sequencer", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.2.0", + "max_tracer_supported": "30.4.1", "auto-instrumented": "True" }, { "dependency": "@jest/transform", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.2.0", + "max_tracer_supported": "30.4.1", "auto-instrumented": "True" }, { @@ -282,7 +282,7 @@ { "dependency": "couchbase", "integration": "couchbase", - "minimum_tracer_supported": "2.6.12", + "minimum_tracer_supported": "3.0.7", "max_tracer_supported": "4.6.0", "auto-instrumented": "True" }, @@ -395,42 +395,42 @@ "dependency": "jest-circus", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.2.0", + "max_tracer_supported": "30.4.1", "auto-instrumented": "True" }, { "dependency": "jest-config", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.2.0", + "max_tracer_supported": "30.4.1", "auto-instrumented": "True" }, { "dependency": "jest-environment-jsdom", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.2.0", + "max_tracer_supported": "30.4.1", "auto-instrumented": "True" }, { "dependency": "jest-environment-node", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.2.0", + "max_tracer_supported": "30.4.1", "auto-instrumented": "True" }, { "dependency": "jest-runtime", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.2.0", + "max_tracer_supported": "30.4.1", "auto-instrumented": "True" }, { "dependency": "jest-worker", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.2.0", + "max_tracer_supported": "30.4.1", "auto-instrumented": "True" }, { @@ -626,7 +626,7 @@ "dependency": "playwright", "integration": "playwright", "minimum_tracer_supported": "1.38.0", - "max_tracer_supported": "1.57.0", + "max_tracer_supported": "1.59.1", "auto-instrumented": "True" }, { diff --git a/supported_versions_table.csv b/supported_versions_table.csv index 3fa98ead944..bf8a9f8dc09 100644 --- a/supported_versions_table.csv +++ b/supported_versions_table.csv @@ -6,7 +6,7 @@ dependency,integration,minimum_tracer_supported,max_tracer_supported,auto-instru @azure/functions,azure-functions,4.0.0,4.11.0,True @azure/service-bus,azure-service-bus,7.9.2,7.9.5,True @confluentinc/kafka-javascript,confluentinc-kafka-javascript,1.0.0,1.8.0,True -@cucumber/cucumber,cucumber,7.0.0,12.5.0,True +@cucumber/cucumber,cucumber,7.0.0,12.8.2,True @elastic/elasticsearch,elasticsearch,5.6.16,9.3.2,True @elastic/transport,elasticsearch,8.0.0,9.3.3,True @google-cloud/pubsub,google-cloud-pubsub,1.2.0,5.2.2,True @@ -15,9 +15,9 @@ dependency,integration,minimum_tracer_supported,max_tracer_supported,auto-instru @grpc/grpc-js,grpc,1.0.3,1.14.3,True @hapi/hapi,hapi,17.9.0,21.4.4,True @happy-dom/jest-environment,jest,10.0.0,20.3.1,True -@jest/core,jest,28.0.0,30.2.0,True -@jest/test-sequencer,jest,28.0.0,30.2.0,True -@jest/transform,jest,28.0.0,30.2.0,True +@jest/core,jest,28.0.0,30.4.1,True +@jest/test-sequencer,jest,28.0.0,30.4.1,True +@jest/transform,jest,28.0.0,30.4.1,True @koa/router,koa,8.0.0,15.2.0,True @langchain/core,langchain,0.1.0,1.1.16,True @langchain/langgraph,langgraph,1.1.2,1.1.2,True @@ -39,7 +39,7 @@ bunyan,bunyan,1.0.0,2.0.5,True cassandra-driver,cassandra-driver,3.0.0,4.8.0,True child_process,child_process,18.0.0,25.9.0,True connect,connect,2.2.2,3.7.0,True -couchbase,couchbase,2.6.12,4.6.0,True +couchbase,couchbase,3.0.7,4.6.0,True cypress,cypress,12.0.0,15.13.0,True dns,dns,18.0.0,25.9.0,True durable-functions,azure-durable-functions,3.0.0,3.3.0,True @@ -55,12 +55,12 @@ http2,http2,18.0.0,25.9.0,True https,http,18.0.0,25.9.0,True ioredis,ioredis,2.0.0,5.9.2,True iovalkey,iovalkey,0.0.1,0.3.3,True -jest-circus,jest,28.0.0,30.2.0,True -jest-config,jest,28.0.0,30.2.0,True -jest-environment-jsdom,jest,28.0.0,30.2.0,True -jest-environment-node,jest,28.0.0,30.2.0,True -jest-runtime,jest,28.0.0,30.2.0,True -jest-worker,jest,28.0.0,30.2.0,True +jest-circus,jest,28.0.0,30.4.1,True +jest-config,jest,28.0.0,30.4.1,True +jest-environment-jsdom,jest,28.0.0,30.4.1,True +jest-environment-node,jest,28.0.0,30.4.1,True +jest-runtime,jest,28.0.0,30.4.1,True +jest-worker,jest,28.0.0,30.4.1,True kafkajs,kafkajs,1.4.0,2.2.4,True koa,koa,2.0.0,3.1.1,True koa-router,koa,7.0.0,14.0.0,True @@ -88,7 +88,7 @@ oracledb,oracledb,5.0.0,6.10.0,True pg,pg,8.0.3,8.17.1,True pino,pino,2.0.0,10.2.0,True pino-pretty,pino,1.0.0,13.1.3,True -playwright,playwright,1.38.0,1.57.0,True +playwright,playwright,1.38.0,1.59.1,True protobufjs,protobufjs,6.8.0,8.0.0,True redis,redis,0.12.0,5.10.0,True restify,restify,3.0.0,11.1.0,True diff --git a/yarn.lock b/yarn.lock index 313591b646c..829531a9b20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -446,6 +446,18 @@ resolved "https://registry.yarnpkg.com/@msgpack/msgpack/-/msgpack-3.1.3.tgz#c4bff2b9539faf0882f3ee03537a7e9a4b3a7864" integrity sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA== +"@mswjs/interceptors@^0.41.0": + version "0.41.8" + resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.41.8.tgz#85ef74560f15d401dfe13798e6dabac15e457d6a" + integrity sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A== + dependencies: + "@open-draft/deferred-promise" "^2.2.0" + "@open-draft/logger" "^0.3.0" + "@open-draft/until" "^2.0.0" + is-node-process "^1.2.0" + outvariant "^1.4.3" + strict-event-emitter "^0.5.1" + "@napi-rs/wasm-runtime@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1" @@ -670,6 +682,24 @@ "@octokit/request-error" "^7.0.0" "@octokit/webhooks-methods" "^6.0.0" +"@open-draft/deferred-promise@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd" + integrity sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA== + +"@open-draft/logger@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@open-draft/logger/-/logger-0.3.0.tgz#2b3ab1242b360aa0adb28b85f5d7da1c133a0954" + integrity sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ== + dependencies: + is-node-process "^1.2.0" + outvariant "^1.4.0" + +"@open-draft/until@^2.0.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" + integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== + "@openfeature/core@^1.10.0": version "1.10.0" resolved "https://registry.yarnpkg.com/@openfeature/core/-/core-1.10.0.tgz#6fdcc2e00909bc0de45eae602c6650604bff1ed0" @@ -1225,7 +1255,7 @@ brace-expansion@^2.0.2: dependencies: balanced-match "^1.0.0" -brace-expansion@^5.0.2, brace-expansion@^5.0.5: +brace-expansion@^5.0.5: version "5.0.5" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== @@ -1551,10 +1581,10 @@ data-view-byte-offset@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" -dc-polyfill@^0.1.10: - version "0.1.10" - resolved "https://registry.yarnpkg.com/dc-polyfill/-/dc-polyfill-0.1.10.tgz#6f2ada1a9e449587c363ca98cfb79b443cf74b70" - integrity sha512-9iSbB8XZ7aIrhUtWI5ulEOJ+IyUN+axquodHK+bZO4r7HfY/xwmo6I4fYYf+aiDom+WMcN/wnzCz+pKvHDDCug== +dc-polyfill@^0.1.11: + version "0.1.11" + resolved "https://registry.yarnpkg.com/dc-polyfill/-/dc-polyfill-0.1.11.tgz#3efa792147f3b5224b8a9274905b1e98fe82a856" + integrity sha512-TyyeGcjx0YeThAI9fTFtgsvj5qd4R+aGfVmXiUhevbgzWFDr7IK4tv4YjE6jaGzLHQTchk4h7DHdr5q4WGgaZw== debug@^3.2.7: version "3.2.7" @@ -2694,6 +2724,11 @@ is-negative-zero@^2.0.3: resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== +is-node-process@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-node-process/-/is-node-process-1.2.0.tgz#ea02a1b90ddb3934a19aea414e88edef7e11d134" + integrity sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw== + is-number-object@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" @@ -3245,12 +3280,12 @@ negotiator@^1.0.0: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== -nock@^13.5.6: - version "13.5.6" - resolved "https://registry.yarnpkg.com/nock/-/nock-13.5.6.tgz#5e693ec2300bbf603b61dae6df0225673e6c4997" - integrity sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ== +nock@^14.0.14: + version "14.0.14" + resolved "https://registry.yarnpkg.com/nock/-/nock-14.0.14.tgz#877044b2de4a6844115d285cdbd24d423d30f58e" + integrity sha512-PKk7tex0O3RRXUZC5XDKJ9yM3rYRPS13myduT85VIIYDBnib42Fpxoe6KxRSzqB4iL2NDxkcJ2yiskZ18hGLEQ== dependencies: - debug "^4.1.0" + "@mswjs/interceptors" "^0.41.0" json-stringify-safe "^5.0.1" propagate "^2.0.0" @@ -3418,6 +3453,11 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" +outvariant@^1.4.0, outvariant@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.4.3.tgz#221c1bfc093e8fec7075497e7799fdbf43d14873" + integrity sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA== + own-keys@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" @@ -4135,6 +4175,11 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== +strict-event-emitter@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93" + integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ== + "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"