diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index fd409251590..00000000000 --- a/.eslintignore +++ /dev/null @@ -1,12 +0,0 @@ -coverage -dist -docs -out -node_modules -versions -acmeair-nodejs -vendor -integration-tests/esbuild/out.js -integration-tests/esbuild/aws-sdk-out.js -packages/dd-trace/src/appsec/blocked_templates.js -packages/dd-trace/src/payload-tagging/jsonpath-plus.js diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 13031ec7db1..00000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "parserOptions": { - "ecmaVersion": 2021 - }, - "extends": [ - "eslint:recommended", - "standard", - "plugin:mocha/recommended" - ], - "plugins": [ - "mocha", - "n" - ], - "env": { - "node": true, - "es2021": true - }, - "settings": { - "node": { - "version": ">=16.0.0" - } - }, - "rules": { - "max-len": [2, 120, 2], - "no-var": 2, - "no-console": 2, - "prefer-const": 2, - "object-curly-spacing": [2, "always"], - "import/no-extraneous-dependencies": 2, - "standard/no-callback-literal": 0, - "no-prototype-builtins": 0, - "mocha/no-mocha-arrows": 0, - "mocha/no-setup-in-describe": 0, - "mocha/no-sibling-hooks": 0, - "mocha/no-top-level-hooks": 0, - "mocha/max-top-level-suites": 0, - "mocha/no-identical-title": 0, - "mocha/no-global-tests": 0, - "mocha/no-exports": 0, - "mocha/no-skipped-tests": 0, - "n/no-restricted-require": [2, ["diagnostics_channel"]], - "n/no-callback-literal": 0, - "object-curly-newline": ["error", {"multiline": true, "consistent": true }], - "import/no-absolute-path": 0 - } -} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 00000000000..833243210ca --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,71 @@ +name: "Bug Report (Low Priority)" +description: "Create a public Bug Report. Note that these may not be addressed as quickly as the helpdesk and that looking up account information will be difficult." +title: "[BUG]: " +labels: bug +body: + - type: input + attributes: + label: Tracer Version(s) + description: "Version(s) of the tracer affected by this bug" + placeholder: 1.2.3, 4.5.6 + validations: + required: true + + - type: input + attributes: + label: Node.js Version(s) + description: "Version(s) of Node.js (`node --version`) that you've encountered this bug with" + placeholder: 20.1.1 + validations: + required: true + + - type: textarea + attributes: + label: Bug Report + description: Please add a clear and concise description of the bug here + validations: + required: true + + - type: textarea + attributes: + label: Reproduction Code + description: Please add code here to help us reproduce the problem + validations: + required: false + + - type: textarea + attributes: + label: Error Logs + description: "Please provide any error logs from the tracer (`DD_TRACE_DEBUG=true` can help)" + validations: + required: false + + - type: textarea + attributes: + label: Tracer Config + description: "Please provide the `tracer.init(config)` object and any applicable tracer environment variables" + validations: + required: false + + - type: input + attributes: + label: Operating System + description: "Provide your operating system and version (e.g. `uname -a`)" + placeholder: Darwin Kernel Version 23.6.0 + validations: + required: false + + - type: dropdown + attributes: + label: Bundling + description: "How is your application being bundled" + options: + - Unsure + - No Bundling + - ESBuild + - Webpack + - Next.js + - Vite + - Rollup + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index b5a5eb1d199..5f822733ea5 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,9 +1,8 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: - - name: Bug Report + - name: Bug Report (High Priority) url: https://help.datadoghq.com/hc/en-us/requests/new?tf_1260824651490=pt_product_type:apm&tf_1900004146284=pt_apm_language:node - about: This option creates an expedited Bug Report via the helpdesk (no login required). This will allow us to look up your account and allows you to provide additional information in private. Please do not create a GitHub issue to report a bug. - - name: Feature Request + about: Create an expedited Bug Report via the helpdesk (no login required). This will allow us to look up your account and allows you to provide additional information in private. Please do not create a GitHub issue to report a bug. + - name: Feature Request (High Priority) url: https://help.datadoghq.com/hc/en-us/requests/new?tf_1260824651490=pt_product_type:apm&tf_1900004146284=pt_apm_language:node&tf_1260825272270=pt_apm_category_feature_request - about: This option creates an expedited Feature Request via the helpdesk (no login required). This helps with prioritization and allows you to provide additional information in private. Please do not create a GitHub issue to request a feature. - + about: Create an expedited Feature Request via the helpdesk (no login required). This helps with prioritization and allows you to provide additional information in private. Please do not create a GitHub issue to request a feature. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 00000000000..9d26ea1dd33 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,50 @@ +name: Feature Request (Low Priority) +description: Create a public Feature Request. Note that these may not be addressed as quickly as the helpdesk and that looking up account information will be difficult. +title: "[FEATURE]: " +labels: feature-request +body: + - type: input + attributes: + label: Package Name + description: "If your feature request is to add instrumentation support for an npm package please provide the name here" + placeholder: left-pad + validations: + required: false + + - type: input + attributes: + label: Package Version(s) + description: "If your feature request is to add instrumentation support for an npm package please provide the version you use" + placeholder: 1.2.3 + validations: + required: false + + - type: textarea + attributes: + label: Describe the feature you'd like + description: A clear and concise description of what you want to happen. + validations: + required: true + + - type: textarea + attributes: + label: Is your feature request related to a problem? + description: | + Please add a clear and concise description of your problem. + E.g. I'm unable to instrument my database queries... + validations: + required: false + + - type: textarea + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered + validations: + required: false + + - type: textarea + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here + validations: + required: false diff --git a/.github/workflows/appsec.yml b/.github/workflows/appsec.yml index 45edbde6ebc..17a4e66f15c 100644 --- a/.github/workflows/appsec.yml +++ b/.github/workflows/appsec.yml @@ -210,7 +210,7 @@ jobs: version: - 18 - latest - range: ['9.5.0', '11.1.4', '13.2.0', '>=14.0.0 <=14.2.6', '>=14.2.7 <15', '>=15.0.0'] + range: ['>=10.2.0 <11', '>=11.0.0 <13', '11.1.4', '>=13.0.0 <14', '13.2.0', '>=14.0.0 <=14.2.6', '>=14.2.7 <15', '>=15.0.0'] runs-on: ubuntu-latest env: PLUGINS: next diff --git a/.github/workflows/package-size.yml b/.github/workflows/package-size.yml index 628614c7dc5..b6fee75c4c4 100644 --- a/.github/workflows/package-size.yml +++ b/.github/workflows/package-size.yml @@ -17,9 +17,9 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: - node-version: '18' + node-version: '20' - run: yarn - name: Compute module size tree and report uses: qard/heaviest-objects-in-the-universe@v1 diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index 5e1c3ac3017..4822539ecab 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -15,54 +15,30 @@ concurrency: jobs: - aerospike-node-16: - runs-on: ubuntu-latest - services: - aerospike: - image: aerospike:ce-5.7.0.15 - ports: - - "127.0.0.1:3000-3002:3000-3002" - env: - PLUGINS: aerospike - SERVICES: aerospike - PACKAGE_VERSION_RANGE: '>=4.0.0 <5.2.0' - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/testagent/start - - uses: ./.github/actions/node/setup - - id: pkg - run: | - content=`cat ./package.json | tr '\n' ' '` - echo "json=$content" >> $GITHUB_OUTPUT - - id: extract - run: | - version="${{fromJson(steps.pkg.outputs.json).version}}" - majorVersion=$(echo "$version" | cut -d '.' -f 1) - echo "Major Version: $majorVersion" - echo "MAJOR_VERSION=$majorVersion" >> $GITHUB_ENV - - uses: ./.github/actions/node/oldest - - name: Install dependencies - if: env.MAJOR_VERSION == '4' - uses: ./.github/actions/install - - name: Run tests - if: env.MAJOR_VERSION == '4' - run: yarn test:plugins:ci - - if: always() - uses: ./.github/actions/testagent/logs - - uses: codecov/codecov-action@v3 - - aerospike-node-18-20: + aerospike: strategy: matrix: - node-version: [18] - range: ['5.2.0 - 5.7.0'] + node-version: [16] + range: ['>=4.0.0 <5.2.0'] + aerospike-image: [ce-5.7.0.15] + test-image: [ubuntu-22.04] include: + - node-version: 18 + range: '>=5.2.0' + aerospike-image: ce-6.4.0.3 + test-image: ubuntu-latest - node-version: 20 - range: '>=5.8.0' - runs-on: ubuntu-latest + range: '>=5.5.0' + aerospike-image: ce-6.4.0.3 + test-image: ubuntu-latest + - node-version: 22 + range: '>=5.12.1' + aerospike-image: ce-6.4.0.3 + test-image: ubuntu-latest + runs-on: ${{ matrix.test-image }} services: aerospike: - image: aerospike:ce-6.4.0.3 + image: aerospike:${{ matrix.aerospike-image }} ports: - "127.0.0.1:3000-3002:3000-3002" env: @@ -73,24 +49,13 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/testagent/start - uses: ./.github/actions/node/setup - - id: pkg - run: | - content=`cat ./package.json | tr '\n' ' '` - echo "json=$content" >> $GITHUB_OUTPUT - - id: extract - run: | - version="${{fromJson(steps.pkg.outputs.json).version}}" - majorVersion=$(echo "$version" | cut -d '.' -f 1) - echo "Major Version: $majorVersion" - echo "MAJOR_VERSION=$majorVersion" >> $GITHUB_ENV - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} + - run: yarn config set ignore-engines true - name: Install dependencies - if: env.MAJOR_VERSION == '5' uses: ./.github/actions/install - name: Run tests - if: env.MAJOR_VERSION == '5' run: yarn test:plugins:ci - if: always() uses: ./.github/actions/testagent/logs @@ -294,6 +259,7 @@ jobs: PLUGINS: couchbase SERVICES: couchbase PACKAGE_VERSION_RANGE: ${{ matrix.range }} + DD_INJECT_FORCE: 'true' steps: - uses: actions/checkout@v4 - uses: ./.github/actions/testagent/start @@ -758,7 +724,7 @@ jobs: version: - 18 - latest - range: ['9.5.0', '11.1.4', '13.2.0', '>=14.0.0 <=14.2.6', '>=14.2.7 <15', '>=15.0.0'] + range: ['>=10.2.0 <11', '>=11.0.0 <13', '11.1.4', '>=13.0.0 <14', '13.2.0', '>=14.0.0 <=14.2.6', '>=14.2.7 <15', '>=15.0.0'] runs-on: ubuntu-latest env: PLUGINS: next @@ -828,6 +794,7 @@ jobs: PLUGINS: oracledb SERVICES: oracledb DD_TEST_AGENT_URL: http://testagent:9126 + DD_INJECT_FORCE: 'true' # Needed to fix issue with `actions/checkout@v3: https://github.com/actions/checkout/issues/1590 ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true steps: diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml index 92a97c56457..3dd8475811e 100644 --- a/.github/workflows/project.yml +++ b/.github/workflows/project.yml @@ -34,7 +34,7 @@ jobs: integration-guardrails: strategy: matrix: - version: [12.0.0, 12, 14.0.0, 14, 16.0.0, 16, 18.0.0, 18.1.0, 20.0.0, 22.0.0] + version: [12, 14.0.0, 14, 16.0.0, 16, 18.0.0, 18.1.0, 20.0.0, 22.0.0] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -47,16 +47,17 @@ jobs: integration-guardrails-unsupported: strategy: matrix: - version: ['0.8', '0.10', '0.12', '4', '6', '8', '10'] + version: ['0.8', '0.10', '0.12', '4', '6', '8', '10', '12.0.0'] runs-on: ubuntu-latest - env: - DD_INJECTION_ENABLED: 'true' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: node-version: ${{ matrix.version }} - run: node ./init + - run: node ./init + env: + DD_INJECTION_ENABLED: 'true' integration-ci: strategy: @@ -161,3 +162,10 @@ jobs: - run: yarn type:test - run: yarn type:doc + verify-yaml: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/node/setup + - uses: ./.github/actions/install + - run: node scripts/verify-ci-config.js diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6da75a763ac..dcf8a6c7772 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -22,8 +22,9 @@ onboarding_tests_installer: SCENARIO: [ SIMPLE_INSTALLER_AUTO_INJECTION, SIMPLE_AUTO_INJECTION_PROFILING ] onboarding_tests_k8s_injection: - variables: - WEBLOG_VARIANT: sample-app + parallel: + matrix: + - WEBLOG_VARIANT: sample-app requirements_json_test: rules: diff --git a/.gitlab/benchmarks.yml b/.gitlab/benchmarks.yml index 57eba976441..7461f88b98c 100644 --- a/.gitlab/benchmarks.yml +++ b/.gitlab/benchmarks.yml @@ -65,6 +65,18 @@ benchmark: GROUP: 2 - MAJOR_VERSION: 18 GROUP: 3 + - MAJOR_VERSION: 20 + GROUP: 1 + - MAJOR_VERSION: 20 + GROUP: 2 + - MAJOR_VERSION: 20 + GROUP: 3 + - MAJOR_VERSION: 22 + GROUP: 1 + - MAJOR_VERSION: 22 + GROUP: 2 + - MAJOR_VERSION: 22 + GROUP: 3 variables: SPLITS: 3 diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index f078d0aa4ae..4ba4775b73c 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -13,7 +13,6 @@ require,crypto-randomuuid,MIT,Copyright 2021 Node.js Foundation and contributors require,dc-polyfill,MIT,Copyright 2023 Datadog Inc. require,ignore,MIT,Copyright 2013 Kael Zhang and contributors require,import-in-the-middle,Apache license 2.0,Copyright 2021 Datadog Inc. -require,int64-buffer,MIT,Copyright 2015-2016 Yusuke Kawasaki require,istanbul-lib-coverage,BSD-3-Clause,Copyright 2012-2015 Yahoo! Inc. require,jest-docblock,MIT,Copyright Meta Platforms, Inc. and affiliates. require,koalas,MIT,Copyright 2013-2017 Brian Woodward @@ -21,7 +20,6 @@ require,limiter,MIT,Copyright 2011 John Hurliman require,lodash.sortby,MIT,Copyright JS Foundation and other contributors require,lru-cache,ISC,Copyright (c) 2010-2022 Isaac Z. Schlueter and Contributors require,module-details-from-path,MIT,Copyright 2016 Thomas Watson Steen -require,msgpack-lite,MIT,Copyright 2015 Yusuke Kawasaki require,opentracing,MIT,Copyright 2016 Resonance Labs Inc require,path-to-regexp,MIT,Copyright 2014 Blake Embrey require,pprof-format,MIT,Copyright 2022 Stephen Belanger @@ -31,8 +29,12 @@ require,retry,MIT,Copyright 2011 Tim Koschützki Felix Geisendörfer require,rfdc,MIT,Copyright 2019 David Mark Clements require,semver,ISC,Copyright Isaac Z. Schlueter and Contributors require,shell-quote,mit,Copyright (c) 2013 James Halliday +require,source-map,BSD-3-Clause,Copyright (c) 2009-2011, Mozilla Foundation and contributors dev,@apollo/server,MIT,Copyright (c) 2016-2020 Apollo Graph, Inc. (Formerly Meteor Development Group, Inc.) dev,@types/node,MIT,Copyright Authors +dev,@eslint/eslintrc,MIT,Copyright OpenJS Foundation and other contributors, +dev,@eslint/js,MIT,Copyright OpenJS Foundation and other contributors, +dev,@stylistic/eslint-plugin-js,MIT,Copyright OpenJS Foundation and other contributors, dev,autocannon,MIT,Copyright 2016 Matteo Collina dev,aws-sdk,Apache 2.0,Copyright 2012-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. dev,axios,MIT,Copyright 2014-present Matt Zabriskie @@ -53,11 +55,14 @@ dev,eslint-plugin-promise,ISC,jden and other contributors dev,express,MIT,Copyright 2009-2014 TJ Holowaychuk 2013-2014 Roman Shtylman 2014-2015 Douglas Christopher Wilson dev,get-port,MIT,Copyright Sindre Sorhus dev,glob,ISC,Copyright Isaac Z. Schlueter and Contributors +dev,globals,MIT,Copyright (c) Sindre Sorhus (https://sindresorhus.com) dev,graphql,MIT,Copyright 2015 Facebook Inc. +dev,int64-buffer,MIT,Copyright 2015-2016 Yusuke Kawasaki dev,jszip,MIT,Copyright 2015-2016 Stuart Knightley and contributors dev,knex,MIT,Copyright (c) 2013-present Tim Griesser dev,mkdirp,MIT,Copyright 2010 James Halliday dev,mocha,MIT,Copyright 2011-2018 JS Foundation and contributors https://js.foundation +dev,msgpack-lite,MIT,Copyright 2015 Yusuke Kawasaki dev,multer,MIT,Copyright 2014 Hage Yaapa dev,nock,MIT,Copyright 2017 Pedro Teixeira and other contributors dev,nyc,ISC,Copyright 2015 Contributors @@ -67,6 +72,7 @@ dev,sinon,BSD-3-Clause,Copyright 2010-2017 Christian Johansen dev,sinon-chai,WTFPL and BSD-2-Clause,Copyright 2004 Sam Hocevar 2012–2017 Domenic Denicola dev,tap,ISC,Copyright 2011-2022 Isaac Z. Schlueter and Contributors dev,tiktoken,MIT,Copyright (c) 2022 OpenAI, Shantanu Jain +dev,yaml,ISC,Copyright Eemeli Aro file,aws-lambda-nodejs-runtime-interface-client,Apache 2.0,Copyright 2019 Amazon.com Inc. or its affiliates. All Rights Reserved. file,profile.proto,Apache license 2.0,Copyright 2016 Google Inc. file,is-git-url,MIT,Copyright (c) 2017 Jon Schlinkert. diff --git a/benchmark/profiler/index.js b/benchmark/profiler/index.js deleted file mode 100644 index 20f1455d05d..00000000000 --- a/benchmark/profiler/index.js +++ /dev/null @@ -1,230 +0,0 @@ -'use strict' - -/* eslint-disable no-console */ - -const autocannon = require('autocannon') -const axios = require('axios') -const chalk = require('chalk') -const getPort = require('get-port') -const Table = require('cli-table3') -const URL = require('url').URL -const { spawn } = require('child_process') - -main() - -async function main () { - try { - const disabled = await run(false) - const enabled = await run(true) - - compare(disabled, enabled) - } catch (e) { - console.error(e) - process.exit(1) - } -} - -async function run (profilerEnabled) { - const port = await getPort() - const url = new URL(`http://localhost:${port}/hello`) - const server = await createServer(profilerEnabled, url) - - title(`Benchmark (enabled=${profilerEnabled})`) - - await getUsage(url) - - const net = await benchmark(url.href, 15000) - const cpu = await getUsage(url) - - server.kill('SIGINT') - - return { cpu, net } -} - -function benchmark (url, maxConnectionRequests) { - return new Promise((resolve, reject) => { - const duration = maxConnectionRequests * 2 / 1000 - const instance = autocannon({ duration, maxConnectionRequests, url }, (err, result) => { - err ? reject(err) : resolve(result) - }) - - process.once('SIGINT', () => { - instance.stop() - }) - - autocannon.track(instance, { - renderResultsTable: true, - renderProgressBar: false - }) - }) -} - -function compare (result1, result2) { - title('Comparison (disabled VS enabled)') - - compareNet(result1.net, result2.net) - compareCpu(result1.cpu, result2.cpu) -} - -function compareNet (result1, result2) { - const shortLatency = new Table({ - head: asColor(chalk.cyan, ['Stat', '2.5%', '50%', '97.5%', '99%', 'Avg', 'Max']) - }) - - shortLatency.push(asLowRow(chalk.bold('Latency'), asDiff(result1.latency, result2.latency))) - - console.log(shortLatency.toString()) - - const requests = new Table({ - head: asColor(chalk.cyan, ['Stat', '1%', '2.5%', '50%', '97.5%', 'Avg', 'Min']) - }) - - requests.push(asHighRow(chalk.bold('Req/Sec'), asDiff(result1.requests, result2.requests, true))) - requests.push(asHighRow(chalk.bold('Bytes/Sec'), asDiff(result1.throughput, result2.throughput, true))) - - console.log(requests.toString()) -} - -function compareCpu (result1, result2) { - const cpuTime = new Table({ - head: asColor(chalk.cyan, ['Stat', 'User', 'System', 'Process']) - }) - - cpuTime.push(asTimeRow(chalk.bold('CPU Time'), asDiff(result1, result2))) - - console.log(cpuTime.toString()) -} - -function waitOn ({ interval = 250, timeout, resources }) { - return Promise.all(resources.map(resource => { - return new Promise((resolve, reject) => { - let intervalTimer - const timeoutTimer = timeout && setTimeout(() => { - reject(new Error('Timeout.')) - clearTimeout(timeoutTimer) - clearTimeout(intervalTimer) - }, timeout) - - function waitOnResource () { - if (timeout && !timeoutTimer) return - - axios.get(resource) - .then(() => { - resolve() - clearTimeout(timeoutTimer) - clearTimeout(intervalTimer) - }) - .catch(() => { - intervalTimer = setTimeout(waitOnResource, interval) - }) - } - - waitOnResource() - }) - })) -} - -async function createServer (profilerEnabled, url) { - const server = spawn(process.execPath, ['server'], { - cwd: __dirname, - env: { - DD_PROFILING_ENABLED: String(profilerEnabled), - PORT: url.port - } - }) - - process.once('SIGINT', () => { - server.kill('SIGINT') - }) - - await waitOn({ - timeout: 5000, - resources: [url.href] - }) - - return server -} - -async function getUsage (url) { - const response = await axios.get(`${url.origin}/usage`) - const usage = response.data - - usage.process = usage.user + usage.system - - return usage -} - -function asColor (colorise, row) { - return row.map((entry) => colorise(entry)) -} - -function asDiff (stat1, stat2, reverse = false) { - const result = Object.create(null) - - Object.keys(stat1).forEach((k) => { - if (stat2[k] === stat1[k]) return (result[k] = '0%') - if (stat1[k] === 0) return (result[k] = '+∞%') - if (stat2[k] === 0) return (result[k] = '-∞%') - - const fraction = stat2[k] / stat1[k] - const percent = Math.round(fraction * 100) - 100 - const value = `${withSign(percent)}%` - - if (percent > 0) { - result[k] = reverse ? chalk.green(value) : chalk.red(value) - } else if (percent < 0) { - result[k] = reverse ? chalk.red(value) : chalk.green(value) - } else { - result[k] = value - } - }) - - return result -} - -function asLowRow (name, stat) { - return [ - name, - stat.p2_5, - stat.p50, - stat.p97_5, - stat.p99, - stat.average, - typeof stat.max === 'string' ? stat.max : Math.floor(stat.max * 100) / 100 - ] -} - -function asHighRow (name, stat) { - return [ - name, - stat.p1, - stat.p2_5, - stat.p50, - stat.p97_5, - stat.average, - typeof stat.min === 'string' ? stat.min : Math.floor(stat.min * 100) / 100 - ] -} - -function asTimeRow (name, stat) { - return [ - name, - stat.user, - stat.system, - stat.process - ] -} - -function withSign (value) { - return value < 0 ? `${value}` : `+${value}` -} - -function title (str) { - const line = ''.padStart(str.length, '=') - - console.log('') - console.log(line) - console.log(str) - console.log(line) - console.log('') -} diff --git a/benchmark/profiler/server.js b/benchmark/profiler/server.js deleted file mode 100644 index cf190e40eed..00000000000 --- a/benchmark/profiler/server.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict' - -require('dotenv').config() -require('../..').init({ enabled: false }) - -const express = require('express') - -const app = express() - -let usage - -app.get('/hello', (req, res) => { - res.status(200).send('Hello World!') -}) - -app.get('/usage', (req, res) => { - const diff = process.cpuUsage(usage) - - usage = process.cpuUsage() - - res.status(200).send(diff) -}) - -app.listen(process.env.PORT || 8080, '127.0.0.1', () => { - usage = process.cpuUsage() -}) diff --git a/benchmark/sirun/.gitignore b/benchmark/sirun/.gitignore index bc111ce710b..6b557f5a398 100644 --- a/benchmark/sirun/.gitignore +++ b/benchmark/sirun/.gitignore @@ -1,2 +1,3 @@ *.ndjson meta-temp.json +summary.json diff --git a/benchmark/sirun/Dockerfile b/benchmark/sirun/Dockerfile index 6ce6d8557fe..ad27d5d71b1 100644 --- a/benchmark/sirun/Dockerfile +++ b/benchmark/sirun/Dockerfile @@ -6,7 +6,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ git hwinfo jq procps \ software-properties-common build-essential libnss3-dev zlib1g-dev libgdbm-dev libncurses5-dev libssl-dev libffi-dev libreadline-dev libsqlite3-dev libbz2-dev -RUN git clone --depth 1 https://github.com/pyenv/pyenv.git --branch "v2.0.4" --single-branch /pyenv +RUN git clone --depth 1 https://github.com/pyenv/pyenv.git --branch "v2.4.1" --single-branch /pyenv ENV PYENV_ROOT "/pyenv" ENV PATH "/pyenv/shims:/pyenv/bin:$PATH" RUN eval "$(pyenv init -)" @@ -34,6 +34,7 @@ RUN mkdir -p /usr/local/nvm \ && nvm install --no-progress 16.20.1 \ && nvm install --no-progress 18.16.1 \ && nvm install --no-progress 20.4.0 \ + && nvm install --no-progress 22.10.0 \ && nvm alias default 18 \ && nvm use 18 diff --git a/benchmark/sirun/appsec-iast/README.md b/benchmark/sirun/appsec-iast/README.md index 79c5e0d21ab..728ed535fb3 100644 --- a/benchmark/sirun/appsec-iast/README.md +++ b/benchmark/sirun/appsec-iast/README.md @@ -1,4 +1,4 @@ -This creates 150 HTTP requests from client to server. +This benchmarks HTTP requests from client to server. The variants are: - control tracer with non vulnerable endpoint without iast diff --git a/benchmark/sirun/appsec/README.md b/benchmark/sirun/appsec/README.md index fd45c303b23..bbcb424e972 100644 --- a/benchmark/sirun/appsec/README.md +++ b/benchmark/sirun/appsec/README.md @@ -1,4 +1,4 @@ -This creates 1,000 HTTP requests from client to server. +This benchmarks HTTP requests from client to server. The variants are: - control tracer without appsec diff --git a/benchmark/sirun/encoding/README.md b/benchmark/sirun/encoding/README.md index 889bb9dec4b..957102dabc7 100644 --- a/benchmark/sirun/encoding/README.md +++ b/benchmark/sirun/encoding/README.md @@ -1,4 +1,4 @@ -This test sends a single trace 10000 times to the encoder. Each trace is +This test sends a single trace many times to the encoder. Each trace is pre-formatted (as the encoder requires) and consists of 30 spans with the same content in each of them. The IDs are all randomized. A null writer is provided to the encoder, so writing operations are not included here. diff --git a/benchmark/sirun/exporting-pipeline/README.md b/benchmark/sirun/exporting-pipeline/README.md index f7447afc608..28a0f23e5d2 100644 --- a/benchmark/sirun/exporting-pipeline/README.md +++ b/benchmark/sirun/exporting-pipeline/README.md @@ -2,6 +2,6 @@ This test creates a 30 span trace (of similar format to the encoding test). These spans are then passed through the formatting, encoding, and writing steps in our pipeline, and sent to a dummy agent. Once a span (i.e. a trace) is added to the exporter, we then proceed to the next iteration via `setImmediate`, and -run for 25000 iterations. +run for many iterations. There's a variant for each of our encodings/endpoints. diff --git a/benchmark/sirun/log/README.md b/benchmark/sirun/log/README.md index 9f25c806479..422abe0a610 100644 --- a/benchmark/sirun/log/README.md +++ b/benchmark/sirun/log/README.md @@ -1,4 +1,4 @@ -This test calls the internal logger on various log levels for 1000 iterations. +This test calls the internal logger on various log levels for many iterations. * `without-log` is the baseline that has logging disabled completely. * `skip-log` has logs enabled but uses a log level that isn't so that the handler doesn't run. diff --git a/benchmark/sirun/plugin-bluebird/README.md b/benchmark/sirun/plugin-bluebird/README.md index 5d1746b4b24..79fd4f57d0d 100644 --- a/benchmark/sirun/plugin-bluebird/README.md +++ b/benchmark/sirun/plugin-bluebird/README.md @@ -1,3 +1,3 @@ -This creates 50000 promises in a chain using the latest version of `bluebird`. +This creates a lot of promises in a chain using the latest version of `bluebird`. -The variants are with the tracer and without it. \ No newline at end of file +The variants are with the tracer and without it. diff --git a/benchmark/sirun/plugin-dns/README.md b/benchmark/sirun/plugin-dns/README.md index af30cb91095..566ac08842b 100644 --- a/benchmark/sirun/plugin-dns/README.md +++ b/benchmark/sirun/plugin-dns/README.md @@ -1,2 +1,2 @@ -Runs `dns.lookup('localhost', cb)` 10000 times. In the `with-tracer` variant, +Runs `dns.lookup('localhost', cb)` many times. In the `with-tracer` variant, tracing is enabled. Iteration count is set to 10. diff --git a/benchmark/sirun/plugin-http/README.md b/benchmark/sirun/plugin-http/README.md index 0ed9208d040..f42693cb6b2 100644 --- a/benchmark/sirun/plugin-http/README.md +++ b/benchmark/sirun/plugin-http/README.md @@ -1,4 +1,4 @@ -This creates 1,000 HTTP requests from client to server. +This benchmarks HTTP requests from client to server. The variants are with the tracer and without it, and instrumenting on the server and the client separately. diff --git a/benchmark/sirun/plugin-net/README.md b/benchmark/sirun/plugin-net/README.md index 0731413e121..dc2635fdbe9 100644 --- a/benchmark/sirun/plugin-net/README.md +++ b/benchmark/sirun/plugin-net/README.md @@ -1,3 +1,3 @@ -Creates 1000 connections between a net server and net client, doing a simple +Benchmarks connections between a net server and net client, doing a simple echo request. Since we only instrument client-side net connections, our variants are having the client with and without the tracer. diff --git a/benchmark/sirun/plugin-q/README.md b/benchmark/sirun/plugin-q/README.md index 48e57db4360..8dcce34ec93 100644 --- a/benchmark/sirun/plugin-q/README.md +++ b/benchmark/sirun/plugin-q/README.md @@ -1,3 +1,3 @@ -This creates 50000 promises in a chain using the latest version of `q`. +This benchmarks promises in a chain using the latest version of `q`. The variants are with the tracer and without it. diff --git a/benchmark/sirun/spans/README.md b/benchmark/sirun/spans/README.md index 734c9df65ac..7b695939b00 100644 --- a/benchmark/sirun/spans/README.md +++ b/benchmark/sirun/spans/README.md @@ -1,5 +1,5 @@ This test initializes a tracer with the no-op scope manager. It then creates -100000 spans, and depending on the variant, either finishes all of them as they +many spans, and depending on the variant, either finishes all of them as they are created, or later on once they're all created. Prior to creating any spans, it modifies the processor instance so that no span processing (or exporting) is done, and it simply stops storing the spans. diff --git a/benchmark/sirun/startup/README.md b/benchmark/sirun/startup/README.md index c09d0aed461..69c311d778c 100644 --- a/benchmark/sirun/startup/README.md +++ b/benchmark/sirun/startup/README.md @@ -1,3 +1,7 @@ This is a simple startup test. It tests with an without the tracer, and with and without requiring every dependency and devDependency in the package.json, for a total of four variants. + +While it's unrealistic to load all the tracer's devDependencies, the intention +is to simulate loading a lot of dependencies for an application, and have them +either be intercepted by our loader hooks, or not. diff --git a/ci/init.js b/ci/init.js index b54e29abd4d..7b15ed15151 100644 --- a/ci/init.js +++ b/ci/init.js @@ -1,11 +1,22 @@ /* eslint-disable no-console */ const tracer = require('../packages/dd-trace') const { isTrue } = require('../packages/dd-trace/src/util') +const log = require('../packages/dd-trace/src/log') const isJestWorker = !!process.env.JEST_WORKER_ID const isCucumberWorker = !!process.env.CUCUMBER_WORKER_ID const isMochaWorker = !!process.env.MOCHA_WORKER_ID +const packageManagers = [ + 'npm', + 'yarn', + 'pnpm' +] + +const isPackageManager = () => { + return packageManagers.some(packageManager => process.argv[1]?.includes(`bin/${packageManager}`)) +} + const options = { startupLogs: false, isCiVisibility: true, @@ -14,6 +25,11 @@ const options = { let shouldInit = true +if (isPackageManager()) { + log.debug('dd-trace is not initialized in a package manager.') + shouldInit = false +} + const isAgentlessEnabled = isTrue(process.env.DD_CIVISIBILITY_AGENTLESS_ENABLED) if (isAgentlessEnabled) { diff --git a/docs/package.json b/docs/package.json index 0ec46d7584a..c68302e3eca 100644 --- a/docs/package.json +++ b/docs/package.json @@ -10,7 +10,7 @@ "license": "BSD-3-Clause", "private": true, "devDependencies": { - "typedoc": "^0.25.8", - "typescript": "^4.6" + "typedoc": "^0.25.13", + "typescript": "^4.9.4" } } diff --git a/docs/test.ts b/docs/test.ts index 479b4620b4d..2c2cbea332e 100644 --- a/docs/test.ts +++ b/docs/test.ts @@ -111,7 +111,7 @@ tracer.init({ blockedTemplateJson: './blocked.json', blockedTemplateGraphql: './blockedgraphql.json', eventTracking: { - mode: 'safe' + mode: 'anon' }, apiSecurity: { enabled: true, @@ -131,6 +131,7 @@ tracer.init({ requestSampling: 50, maxConcurrentRequests: 4, maxContextOperations: 30, + dbRowsToTaint: 12, deduplicationEnabled: true, redactionEnabled: true, redactionNamePattern: 'password', @@ -147,6 +148,7 @@ tracer.init({ requestSampling: 50, maxConcurrentRequests: 4, maxContextOperations: 30, + dbRowsToTaint: 6, deduplicationEnabled: true, redactionEnabled: true, redactionNamePattern: 'password', diff --git a/docs/yarn.lock b/docs/yarn.lock index 4b011ed3db2..4c517dabb07 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -20,9 +20,9 @@ brace-expansion@^2.0.1: balanced-match "^1.0.0" jsonc-parser@^3.2.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.1.tgz#031904571ccf929d7670ee8c547545081cb37f1a" - integrity sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA== + version "3.3.1" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== lunr@^2.3.9: version "2.3.9" @@ -35,9 +35,9 @@ marked@^4.3.0: integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A== minimatch@^9.0.3: - version "9.0.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" - integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== dependencies: brace-expansion "^2.0.1" @@ -51,17 +51,17 @@ shiki@^0.14.7: vscode-oniguruma "^1.7.0" vscode-textmate "^8.0.0" -typedoc@^0.25.8: - version "0.25.8" - resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.25.8.tgz#7d0e1bf12d23bf1c459fd4893c82cb855911ff12" - integrity sha512-mh8oLW66nwmeB9uTa0Bdcjfis+48bAjSH3uqdzSuSawfduROQLlXw//WSNZLYDdhmMVB7YcYZicq6e8T0d271A== +typedoc@^0.25.13: + version "0.25.13" + resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.25.13.tgz#9a98819e3b2d155a6d78589b46fa4c03768f0922" + integrity sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ== dependencies: lunr "^2.3.9" marked "^4.3.0" minimatch "^9.0.3" shiki "^0.14.7" -typescript@^4.6: +typescript@^4.9.4: version "4.9.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000000..8b83488c08e --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,119 @@ +import mocha from 'eslint-plugin-mocha' +import n from 'eslint-plugin-n' +import stylistic from '@stylistic/eslint-plugin-js' +import globals from 'globals' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import js from '@eslint/js' +import { FlatCompat } from '@eslint/eslintrc' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}) + +export default [ + { + ignores: [ + '**/coverage', // Just coverage reports. + '**/dist', // Generated + '**/docs', // Any JS here is for presentation only. + '**/out', // Generated + '**/node_modules', // We don't own these. + '**/versions', // This is effectively a node_modules tree. + '**/acmeair-nodejs', // We don't own this. + '**/vendor', // Generally, we didn't author this code. + 'integration-tests/esbuild/out.js', // Generated + 'integration-tests/esbuild/aws-sdk-out.js', // Generated + 'packages/dd-trace/src/appsec/blocked_templates.js', // TODO Why is this ignored? + 'packages/dd-trace/src/payload-tagging/jsonpath-plus.js' // Vendored + ] + }, ...compat.extends('eslint:recommended', 'standard', 'plugin:mocha/recommended'), { + plugins: { + mocha, + n, + '@stylistic/js': stylistic + }, + + languageOptions: { + globals: { + ...globals.node + }, + + ecmaVersion: 2022 + }, + + settings: { + node: { + version: '>=16.0.0' + } + }, + + rules: { + '@stylistic/js/max-len': ['error', { code: 120, tabWidth: 2 }], + '@stylistic/js/object-curly-newline': ['error', { + multiline: true, + consistent: true + }], + '@stylistic/js/object-curly-spacing': ['error', 'always'], + 'import/no-absolute-path': 'off', + 'import/no-extraneous-dependencies': 'error', + 'n/no-callback-literal': 'off', + 'n/no-restricted-require': ['error', ['diagnostics_channel']], + 'no-console': 'error', + 'no-prototype-builtins': 'off', + 'no-unused-expressions': 'off', + 'no-var': 'error', + 'prefer-const': 'error', + 'standard/no-callback-literal': 'off' + } + }, + { + files: [ + 'packages/*/test/**/*.js', + 'packages/*/test/**/*.mjs', + 'integration-tests/**/*.js', + 'integration-tests/**/*.mjs', + '**/*.spec.js' + ], + languageOptions: { + globals: { + ...globals.mocha, + sinon: false, + expect: false, + proxyquire: false, + withVersions: false, + withPeerService: false, + withNamingSchema: false, + withExports: false + } + }, + rules: { + 'mocha/max-top-level-suites': 'off', + 'mocha/no-exports': 'off', + 'mocha/no-global-tests': 'off', + 'mocha/no-identical-title': 'off', + 'mocha/no-mocha-arrows': 'off', + 'mocha/no-setup-in-describe': 'off', + 'mocha/no-sibling-hooks': 'off', + 'mocha/no-skipped-tests': 'off', + 'mocha/no-top-level-hooks': 'off', + 'n/handle-callback-err': 'off', + 'no-loss-of-precision': 'off' + } + }, + { + files: [ + 'integration-tests/**/*.js', + 'integration-tests/**/*.mjs', + 'packages/*/test/integration-test/**/*.js', + 'packages/*/test/integration-test/**/*.mjs' + ], + rules: { + 'import/no-extraneous-dependencies': 'off' + } + } +] diff --git a/index.d.ts b/index.d.ts index 9b4becec957..8984d02f81a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -655,12 +655,24 @@ declare namespace tracer { */ eventTracking?: { /** - * Controls the automated user event tracking mode. Possible values are disabled, safe and extended. - * On safe mode, any detected Personally Identifiable Information (PII) about the user will be redacted from the event. - * On extended mode, no redaction will take place. - * @default 'safe' + * Controls the automated user tracking mode for user IDs and logins collections. Possible values: + * * 'anonymous': will hash user IDs and user logins before collecting them + * * 'anon': alias for 'anonymous' + * * 'safe': deprecated alias for 'anonymous' + * + * * 'identification': will collect user IDs and logins without redaction + * * 'ident': alias for 'identification' + * * 'extended': deprecated alias for 'identification' + * + * * 'disabled': will not collect user IDs and logins + * + * Unknown values will be considered as 'disabled' + * @default 'identification' */ - mode?: 'safe' | 'extended' | 'disabled' + mode?: + 'anonymous' | 'anon' | 'safe' | + 'identification' | 'ident' | 'extended' | + 'disabled' }, /** * Configuration for Api Security @@ -752,7 +764,7 @@ declare namespace tracer { */ maxDepth?: number } - + /** * Configuration enabling LLM Observability. Enablement is superceded by the DD_LLMOBS_ENABLED environment variable. */ @@ -2191,6 +2203,12 @@ declare namespace tracer { */ cookieFilterPattern?: string, + /** + * Defines the number of rows to taint in data coming from databases + * @default 1 + */ + dbRowsToTaint?: number, + /** * Whether to enable vulnerability deduplication */ @@ -2235,7 +2253,7 @@ declare namespace tracer { * Disable LLM Observability tracing. */ disable (): void, - + /** * Instruments a function by automatically creating a span activated on its * scope. @@ -2277,10 +2295,10 @@ declare namespace tracer { /** * Decorate a function in a javascript runtime that supports function decorators. * Note that this is **not** supported in the Node.js runtime, but is in TypeScript. - * + * * In TypeScript, this decorator is only supported in contexts where general TypeScript * function decorators are supported. - * + * * @param options Optional LLM Observability span options. */ decorate (options: llmobs.LLMObsNamelessSpanOptions): any @@ -2297,7 +2315,7 @@ declare namespace tracer { /** * Sets inputs, outputs, tags, metadata, and metrics as provided for a given LLM Observability span. * Note that with the exception of tags, this method will override any existing values for the provided fields. - * + * * For example: * ```javascript * llmobs.trace({ kind: 'llm', name: 'myLLM', modelName: 'gpt-4o', modelProvider: 'openai' }, () => { @@ -2310,7 +2328,7 @@ declare namespace tracer { * }) * }) * ``` - * + * * @param span The span to annotate (defaults to the current LLM Observability span if not provided) * @param options An object containing the inputs, outputs, tags, metadata, and metrics to set on the span. */ @@ -2486,14 +2504,14 @@ declare namespace tracer { * LLM Observability span kind. One of `agent`, `workflow`, `task`, `tool`, `retrieval`, `embedding`, or `llm`. */ kind: llmobs.spanKind, - + /** * The ID of the underlying user session. Required for tracking sessions. */ sessionId?: string, /** - * The name of the ML application that the agent is orchestrating. + * The name of the ML application that the agent is orchestrating. * If not provided, the default value will be set to mlApp provided during initalization, or `DD_LLMOBS_ML_APP`. */ mlApp?: string, diff --git a/init.js b/init.js index d9286b0307f..625d493b3b1 100644 --- a/init.js +++ b/init.js @@ -2,72 +2,8 @@ /* eslint-disable no-var */ -var nodeVersion = require('./version') -var NODE_MAJOR = nodeVersion.NODE_MAJOR -var NODE_MINOR = nodeVersion.NODE_MINOR +var guard = require('./packages/dd-trace/src/guardrails') -// We use several things that are not supported by older versions of Node: -// - AsyncLocalStorage -// - The `semver` module -// - dc-polyfill -// - Mocha (for testing) -// and probably others. -// TODO: Remove all these dependencies so that we can report telemetry. -if ((NODE_MAJOR === 12 && NODE_MINOR >= 17) || NODE_MAJOR > 12) { - var path = require('path') - var Module = require('module') - var semver = require('semver') - var log = require('./packages/dd-trace/src/log') - var isTrue = require('./packages/dd-trace/src/util').isTrue - var telemetry = require('./packages/dd-trace/src/telemetry/init-telemetry') - - var initBailout = false - var clobberBailout = false - var forced = isTrue(process.env.DD_INJECT_FORCE) - - if (process.env.DD_INJECTION_ENABLED) { - // If we're running via single-step install, and we're not in the app's - // node_modules, then we should not initialize the tracer. This prevents - // single-step-installed tracer from clobbering the manually-installed tracer. - var resolvedInApp - var entrypoint = process.argv[1] - try { - resolvedInApp = Module.createRequire(entrypoint).resolve('dd-trace') - } catch (e) { - // Ignore. If we can't resolve the module, we assume it's not in the app. - } - if (resolvedInApp) { - var ourselves = path.join(__dirname, 'index.js') - if (ourselves !== resolvedInApp) { - clobberBailout = true - } - } - - // If we're running via single-step install, and the runtime doesn't match - // the engines field in package.json, then we should not initialize the tracer. - if (!clobberBailout) { - var engines = require('./package.json').engines - var version = process.versions.node - if (!semver.satisfies(version, engines.node)) { - initBailout = true - telemetry([ - { name: 'abort', tags: ['reason:incompatible_runtime'] }, - { name: 'abort.runtime', tags: [] } - ]) - log.info('Aborting application instrumentation due to incompatible_runtime.') - log.info('Found incompatible runtime nodejs ' + version + ', Supported runtimes: nodejs ' + engines.node + '.') - if (forced) { - log.info('DD_INJECT_FORCE enabled, allowing unsupported runtimes and continuing.') - } - } - } - } - - if (!clobberBailout && (!initBailout || forced)) { - var tracer = require('.') - tracer.init() - module.exports = tracer - telemetry('complete', ['injection_forced:' + (forced && initBailout ? 'true' : 'false')]) - log.info('Application instrumentation bootstrapping complete') - } -} +module.exports = guard(function () { + return require('.').init() +}) diff --git a/integration-tests/ci-visibility-intake.js b/integration-tests/ci-visibility-intake.js index c133a7a31fe..f08f1a24ecd 100644 --- a/integration-tests/ci-visibility-intake.js +++ b/integration-tests/ci-visibility-intake.js @@ -25,7 +25,7 @@ const DEFAULT_SUITES_TO_SKIP = [] const DEFAULT_GIT_UPLOAD_STATUS = 200 const DEFAULT_KNOWN_TESTS_UPLOAD_STATUS = 200 const DEFAULT_INFO_RESPONSE = { - endpoints: ['/evp_proxy/v2'] + endpoints: ['/evp_proxy/v2', '/debugger/v1/input'] } const DEFAULT_CORRELATION_ID = '1234' const DEFAULT_KNOWN_TESTS = ['test-suite1.js.test-name1', 'test-suite2.js.test-name2'] @@ -208,7 +208,10 @@ class FakeCiVisIntake extends FakeAgent { }) }) - app.post('/api/v2/logs', express.json(), (req, res) => { + app.post([ + '/api/v2/logs', + '/debugger/v1/input' + ], express.json(), (req, res) => { res.status(200).send('OK') this.emit('message', { headers: req.headers, diff --git a/integration-tests/ci-visibility/dynamic-instrumentation/dependency.js b/integration-tests/ci-visibility/dynamic-instrumentation/dependency.js new file mode 100644 index 00000000000..b53ebf22f97 --- /dev/null +++ b/integration-tests/ci-visibility/dynamic-instrumentation/dependency.js @@ -0,0 +1,7 @@ +module.exports = function (a, b) { + const localVariable = 2 + if (a > 10) { + throw new Error('a is too big') + } + return a + b + localVariable - localVariable +} diff --git a/integration-tests/ci-visibility/dynamic-instrumentation/is-jest.js b/integration-tests/ci-visibility/dynamic-instrumentation/is-jest.js new file mode 100644 index 00000000000..483b2a543d3 --- /dev/null +++ b/integration-tests/ci-visibility/dynamic-instrumentation/is-jest.js @@ -0,0 +1,7 @@ +module.exports = function () { + try { + return typeof jest !== 'undefined' + } catch (e) { + return false + } +} diff --git a/integration-tests/ci-visibility/dynamic-instrumentation/test-hit-breakpoint.js b/integration-tests/ci-visibility/dynamic-instrumentation/test-hit-breakpoint.js new file mode 100644 index 00000000000..ed2e3d14e51 --- /dev/null +++ b/integration-tests/ci-visibility/dynamic-instrumentation/test-hit-breakpoint.js @@ -0,0 +1,22 @@ +/* eslint-disable */ +const sum = require('./dependency') +const isJest = require('./is-jest') +const { expect } = require('chai') + +// TODO: instead of retrying through jest, this should be retried with auto test retries +if (isJest()) { + jest.retryTimes(1) +} + +describe('dynamic-instrumentation', () => { + it('retries with DI', function () { + if (this.retries) { + this.retries(1) + } + expect(sum(11, 3)).to.equal(14) + }) + + it('is not retried', () => { + expect(1 + 2).to.equal(3) + }) +}) diff --git a/integration-tests/ci-visibility/dynamic-instrumentation/test-not-hit-breakpoint.js b/integration-tests/ci-visibility/dynamic-instrumentation/test-not-hit-breakpoint.js new file mode 100644 index 00000000000..7960852a52c --- /dev/null +++ b/integration-tests/ci-visibility/dynamic-instrumentation/test-not-hit-breakpoint.js @@ -0,0 +1,24 @@ +/* eslint-disable */ +const sum = require('./dependency') +const isJest = require('./is-jest') +const { expect } = require('chai') + +// TODO: instead of retrying through jest, this should be retried with auto test retries +if (isJest()) { + jest.retryTimes(1) +} + +let count = 0 +describe('dynamic-instrumentation', () => { + it('retries with DI', function () { + if (this.retries) { + this.retries(1) + } + const willFail = count++ === 0 + if (willFail) { + expect(sum(11, 3)).to.equal(14) // only throws the first time + } else { + expect(sum(1, 2)).to.equal(3) + } + }) +}) diff --git a/integration-tests/ci-visibility/features-di/support/steps.js b/integration-tests/ci-visibility/features-di/support/steps.js new file mode 100644 index 00000000000..00880f83467 --- /dev/null +++ b/integration-tests/ci-visibility/features-di/support/steps.js @@ -0,0 +1,24 @@ +const assert = require('assert') +const { When, Then } = require('@cucumber/cucumber') +const sum = require('./sum') + +let count = 0 + +When('the greeter says hello', function () { + this.whatIHeard = 'hello' +}) + +Then('I should have heard {string}', function (expectedResponse) { + sum(11, 3) + assert.equal(this.whatIHeard, expectedResponse) +}) + +Then('I should have flakily heard {string}', function (expectedResponse) { + const shouldFail = count++ < 1 + if (shouldFail) { + sum(11, 3) + } else { + sum(1, 3) // does not hit the breakpoint the second time + } + assert.equal(this.whatIHeard, expectedResponse) +}) diff --git a/integration-tests/ci-visibility/features-di/support/sum.js b/integration-tests/ci-visibility/features-di/support/sum.js new file mode 100644 index 00000000000..cb1d7adb951 --- /dev/null +++ b/integration-tests/ci-visibility/features-di/support/sum.js @@ -0,0 +1,10 @@ +function funSum (a, b) { + const localVariable = 2 + if (a > 10) { + throw new Error('the number is too big') + } + + return a + b + localVariable +} + +module.exports = funSum diff --git a/integration-tests/ci-visibility/features-di/test-hit-breakpoint.feature b/integration-tests/ci-visibility/features-di/test-hit-breakpoint.feature new file mode 100644 index 00000000000..06ef560af61 --- /dev/null +++ b/integration-tests/ci-visibility/features-di/test-hit-breakpoint.feature @@ -0,0 +1,6 @@ + +Feature: Greeting + + Scenario: Say hello + When the greeter says hello + Then I should have heard "hello" diff --git a/integration-tests/ci-visibility/features-di/test-not-hit-breakpoint.feature b/integration-tests/ci-visibility/features-di/test-not-hit-breakpoint.feature new file mode 100644 index 00000000000..ca5562b55c0 --- /dev/null +++ b/integration-tests/ci-visibility/features-di/test-not-hit-breakpoint.feature @@ -0,0 +1,6 @@ + +Feature: Greeting + + Scenario: Say hello + When the greeter says hello + Then I should have flakily heard "hello" diff --git a/integration-tests/ci-visibility/features-esm/support/steps.mjs b/integration-tests/ci-visibility/features-esm/support/steps.mjs index 64194a68684..66d05584383 100644 --- a/integration-tests/ci-visibility/features-esm/support/steps.mjs +++ b/integration-tests/ci-visibility/features-esm/support/steps.mjs @@ -5,12 +5,15 @@ class Greeter { sayFarewell () { return 'farewell' } + sayGreetings () { return 'greetings' } + sayYo () { return 'yo' } + sayYeah () { return 'yeah whatever' } diff --git a/integration-tests/ci-visibility/office-addin-mock/dependency.js b/integration-tests/ci-visibility/office-addin-mock/dependency.js new file mode 100644 index 00000000000..363131a422a --- /dev/null +++ b/integration-tests/ci-visibility/office-addin-mock/dependency.js @@ -0,0 +1,7 @@ +require('office-addin-mock') + +function sum (a, b) { + return a + b +} + +module.exports = sum diff --git a/integration-tests/ci-visibility/office-addin-mock/test.js b/integration-tests/ci-visibility/office-addin-mock/test.js new file mode 100644 index 00000000000..50a3b6c2e28 --- /dev/null +++ b/integration-tests/ci-visibility/office-addin-mock/test.js @@ -0,0 +1,6 @@ +const sum = require('./dependency') +const { expect } = require('chai') + +test('can sum', () => { + expect(sum(1, 2)).to.equal(3) +}) diff --git a/integration-tests/ci-visibility/playwright-tests/landing-page-test.js b/integration-tests/ci-visibility/playwright-tests/landing-page-test.js index 4e05a904176..7ee22886c7b 100644 --- a/integration-tests/ci-visibility/playwright-tests/landing-page-test.js +++ b/integration-tests/ci-visibility/playwright-tests/landing-page-test.js @@ -4,29 +4,34 @@ test.beforeEach(async ({ page }) => { await page.goto(process.env.PW_BASE_URL) }) -test.describe('playwright', () => { - test('should work with passing tests', async ({ page }) => { - await expect(page.locator('.hello-world')).toHaveText([ - 'Hello World' - ]) - }) - test.skip('should work with skipped tests', async ({ page }) => { - await expect(page.locator('.hello-world')).toHaveText([ - 'Hello World' - ]) - }) - test.fixme('should work with fixme', async ({ page }) => { - await expect(page.locator('.hello-world')).toHaveText([ - 'Hello Warld' - ]) - }) - test('should work with annotated tests', async ({ page }) => { - test.info().annotations.push({ type: 'DD_TAGS[test.memory.usage]', description: 'low' }) - test.info().annotations.push({ type: 'DD_TAGS[test.memory.allocations]', description: 16 }) - // this is malformed and should be ignored - test.info().annotations.push({ type: 'DD_TAGS[test.invalid', description: 'high' }) - await expect(page.locator('.hello-world')).toHaveText([ - 'Hello World' - ]) +test.describe('highest-level-describe', () => { + test.describe(' leading and trailing spaces ', () => { + // even empty describe blocks should be allowed + test.describe(' ', () => { + test('should work with passing tests', async ({ page }) => { + await expect(page.locator('.hello-world')).toHaveText([ + 'Hello World' + ]) + }) + test.skip('should work with skipped tests', async ({ page }) => { + await expect(page.locator('.hello-world')).toHaveText([ + 'Hello World' + ]) + }) + test.fixme('should work with fixme', async ({ page }) => { + await expect(page.locator('.hello-world')).toHaveText([ + 'Hello Warld' + ]) + }) + test('should work with annotated tests', async ({ page }) => { + test.info().annotations.push({ type: 'DD_TAGS[test.memory.usage]', description: 'low' }) + test.info().annotations.push({ type: 'DD_TAGS[test.memory.allocations]', description: 16 }) + // this is malformed and should be ignored + test.info().annotations.push({ type: 'DD_TAGS[test.invalid', description: 'high' }) + await expect(page.locator('.hello-world')).toHaveText([ + 'Hello World' + ]) + }) + }) }) }) diff --git a/integration-tests/ci-visibility/vitest-tests/bad-sum.mjs b/integration-tests/ci-visibility/vitest-tests/bad-sum.mjs new file mode 100644 index 00000000000..809a131c8d3 --- /dev/null +++ b/integration-tests/ci-visibility/vitest-tests/bad-sum.mjs @@ -0,0 +1,7 @@ +export function sum (a, b) { + const localVar = 10 + if (a > 10) { + throw new Error('a is too large') + } + return a + b + localVar - localVar +} diff --git a/integration-tests/ci-visibility/vitest-tests/breakpoint-not-hit.mjs b/integration-tests/ci-visibility/vitest-tests/breakpoint-not-hit.mjs new file mode 100644 index 00000000000..33c9bca09c5 --- /dev/null +++ b/integration-tests/ci-visibility/vitest-tests/breakpoint-not-hit.mjs @@ -0,0 +1,18 @@ +import { describe, test, expect } from 'vitest' +import { sum } from './bad-sum' + +let numAttempt = 0 + +describe('dynamic instrumentation', () => { + test('can sum', () => { + const shouldFail = numAttempt++ === 0 + if (shouldFail) { + expect(sum(11, 2)).to.equal(13) + } else { + expect(sum(1, 2)).to.equal(3) + } + }) + test('is not retried', () => { + expect(sum(1, 2)).to.equal(3) + }) +}) diff --git a/integration-tests/ci-visibility/vitest-tests/dynamic-instrumentation.mjs b/integration-tests/ci-visibility/vitest-tests/dynamic-instrumentation.mjs new file mode 100644 index 00000000000..1e2bb73352d --- /dev/null +++ b/integration-tests/ci-visibility/vitest-tests/dynamic-instrumentation.mjs @@ -0,0 +1,11 @@ +import { describe, test, expect } from 'vitest' +import { sum } from './bad-sum' + +describe('dynamic instrumentation', () => { + test('can sum', () => { + expect(sum(11, 2)).to.equal(13) + }) + test('is not retried', () => { + expect(sum(1, 2)).to.equal(3) + }) +}) diff --git a/integration-tests/cucumber/cucumber.spec.js b/integration-tests/cucumber/cucumber.spec.js index d7fd132caf7..f7925210a87 100644 --- a/integration-tests/cucumber/cucumber.spec.js +++ b/integration-tests/cucumber/cucumber.spec.js @@ -37,7 +37,11 @@ const { TEST_SUITE, TEST_CODE_OWNERS, TEST_SESSION_NAME, - TEST_LEVEL_EVENT_TYPES + TEST_LEVEL_EVENT_TYPES, + DI_ERROR_DEBUG_INFO_CAPTURED, + DI_DEBUG_ERROR_FILE, + DI_DEBUG_ERROR_SNAPSHOT_ID, + DI_DEBUG_ERROR_LINE } = require('../../packages/dd-trace/src/plugins/util/test') const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/env') @@ -86,10 +90,11 @@ versions.forEach(version => { reportMethods.forEach((reportMethod) => { context(`reporting via ${reportMethod}`, () => { - let envVars, isAgentless + let envVars, isAgentless, logsEndpoint beforeEach(() => { isAgentless = reportMethod === 'agentless' envVars = isAgentless ? getCiVisAgentlessConfig(receiver.port) : getCiVisEvpProxyConfig(receiver.port) + logsEndpoint = isAgentless ? '/api/v2/logs' : '/debugger/v1/input' }) const runModes = ['serial'] @@ -1536,6 +1541,234 @@ versions.forEach(version => { }) }) }) + // Dynamic instrumentation only supported from >=8.0.0 + context('dynamic instrumentation', () => { + it('does not activate if DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED is not set', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: true + }) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.notProperty(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_FILE) + assert.notProperty(retriedTest.metrics, DI_DEBUG_ERROR_LINE) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_SNAPSHOT_ID) + }) + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url === logsEndpoint, (payloads) => { + if (payloads.length > 0) { + throw new Error('Unexpected logs') + } + }, 5000) + + childProcess = exec( + './node_modules/.bin/cucumber-js ci-visibility/features-di/test-hit-breakpoint.feature --retry 1', + { + cwd, + env: envVars, + stdio: 'pipe' + } + ) + + childProcess.on('exit', () => { + Promise.all([eventsPromise, logsPromise]).then(() => { + done() + }).catch(done) + }) + }) + + it('does not activate dynamic instrumentation if remote settings are disabled', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: false + }) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.notProperty(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_FILE) + assert.notProperty(retriedTest.metrics, DI_DEBUG_ERROR_LINE) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_SNAPSHOT_ID) + }) + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url === logsEndpoint, (payloads) => { + if (payloads.length > 0) { + throw new Error('Unexpected logs') + } + }, 5000) + + childProcess = exec( + './node_modules/.bin/cucumber-js ci-visibility/features-di/test-hit-breakpoint.feature --retry 1', + { + cwd, + env: { + ...envVars, + DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true' + }, + stdio: 'pipe' + } + ) + + childProcess.on('exit', () => { + Promise.all([eventsPromise, logsPromise]).then(() => { + done() + }).catch(done) + }) + }) + + it('runs retries with dynamic instrumentation', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: true + }) + + let snapshotIdByTest, snapshotIdByLog + let spanIdByTest, spanIdByLog, traceIdByTest, traceIdByLog + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + assert.propertyVal( + retriedTest.meta, + DI_DEBUG_ERROR_FILE, + 'ci-visibility/features-di/support/sum.js' + ) + assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4) + assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID]) + + snapshotIdByTest = retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID] + spanIdByTest = retriedTest.span_id.toString() + traceIdByTest = retriedTest.trace_id.toString() + }) + + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url === logsEndpoint, (payloads) => { + const [{ logMessage: [diLog] }] = payloads + assert.deepInclude(diLog, { + ddsource: 'dd_debugger', + level: 'error' + }) + assert.equal(diLog.debugger.snapshot.language, 'javascript') + assert.deepInclude(diLog.debugger.snapshot.captures.lines['4'].locals, { + a: { + type: 'number', + value: '11' + }, + b: { + type: 'number', + value: '3' + }, + localVariable: { + type: 'number', + value: '2' + } + }) + spanIdByLog = diLog.dd.span_id + traceIdByLog = diLog.dd.trace_id + snapshotIdByLog = diLog.debugger.snapshot.id + }) + + childProcess = exec( + './node_modules/.bin/cucumber-js ci-visibility/features-di/test-hit-breakpoint.feature --retry 1', + { + cwd, + env: { + ...envVars, + DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true' + }, + stdio: 'pipe' + } + ) + + childProcess.on('exit', () => { + Promise.all([eventsPromise, logsPromise]).then(() => { + assert.equal(snapshotIdByTest, snapshotIdByLog) + assert.equal(spanIdByTest, spanIdByLog) + assert.equal(traceIdByTest, traceIdByLog) + done() + }).catch(done) + }) + }) + + it('does not crash if the retry does not hit the breakpoint', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: true + }) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + assert.propertyVal( + retriedTest.meta, + DI_DEBUG_ERROR_FILE, + 'ci-visibility/features-di/support/sum.js' + ) + assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4) + assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID]) + }) + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + if (payloads.length > 0) { + throw new Error('Unexpected logs') + } + }, 5000) + + childProcess = exec( + './node_modules/.bin/cucumber-js ci-visibility/features-di/test-not-hit-breakpoint.feature --retry 1', + { + cwd, + env: { + ...envVars, + DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true' + }, + stdio: 'pipe' + } + ) + + childProcess.on('exit', (exitCode) => { + Promise.all([eventsPromise, logsPromise]).then(() => { + assert.equal(exitCode, 0) + done() + }).catch(done) + }) + }) + }) } }) }) diff --git a/integration-tests/debugger/basic.spec.js b/integration-tests/debugger/basic.spec.js index 3330a6c32d3..aa6a1881d33 100644 --- a/integration-tests/debugger/basic.spec.js +++ b/integration-tests/debugger/basic.spec.js @@ -9,387 +9,552 @@ const { ACKNOWLEDGED, ERROR } = require('../../packages/dd-trace/src/appsec/remo const { version } = require('../../package.json') describe('Dynamic Instrumentation', function () { - const t = setup() + describe('Default env', function () { + const t = setup() - it('base case: target app should work as expected if no test probe has been added', async function () { - const response = await t.axios.get('/foo') - assert.strictEqual(response.status, 200) - assert.deepStrictEqual(response.data, { hello: 'foo' }) - }) + it('base case: target app should work as expected if no test probe has been added', async function () { + const response = await t.axios.get(t.breakpoint.url) + assert.strictEqual(response.status, 200) + assert.deepStrictEqual(response.data, { hello: 'bar' }) + }) - describe('diagnostics messages', function () { - it('should send expected diagnostics messages if probe is received and triggered', function (done) { - let receivedAckUpdate = false - const probeId = t.rcConfig.config.id - const expectedPayloads = [{ - ddsource: 'dd_debugger', - service: 'node', - debugger: { diagnostics: { probeId, version: 0, status: 'RECEIVED' } } - }, { - ddsource: 'dd_debugger', - service: 'node', - debugger: { diagnostics: { probeId, version: 0, status: 'INSTALLED' } } - }, { - ddsource: 'dd_debugger', - service: 'node', - debugger: { diagnostics: { probeId, version: 0, status: 'EMITTING' } } - }] - - t.agent.on('remote-config-ack-update', (id, version, state, error) => { - assert.strictEqual(id, t.rcConfig.id) - assert.strictEqual(version, 1) - assert.strictEqual(state, ACKNOWLEDGED) - assert.notOk(error) // falsy check since error will be an empty string, but that's an implementation detail - - receivedAckUpdate = true - endIfDone() - }) + describe('diagnostics messages', function () { + it('should send expected diagnostics messages if probe is received and triggered', function (done) { + let receivedAckUpdate = false + const probeId = t.rcConfig.config.id + const expectedPayloads = [{ + ddsource: 'dd_debugger', + service: 'node', + debugger: { diagnostics: { probeId, probeVersion: 0, status: 'RECEIVED' } } + }, { + ddsource: 'dd_debugger', + service: 'node', + debugger: { diagnostics: { probeId, probeVersion: 0, status: 'INSTALLED' } } + }, { + ddsource: 'dd_debugger', + service: 'node', + debugger: { diagnostics: { probeId, probeVersion: 0, status: 'EMITTING' } } + }] - t.agent.on('debugger-diagnostics', ({ payload }) => { - const expected = expectedPayloads.shift() - assertObjectContains(payload, expected) - assertUUID(payload.debugger.diagnostics.runtimeId) + t.agent.on('remote-config-ack-update', (id, version, state, error) => { + assert.strictEqual(id, t.rcConfig.id) + assert.strictEqual(version, 1) + assert.strictEqual(state, ACKNOWLEDGED) + assert.notOk(error) // falsy check since error will be an empty string, but that's an implementation detail - if (payload.debugger.diagnostics.status === 'INSTALLED') { - t.axios.get('/foo') - .then((response) => { - assert.strictEqual(response.status, 200) - assert.deepStrictEqual(response.data, { hello: 'foo' }) - }) - .catch(done) - } else { + receivedAckUpdate = true endIfDone() - } - }) + }) - t.agent.addRemoteConfig(t.rcConfig) + t.agent.on('debugger-diagnostics', ({ payload }) => { + const expected = expectedPayloads.shift() + assertObjectContains(payload, expected) + assertUUID(payload.debugger.diagnostics.runtimeId) - function endIfDone () { - if (receivedAckUpdate && expectedPayloads.length === 0) done() - } - }) + if (payload.debugger.diagnostics.status === 'INSTALLED') { + t.axios.get(t.breakpoint.url) + .then((response) => { + assert.strictEqual(response.status, 200) + assert.deepStrictEqual(response.data, { hello: 'bar' }) + }) + .catch(done) + } else { + endIfDone() + } + }) - it('should send expected diagnostics messages if probe is first received and then updated', function (done) { - let receivedAckUpdates = 0 - const probeId = t.rcConfig.config.id - const expectedPayloads = [{ - ddsource: 'dd_debugger', - service: 'node', - debugger: { diagnostics: { probeId, version: 0, status: 'RECEIVED' } } - }, { - ddsource: 'dd_debugger', - service: 'node', - debugger: { diagnostics: { probeId, version: 0, status: 'INSTALLED' } } - }, { - ddsource: 'dd_debugger', - service: 'node', - debugger: { diagnostics: { probeId, version: 1, status: 'RECEIVED' } } - }, { - ddsource: 'dd_debugger', - service: 'node', - debugger: { diagnostics: { probeId, version: 1, status: 'INSTALLED' } } - }] - const triggers = [ - () => { - t.rcConfig.config.version++ - t.agent.updateRemoteConfig(t.rcConfig.id, t.rcConfig.config) - }, - () => {} - ] - - t.agent.on('remote-config-ack-update', (id, version, state, error) => { - assert.strictEqual(id, t.rcConfig.id) - assert.strictEqual(version, ++receivedAckUpdates) - assert.strictEqual(state, ACKNOWLEDGED) - assert.notOk(error) // falsy check since error will be an empty string, but that's an implementation detail - - endIfDone() - }) + t.agent.addRemoteConfig(t.rcConfig) - t.agent.on('debugger-diagnostics', ({ payload }) => { - const expected = expectedPayloads.shift() - assertObjectContains(payload, expected) - assertUUID(payload.debugger.diagnostics.runtimeId) - if (payload.debugger.diagnostics.status === 'INSTALLED') triggers.shift()() - endIfDone() + function endIfDone () { + if (receivedAckUpdate && expectedPayloads.length === 0) done() + } }) - t.agent.addRemoteConfig(t.rcConfig) + it('should send expected diagnostics messages if probe is first received and then updated', function (done) { + let receivedAckUpdates = 0 + const probeId = t.rcConfig.config.id + const expectedPayloads = [{ + ddsource: 'dd_debugger', + service: 'node', + debugger: { diagnostics: { probeId, probeVersion: 0, status: 'RECEIVED' } } + }, { + ddsource: 'dd_debugger', + service: 'node', + debugger: { diagnostics: { probeId, probeVersion: 0, status: 'INSTALLED' } } + }, { + ddsource: 'dd_debugger', + service: 'node', + debugger: { diagnostics: { probeId, probeVersion: 1, status: 'RECEIVED' } } + }, { + ddsource: 'dd_debugger', + service: 'node', + debugger: { diagnostics: { probeId, probeVersion: 1, status: 'INSTALLED' } } + }] + const triggers = [ + () => { + t.rcConfig.config.version++ + t.agent.updateRemoteConfig(t.rcConfig.id, t.rcConfig.config) + }, + () => {} + ] - function endIfDone () { - if (receivedAckUpdates === 2 && expectedPayloads.length === 0) done() - } - }) + t.agent.on('remote-config-ack-update', (id, version, state, error) => { + assert.strictEqual(id, t.rcConfig.id) + assert.strictEqual(version, ++receivedAckUpdates) + assert.strictEqual(state, ACKNOWLEDGED) + assert.notOk(error) // falsy check since error will be an empty string, but that's an implementation detail - it('should send expected diagnostics messages if probe is first received and then deleted', function (done) { - let receivedAckUpdate = false - let payloadsProcessed = false - const probeId = t.rcConfig.config.id - const expectedPayloads = [{ - ddsource: 'dd_debugger', - service: 'node', - debugger: { diagnostics: { probeId, version: 0, status: 'RECEIVED' } } - }, { - ddsource: 'dd_debugger', - service: 'node', - debugger: { diagnostics: { probeId, version: 0, status: 'INSTALLED' } } - }] - - t.agent.on('remote-config-ack-update', (id, version, state, error) => { - assert.strictEqual(id, t.rcConfig.id) - assert.strictEqual(version, 1) - assert.strictEqual(state, ACKNOWLEDGED) - assert.notOk(error) // falsy check since error will be an empty string, but that's an implementation detail - - receivedAckUpdate = true - endIfDone() - }) + endIfDone() + }) - t.agent.on('debugger-diagnostics', ({ payload }) => { - const expected = expectedPayloads.shift() - assertObjectContains(payload, expected) - assertUUID(payload.debugger.diagnostics.runtimeId) + t.agent.on('debugger-diagnostics', ({ payload }) => { + const expected = expectedPayloads.shift() + assertObjectContains(payload, expected) + assertUUID(payload.debugger.diagnostics.runtimeId) + if (payload.debugger.diagnostics.status === 'INSTALLED') triggers.shift()() + endIfDone() + }) - if (payload.debugger.diagnostics.status === 'INSTALLED') { - t.agent.removeRemoteConfig(t.rcConfig.id) - // Wait a little to see if we get any follow-up `debugger-diagnostics` messages - setTimeout(() => { - payloadsProcessed = true - endIfDone() - }, pollInterval * 2 * 1000) // wait twice as long as the RC poll interval + t.agent.addRemoteConfig(t.rcConfig) + + function endIfDone () { + if (receivedAckUpdates === 2 && expectedPayloads.length === 0) done() } }) - t.agent.addRemoteConfig(t.rcConfig) - - function endIfDone () { - if (receivedAckUpdate && payloadsProcessed) done() - } - }) - - const unsupporedOrInvalidProbes = [[ - 'should send expected error diagnostics messages if probe doesn\'t conform to expected schema', - 'bad config!!!', - { status: 'ERROR' } - ], [ - 'should send expected error diagnostics messages if probe type isn\'t supported', - t.generateProbeConfig({ type: 'INVALID_PROBE' }) - ], [ - 'should send expected error diagnostics messages if it isn\'t a line-probe', - t.generateProbeConfig({ where: { foo: 'bar' } }) // TODO: Use valid schema for method probe instead - ]] - - for (const [title, config, customErrorDiagnosticsObj] of unsupporedOrInvalidProbes) { - it(title, function (done) { + it('should send expected diagnostics messages if probe is first received and then deleted', function (done) { let receivedAckUpdate = false - - t.agent.on('remote-config-ack-update', (id, version, state, error) => { - assert.strictEqual(id, `logProbe_${config.id}`) - assert.strictEqual(version, 1) - assert.strictEqual(state, ERROR) - assert.strictEqual(error.slice(0, 6), 'Error:') - - receivedAckUpdate = true - endIfDone() - }) - - const probeId = config.id + let payloadsProcessed = false + const probeId = t.rcConfig.config.id const expectedPayloads = [{ ddsource: 'dd_debugger', service: 'node', - debugger: { diagnostics: { status: 'RECEIVED' } } + debugger: { diagnostics: { probeId, probeVersion: 0, status: 'RECEIVED' } } }, { ddsource: 'dd_debugger', service: 'node', - debugger: { diagnostics: customErrorDiagnosticsObj ?? { probeId, version: 0, status: 'ERROR' } } + debugger: { diagnostics: { probeId, probeVersion: 0, status: 'INSTALLED' } } }] + t.agent.on('remote-config-ack-update', (id, version, state, error) => { + assert.strictEqual(id, t.rcConfig.id) + assert.strictEqual(version, 1) + assert.strictEqual(state, ACKNOWLEDGED) + assert.notOk(error) // falsy check since error will be an empty string, but that's an implementation detail + + receivedAckUpdate = true + endIfDone() + }) + t.agent.on('debugger-diagnostics', ({ payload }) => { const expected = expectedPayloads.shift() assertObjectContains(payload, expected) - const { diagnostics } = payload.debugger - assertUUID(diagnostics.runtimeId) - - if (diagnostics.status === 'ERROR') { - assert.property(diagnostics, 'exception') - assert.hasAllKeys(diagnostics.exception, ['message', 'stacktrace']) - assert.typeOf(diagnostics.exception.message, 'string') - assert.typeOf(diagnostics.exception.stacktrace, 'string') - } + assertUUID(payload.debugger.diagnostics.runtimeId) - endIfDone() + if (payload.debugger.diagnostics.status === 'INSTALLED') { + t.agent.removeRemoteConfig(t.rcConfig.id) + // Wait a little to see if we get any follow-up `debugger-diagnostics` messages + setTimeout(() => { + payloadsProcessed = true + endIfDone() + }, pollInterval * 2 * 1000) // wait twice as long as the RC poll interval + } }) - t.agent.addRemoteConfig({ - product: 'LIVE_DEBUGGING', - id: `logProbe_${config.id}`, - config - }) + t.agent.addRemoteConfig(t.rcConfig) function endIfDone () { - if (receivedAckUpdate && expectedPayloads.length === 0) done() + if (receivedAckUpdate && payloadsProcessed) done() } }) - } - }) - describe('input messages', function () { - it('should capture and send expected payload when a log line probe is triggered', function (done) { - t.triggerBreakpoint() + const unsupporedOrInvalidProbes = [[ + 'should send expected error diagnostics messages if probe doesn\'t conform to expected schema', + 'bad config!!!', + { status: 'ERROR' } + ], [ + 'should send expected error diagnostics messages if probe type isn\'t supported', + t.generateProbeConfig({ type: 'INVALID_PROBE' }) + ], [ + 'should send expected error diagnostics messages if it isn\'t a line-probe', + t.generateProbeConfig({ where: { foo: 'bar' } }) // TODO: Use valid schema for method probe instead + ]] + + for (const [title, config, customErrorDiagnosticsObj] of unsupporedOrInvalidProbes) { + it(title, function (done) { + let receivedAckUpdate = false + + t.agent.on('remote-config-ack-update', (id, version, state, error) => { + assert.strictEqual(id, `logProbe_${config.id}`) + assert.strictEqual(version, 1) + assert.strictEqual(state, ERROR) + assert.strictEqual(error.slice(0, 6), 'Error:') + + receivedAckUpdate = true + endIfDone() + }) - t.agent.on('debugger-input', ({ payload }) => { - const expected = { - ddsource: 'dd_debugger', - hostname: os.hostname(), - service: 'node', - message: 'Hello World!', - logger: { - name: t.breakpoint.file, - method: 'handler', - version, - thread_name: 'MainThread' + const probeId = config.id + const expectedPayloads = [{ + ddsource: 'dd_debugger', + service: 'node', + debugger: { diagnostics: { status: 'RECEIVED' } } + }, { + ddsource: 'dd_debugger', + service: 'node', + debugger: { diagnostics: customErrorDiagnosticsObj ?? { probeId, probeVersion: 0, status: 'ERROR' } } + }] + + t.agent.on('debugger-diagnostics', ({ payload }) => { + const expected = expectedPayloads.shift() + assertObjectContains(payload, expected) + const { diagnostics } = payload.debugger + assertUUID(diagnostics.runtimeId) + + if (diagnostics.status === 'ERROR') { + assert.property(diagnostics, 'exception') + assert.hasAllKeys(diagnostics.exception, ['message', 'stacktrace']) + assert.typeOf(diagnostics.exception.message, 'string') + assert.typeOf(diagnostics.exception.stacktrace, 'string') + } + + endIfDone() + }) + + t.agent.addRemoteConfig({ + product: 'LIVE_DEBUGGING', + id: `logProbe_${config.id}`, + config + }) + + function endIfDone () { + if (receivedAckUpdate && expectedPayloads.length === 0) done() + } + }) + } + }) + + describe('input messages', function () { + it( + 'should capture and send expected payload when a log line probe is triggered', + testBasicInputWithDD.bind(null, t) + ) + + it('should respond with updated message if probe message is updated', function (done) { + const expectedMessages = ['Hello World!', 'Hello Updated World!'] + const triggers = [ + async () => { + await t.axios.get(t.breakpoint.url) + t.rcConfig.config.version++ + t.rcConfig.config.template = 'Hello Updated World!' + t.agent.updateRemoteConfig(t.rcConfig.id, t.rcConfig.config) }, - 'debugger.snapshot': { - probe: { - id: t.rcConfig.config.id, - version: 0, - location: { file: t.breakpoint.file, lines: [String(t.breakpoint.line)] } - }, - language: 'javascript' + async () => { + await t.axios.get(t.breakpoint.url) } - } + ] - assertObjectContains(payload, expected) - assert.match(payload.logger.thread_id, /^pid:\d+$/) - assertUUID(payload['debugger.snapshot'].id) - assert.isNumber(payload['debugger.snapshot'].timestamp) - assert.isTrue(payload['debugger.snapshot'].timestamp > Date.now() - 1000 * 60) - assert.isTrue(payload['debugger.snapshot'].timestamp <= Date.now()) - - assert.isArray(payload['debugger.snapshot'].stack) - assert.isAbove(payload['debugger.snapshot'].stack.length, 0) - for (const frame of payload['debugger.snapshot'].stack) { - assert.isObject(frame) - assert.hasAllKeys(frame, ['fileName', 'function', 'lineNumber', 'columnNumber']) - assert.isString(frame.fileName) - assert.isString(frame.function) - assert.isAbove(frame.lineNumber, 0) - assert.isAbove(frame.columnNumber, 0) - } - const topFrame = payload['debugger.snapshot'].stack[0] - // path seems to be prefeixed with `/private` on Mac - assert.match(topFrame.fileName, new RegExp(`${t.appFile}$`)) - assert.strictEqual(topFrame.function, 'handler') - assert.strictEqual(topFrame.lineNumber, t.breakpoint.line) - assert.strictEqual(topFrame.columnNumber, 3) - - done() + t.agent.on('debugger-diagnostics', ({ payload }) => { + if (payload.debugger.diagnostics.status === 'INSTALLED') triggers.shift()().catch(done) + }) + + t.agent.on('debugger-input', ({ payload }) => { + assert.strictEqual(payload.message, expectedMessages.shift()) + if (expectedMessages.length === 0) done() + }) + + t.agent.addRemoteConfig(t.rcConfig) }) - t.agent.addRemoteConfig(t.rcConfig) + it('should not trigger if probe is deleted', function (done) { + t.agent.on('debugger-diagnostics', ({ payload }) => { + if (payload.debugger.diagnostics.status === 'INSTALLED') { + t.agent.once('remote-confg-responded', async () => { + await t.axios.get(t.breakpoint.url) + // We want to wait enough time to see if the client triggers on the breakpoint so that the test can fail + // if it does, but not so long that the test times out. + // TODO: Is there some signal we can use instead of a timer? + setTimeout(done, pollInterval * 2 * 1000) // wait twice as long as the RC poll interval + }) + + t.agent.removeRemoteConfig(t.rcConfig.id) + } + }) + + t.agent.on('debugger-input', () => { + assert.fail('should not capture anything when the probe is deleted') + }) + + t.agent.addRemoteConfig(t.rcConfig) + }) }) - it('should respond with updated message if probe message is updated', function (done) { - const expectedMessages = ['Hello World!', 'Hello Updated World!'] - const triggers = [ - async () => { - await t.axios.get('/foo') - t.rcConfig.config.version++ - t.rcConfig.config.template = 'Hello Updated World!' - t.agent.updateRemoteConfig(t.rcConfig.id, t.rcConfig.config) - }, - async () => { - await t.axios.get('/foo') + describe('sampling', function () { + it('should respect sampling rate for single probe', function (done) { + let start, timer + let payloadsReceived = 0 + const rcConfig = t.generateRemoteConfig({ sampling: { snapshotsPerSecond: 1 } }) + + function triggerBreakpointContinuously () { + t.axios.get(t.breakpoint.url).catch(done) + timer = setTimeout(triggerBreakpointContinuously, 10) } - ] - t.agent.on('debugger-diagnostics', ({ payload }) => { - if (payload.debugger.diagnostics.status === 'INSTALLED') triggers.shift()().catch(done) - }) + t.agent.on('debugger-diagnostics', ({ payload }) => { + if (payload.debugger.diagnostics.status === 'INSTALLED') triggerBreakpointContinuously() + }) - t.agent.on('debugger-input', ({ payload }) => { - assert.strictEqual(payload.message, expectedMessages.shift()) - if (expectedMessages.length === 0) done() + t.agent.on('debugger-input', () => { + payloadsReceived++ + if (payloadsReceived === 1) { + start = Date.now() + } else if (payloadsReceived === 2) { + const duration = Date.now() - start + clearTimeout(timer) + + // Allow for a variance of -5/+50ms (time will tell if this is enough) + assert.isAbove(duration, 995) + assert.isBelow(duration, 1050) + + // Wait at least a full sampling period, to see if we get any more payloads + timer = setTimeout(done, 1250) + } else { + clearTimeout(timer) + done(new Error('Too many payloads received!')) + } + }) + + t.agent.addRemoteConfig(rcConfig) }) - t.agent.addRemoteConfig(t.rcConfig) - }) + it('should adhere to individual probes sample rate', function (done) { + const rcConfig1 = t.breakpoints[0].generateRemoteConfig({ sampling: { snapshotsPerSecond: 1 } }) + const rcConfig2 = t.breakpoints[1].generateRemoteConfig({ sampling: { snapshotsPerSecond: 1 } }) + const state = { + [rcConfig1.config.id]: { + payloadsReceived: 0, + tiggerBreakpointContinuously () { + t.axios.get(t.breakpoints[0].url).catch(done) + this.timer = setTimeout(this.tiggerBreakpointContinuously.bind(this), 10) + } + }, + [rcConfig2.config.id]: { + payloadsReceived: 0, + tiggerBreakpointContinuously () { + t.axios.get(t.breakpoints[1].url).catch(done) + this.timer = setTimeout(this.tiggerBreakpointContinuously.bind(this), 10) + } + } + } - it('should not trigger if probe is deleted', function (done) { - t.agent.on('debugger-diagnostics', async ({ payload }) => { - try { - if (payload.debugger.diagnostics.status === 'INSTALLED') { - t.agent.once('remote-confg-responded', async () => { - try { - await t.axios.get('/foo') - // We want to wait enough time to see if the client triggers on the breakpoint so that the test can fail - // if it does, but not so long that the test times out. - // TODO: Is there some signal we can use instead of a timer? - setTimeout(done, pollInterval * 2 * 1000) // wait twice as long as the RC poll interval - } catch (err) { - // Nessecary hack: Any errors thrown inside of an async function is invisible to Mocha unless the outer - // `it` callback is also `async` (which we can't do in this case since we rely on the `done` callback). - done(err) - } - }) + t.agent.on('debugger-diagnostics', ({ payload }) => { + const { probeId, status } = payload.debugger.diagnostics + if (status === 'INSTALLED') state[probeId].tiggerBreakpointContinuously() + }) - t.agent.removeRemoteConfig(t.rcConfig.id) + t.agent.on('debugger-input', ({ payload }) => { + const _state = state[payload['debugger.snapshot'].probe.id] + _state.payloadsReceived++ + if (_state.payloadsReceived === 1) { + _state.start = Date.now() + } else if (_state.payloadsReceived === 2) { + const duration = Date.now() - _state.start + clearTimeout(_state.timer) + + // Allow for a variance of -5/+50ms (time will tell if this is enough) + assert.isAbove(duration, 995) + assert.isBelow(duration, 1050) + + // Wait at least a full sampling period, to see if we get any more payloads + _state.timer = setTimeout(doneWhenCalledTwice, 1250) + } else { + clearTimeout(_state.timer) + done(new Error('Too many payloads received!')) } - } catch (err) { - // Nessecary hack: Any errors thrown inside of an async function is invisible to Mocha unless the outer `it` - // callback is also `async` (which we can't do in this case since we rely on the `done` callback). - done(err) + }) + + t.agent.addRemoteConfig(rcConfig1) + t.agent.addRemoteConfig(rcConfig2) + + function doneWhenCalledTwice () { + if (doneWhenCalledTwice.calledOnce) return done() + doneWhenCalledTwice.calledOnce = true } }) + }) + + describe('race conditions', function () { + it('should remove the last breakpoint completely before trying to add a new one', function (done) { + const rcConfig2 = t.generateRemoteConfig() + + t.agent.on('debugger-diagnostics', ({ payload: { debugger: { diagnostics: { status, probeId } } } }) => { + if (status !== 'INSTALLED') return - t.agent.on('debugger-input', () => { - assert.fail('should not capture anything when the probe is deleted') + if (probeId === t.rcConfig.config.id) { + // First INSTALLED payload: Try to trigger the race condition. + t.agent.removeRemoteConfig(t.rcConfig.id) + t.agent.addRemoteConfig(rcConfig2) + } else { + // Second INSTALLED payload: Perform an HTTP request to see if we successfully handled the race condition. + let finished = false + + // If the race condition occurred, the debugger will have been detached from the main thread and the new + // probe will never trigger. If that's the case, the following timer will fire: + const timer = setTimeout(() => { + done(new Error('Race condition occurred!')) + }, 1000) + + // If we successfully handled the race condition, the probe will trigger, we'll get a probe result and the + // following event listener will be called: + t.agent.once('debugger-input', () => { + clearTimeout(timer) + finished = true + done() + }) + + // Perform HTTP request to try and trigger the probe + t.axios.get(t.breakpoint.url).catch((err) => { + // If the request hasn't fully completed by the time the tests ends and the target app is destroyed, Axios + // will complain with a "socket hang up" error. Hence this sanity check before calling `done(err)`. If we + // later add more tests below this one, this shouuldn't be an issue. + if (!finished) done(err) + }) + } + }) + + t.agent.addRemoteConfig(t.rcConfig) }) + }) + }) - t.agent.addRemoteConfig(t.rcConfig) + describe('DD_TRACING_ENABLED=true, DD_TRACE_128_BIT_TRACEID_GENERATION_ENABLED=true', function () { + const t = setup({ env: { DD_TRACING_ENABLED: true, DD_TRACE_128_BIT_TRACEID_GENERATION_ENABLED: true } }) + + describe('input messages', function () { + it( + 'should capture and send expected payload when a log line probe is triggered', + testBasicInputWithDD.bind(null, t) + ) }) }) - describe('race conditions', function () { - it('should remove the last breakpoint completely before trying to add a new one', function (done) { - const rcConfig2 = t.generateRemoteConfig() - - t.agent.on('debugger-diagnostics', ({ payload: { debugger: { diagnostics: { status, probeId } } } }) => { - if (status !== 'INSTALLED') return - - if (probeId === t.rcConfig.config.id) { - // First INSTALLED payload: Try to trigger the race condition. - t.agent.removeRemoteConfig(t.rcConfig.id) - t.agent.addRemoteConfig(rcConfig2) - } else { - // Second INSTALLED payload: Perform an HTTP request to see if we successfully handled the race condition. - let finished = false - - // If the race condition occurred, the debugger will have been detached from the main thread and the new - // probe will never trigger. If that's the case, the following timer will fire: - const timer = setTimeout(() => { - done(new Error('Race condition occurred!')) - }, 1000) - - // If we successfully handled the race condition, the probe will trigger, we'll get a probe result and the - // following event listener will be called: - t.agent.once('debugger-input', () => { - clearTimeout(timer) - finished = true - done() - }) + describe('DD_TRACING_ENABLED=true, DD_TRACE_128_BIT_TRACEID_GENERATION_ENABLED=false', function () { + const t = setup({ env: { DD_TRACING_ENABLED: true, DD_TRACE_128_BIT_TRACEID_GENERATION_ENABLED: false } }) - // Perform HTTP request to try and trigger the probe - t.axios.get('/foo').catch((err) => { - // If the request hasn't fully completed by the time the tests ends and the target app is destroyed, Axios - // will complain with a "socket hang up" error. Hence this sanity check before calling `done(err)`. If we - // later add more tests below this one, this shouuldn't be an issue. - if (!finished) done(err) - }) - } - }) + describe('input messages', function () { + it( + 'should capture and send expected payload when a log line probe is triggered', + testBasicInputWithDD.bind(null, t) + ) + }) + }) - t.agent.addRemoteConfig(t.rcConfig) + describe('DD_TRACING_ENABLED=false', function () { + const t = setup({ env: { DD_TRACING_ENABLED: false } }) + + describe('input messages', function () { + it( + 'should capture and send expected payload when a log line probe is triggered', + testBasicInputWithoutDD.bind(null, t) + ) }) }) }) + +function testBasicInputWithDD (t, done) { + let traceId, spanId, dd + + t.triggerBreakpoint() + + t.agent.on('message', ({ payload }) => { + const span = payload.find((arr) => arr[0].name === 'fastify.request')[0] + traceId = span.trace_id.toString() + spanId = span.span_id.toString() + + assertDD() + }) + + t.agent.on('debugger-input', ({ payload }) => { + assertBasicInputPayload(t, payload) + + assert.isObject(payload.dd) + assert.hasAllKeys(payload.dd, ['trace_id', 'span_id']) + assert.typeOf(payload.dd.trace_id, 'string') + assert.typeOf(payload.dd.span_id, 'string') + assert.isAbove(payload.dd.trace_id.length, 0) + assert.isAbove(payload.dd.span_id.length, 0) + dd = payload.dd + + assertDD() + }) + + t.agent.addRemoteConfig(t.rcConfig) + + function assertDD () { + if (!traceId || !spanId || !dd) return + assert.strictEqual(dd.trace_id, traceId) + assert.strictEqual(dd.span_id, spanId) + done() + } +} + +function testBasicInputWithoutDD (t, done) { + t.triggerBreakpoint() + + t.agent.on('debugger-input', ({ payload }) => { + assertBasicInputPayload(t, payload) + assert.doesNotHaveAnyKeys(payload, ['dd']) + done() + }) + + t.agent.addRemoteConfig(t.rcConfig) +} + +function assertBasicInputPayload (t, payload) { + const expected = { + ddsource: 'dd_debugger', + hostname: os.hostname(), + service: 'node', + message: 'Hello World!', + logger: { + name: t.breakpoint.file, + method: 'fooHandler', + version, + thread_name: 'MainThread' + }, + 'debugger.snapshot': { + probe: { + id: t.rcConfig.config.id, + version: 0, + location: { file: t.breakpoint.file, lines: [String(t.breakpoint.line)] } + }, + language: 'javascript' + } + } + + assertObjectContains(payload, expected) + + assert.match(payload.logger.thread_id, /^pid:\d+$/) + + assertUUID(payload['debugger.snapshot'].id) + assert.isNumber(payload['debugger.snapshot'].timestamp) + assert.isTrue(payload['debugger.snapshot'].timestamp > Date.now() - 1000 * 60) + assert.isTrue(payload['debugger.snapshot'].timestamp <= Date.now()) + + assert.isArray(payload['debugger.snapshot'].stack) + assert.isAbove(payload['debugger.snapshot'].stack.length, 0) + for (const frame of payload['debugger.snapshot'].stack) { + assert.isObject(frame) + assert.hasAllKeys(frame, ['fileName', 'function', 'lineNumber', 'columnNumber']) + assert.isString(frame.fileName) + assert.isString(frame.function) + assert.isAbove(frame.lineNumber, 0) + assert.isAbove(frame.columnNumber, 0) + } + const topFrame = payload['debugger.snapshot'].stack[0] + // path seems to be prefeixed with `/private` on Mac + assert.match(topFrame.fileName, new RegExp(`${t.appFile}$`)) + assert.strictEqual(topFrame.function, 'fooHandler') + assert.strictEqual(topFrame.lineNumber, t.breakpoint.line) + assert.strictEqual(topFrame.columnNumber, 3) +} diff --git a/integration-tests/debugger/ddtags.spec.js b/integration-tests/debugger/ddtags.spec.js new file mode 100644 index 00000000000..5f864d71123 --- /dev/null +++ b/integration-tests/debugger/ddtags.spec.js @@ -0,0 +1,56 @@ +'use strict' + +const os = require('os') + +const { assert } = require('chai') +const { setup } = require('./utils') +const { version } = require('../../package.json') + +describe('Dynamic Instrumentation', function () { + describe('ddtags', function () { + const t = setup({ + env: { + DD_ENV: 'test-env', + DD_VERSION: 'test-version', + DD_GIT_COMMIT_SHA: 'test-commit-sha', + DD_GIT_REPOSITORY_URL: 'test-repository-url' + }, + testApp: 'target-app/basic.js' + }) + + it('should add the expected ddtags as a query param to /debugger/v1/input', function (done) { + t.triggerBreakpoint() + + t.agent.on('debugger-input', ({ query }) => { + assert.property(query, 'ddtags') + + // Before: "a:b,c:d" + // After: { a: 'b', c: 'd' } + const ddtags = query.ddtags + .split(',') + .map((tag) => tag.split(':')) + .reduce((acc, [k, v]) => { acc[k] = v; return acc }, {}) + + assert.hasAllKeys(ddtags, [ + 'env', + 'version', + 'debugger_version', + 'host_name', + 'git.commit.sha', + 'git.repository_url' + ]) + + assert.strictEqual(ddtags.env, 'test-env') + assert.strictEqual(ddtags.version, 'test-version') + assert.strictEqual(ddtags.debugger_version, version) + assert.strictEqual(ddtags.host_name, os.hostname()) + assert.strictEqual(ddtags['git.commit.sha'], 'test-commit-sha') + assert.strictEqual(ddtags['git.repository_url'], 'test-repository-url') + + done() + }) + + t.agent.addRemoteConfig(t.rcConfig) + }) + }) +}) diff --git a/integration-tests/debugger/snapshot-pruning.spec.js b/integration-tests/debugger/snapshot-pruning.spec.js index 91190a1c25d..c1ba218dd1c 100644 --- a/integration-tests/debugger/snapshot-pruning.spec.js +++ b/integration-tests/debugger/snapshot-pruning.spec.js @@ -1,9 +1,7 @@ 'use strict' const { assert } = require('chai') -const { setup, getBreakpointInfo } = require('./utils') - -const { line } = getBreakpointInfo() +const { setup } = require('./utils') describe('Dynamic Instrumentation', function () { const t = setup() @@ -17,7 +15,7 @@ describe('Dynamic Instrumentation', function () { assert.isBelow(Buffer.byteLength(JSON.stringify(payload)), 1024 * 1024) // 1MB assert.deepEqual(payload['debugger.snapshot'].captures, { lines: { - [line]: { + [t.breakpoint.line]: { locals: { notCapturedReason: 'Snapshot was too large', size: 6 diff --git a/integration-tests/debugger/snapshot.spec.js b/integration-tests/debugger/snapshot.spec.js index 94ef323f6a7..e3d17b225c4 100644 --- a/integration-tests/debugger/snapshot.spec.js +++ b/integration-tests/debugger/snapshot.spec.js @@ -31,7 +31,7 @@ describe('Dynamic Instrumentation', function () { str: { type: 'string', value: 'foo' }, lstr: { type: 'string', - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor i', truncated: true, size: 445 @@ -129,7 +129,7 @@ describe('Dynamic Instrumentation', function () { str: { type: 'string', value: 'foo' }, lstr: { type: 'string', - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor i', truncated: true, size: 445 diff --git a/integration-tests/debugger/target-app/basic.js b/integration-tests/debugger/target-app/basic.js index f8330012278..d9d1e0e9185 100644 --- a/integration-tests/debugger/target-app/basic.js +++ b/integration-tests/debugger/target-app/basic.js @@ -5,8 +5,12 @@ const Fastify = require('fastify') const fastify = Fastify() -fastify.get('/:name', function handler (request) { - return { hello: request.params.name } // BREAKPOINT +fastify.get('/foo/:name', function fooHandler (request) { + return { hello: request.params.name } // BREAKPOINT: /foo/bar +}) + +fastify.get('/bar/:name', function barHandler (request) { + return { hello: request.params.name } // BREAKPOINT: /bar/baz }) fastify.listen({ port: process.env.APP_PORT }, (err) => { diff --git a/integration-tests/debugger/target-app/snapshot-pruning.js b/integration-tests/debugger/target-app/snapshot-pruning.js index 58752006192..6b14405e61d 100644 --- a/integration-tests/debugger/target-app/snapshot-pruning.js +++ b/integration-tests/debugger/target-app/snapshot-pruning.js @@ -14,7 +14,7 @@ fastify.get('/:name', function handler (request) { // eslint-disable-next-line no-unused-vars const obj = generateObjectWithJSONSizeLargerThan1MB() - return { hello: request.params.name } // BREAKPOINT + return { hello: request.params.name } // BREAKPOINT: /foo }) fastify.listen({ port: process.env.APP_PORT }, (err) => { diff --git a/integration-tests/debugger/target-app/snapshot.js b/integration-tests/debugger/target-app/snapshot.js index a7b1810c10b..03cfc758556 100644 --- a/integration-tests/debugger/target-app/snapshot.js +++ b/integration-tests/debugger/target-app/snapshot.js @@ -11,7 +11,7 @@ const fastify = Fastify() fastify.get('/:name', function handler (request) { // eslint-disable-next-line no-unused-vars const { nil, undef, bool, num, bigint, str, lstr, sym, regex, arr, obj, emptyObj, fn, p } = getSomeData() - return { hello: request.params.name } // BREAKPOINT + return { hello: request.params.name } // BREAKPOINT: /foo }) fastify.listen({ port: process.env.APP_PORT }, (err) => { @@ -30,7 +30,7 @@ function getSomeData () { num: 42, bigint: 42n, str: 'foo', - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len lstr: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', sym: Symbol('foo'), regex: /bar/i, diff --git a/integration-tests/debugger/target-app/unreffed.js b/integration-tests/debugger/target-app/unreffed.js new file mode 100644 index 00000000000..c3c73d72d8b --- /dev/null +++ b/integration-tests/debugger/target-app/unreffed.js @@ -0,0 +1,15 @@ +'use strict' + +require('dd-trace/init') +const http = require('http') + +const server = http.createServer((req, res) => { + res.end('hello world') // BREAKPOINT: / + setImmediate(() => { + server.close() + }) +}) + +server.listen(process.env.APP_PORT, () => { + process.send({ port: process.env.APP_PORT }) +}) diff --git a/integration-tests/debugger/unreffed.spec.js b/integration-tests/debugger/unreffed.spec.js new file mode 100644 index 00000000000..3ce9458f341 --- /dev/null +++ b/integration-tests/debugger/unreffed.spec.js @@ -0,0 +1,17 @@ +'use strict' + +const { assert } = require('chai') +const { setup } = require('./utils') + +describe('Dynamic Instrumentation', function () { + const t = setup() + + it('should not hinder the program from exiting', function (done) { + // Expect the instrumented app to exit after receiving an HTTP request. Will time out otherwise. + t.proc.on('exit', (code) => { + assert.strictEqual(code, 0) + done() + }) + t.axios.get(t.breakpoint.url) + }) +}) diff --git a/integration-tests/debugger/utils.js b/integration-tests/debugger/utils.js index c5760a0e9d4..4f215723816 100644 --- a/integration-tests/debugger/utils.js +++ b/integration-tests/debugger/utils.js @@ -8,69 +8,68 @@ const getPort = require('get-port') const Axios = require('axios') const { createSandbox, FakeAgent, spawnProc } = require('../helpers') +const { generateProbeConfig } = require('../../packages/dd-trace/test/debugger/devtools_client/utils') +const BREAKPOINT_TOKEN = '// BREAKPOINT' const pollInterval = 1 module.exports = { pollInterval, - setup, - getBreakpointInfo + setup } -function setup () { - let sandbox, cwd, appPort, proc - const breakpoint = getBreakpointInfo(1) // `1` to disregard the `setup` function +function setup ({ env, testApp } = {}) { + let sandbox, cwd, appPort + const breakpoints = getBreakpointInfo({ file: testApp, stackIndex: 1 }) // `1` to disregard the `setup` function const t = { - breakpoint, + breakpoint: breakpoints[0], + breakpoints, + axios: null, appFile: null, agent: null, + + // Default to the first breakpoint in the file (normally there's only one) rcConfig: null, - triggerBreakpoint, - generateRemoteConfig, - generateProbeConfig + triggerBreakpoint: triggerBreakpoint.bind(null, breakpoints[0].url), + generateRemoteConfig: generateRemoteConfig.bind(null, breakpoints[0]), + generateProbeConfig: generateProbeConfig.bind(null, breakpoints[0]) } - function triggerBreakpoint () { + // Allow specific access to each breakpoint + for (let i = 0; i < breakpoints.length; i++) { + t.breakpoints[i] = { + rcConfig: null, + triggerBreakpoint: triggerBreakpoint.bind(null, breakpoints[i].url), + generateRemoteConfig: generateRemoteConfig.bind(null, breakpoints[i]), + generateProbeConfig: generateProbeConfig.bind(null, breakpoints[i]), + ...breakpoints[i] + } + } + + function triggerBreakpoint (url) { // Trigger the breakpoint once probe is successfully installed t.agent.on('debugger-diagnostics', ({ payload }) => { if (payload.debugger.diagnostics.status === 'INSTALLED') { - t.axios.get('/foo') + t.axios.get(url) } }) } - function generateRemoteConfig (overrides = {}) { + function generateRemoteConfig (breakpoint, overrides = {}) { overrides.id = overrides.id || randomUUID() return { product: 'LIVE_DEBUGGING', id: `logProbe_${overrides.id}`, - config: generateProbeConfig(overrides) - } - } - - function generateProbeConfig (overrides = {}) { - overrides.capture = { maxReferenceDepth: 3, ...overrides.capture } - overrides.sampling = { snapshotsPerSecond: 5000, ...overrides.sampling } - return { - id: randomUUID(), - version: 0, - type: 'LOG_PROBE', - language: 'javascript', - where: { sourceFile: breakpoint.file, lines: [String(breakpoint.line)] }, - tags: [], - template: 'Hello World!', - segments: [{ str: 'Hello World!' }], - captureSnapshot: false, - evaluateAt: 'EXIT', - ...overrides + config: generateProbeConfig(breakpoint, overrides) } } before(async function () { - sandbox = await createSandbox(['fastify']) + sandbox = await createSandbox(['fastify']) // TODO: Make this dynamic cwd = sandbox.folder - t.appFile = join(cwd, ...breakpoint.file.split('/')) + // The sandbox uses the `integration-tests` folder as its root + t.appFile = join(cwd, 'debugger', breakpoints[0].file) }) after(async function () { @@ -78,17 +77,22 @@ function setup () { }) beforeEach(async function () { - t.rcConfig = generateRemoteConfig(breakpoint) + // Default to the first breakpoint in the file (normally there's only one) + t.rcConfig = generateRemoteConfig(breakpoints[0]) + // Allow specific access to each breakpoint + t.breakpoints.forEach((breakpoint) => { breakpoint.rcConfig = generateRemoteConfig(breakpoint) }) + appPort = await getPort() t.agent = await new FakeAgent().start() - proc = await spawnProc(t.appFile, { + t.proc = await spawnProc(t.appFile, { cwd, env: { APP_PORT: appPort, DD_DYNAMIC_INSTRUMENTATION_ENABLED: true, DD_TRACE_AGENT_PORT: t.agent.port, DD_TRACE_DEBUG: process.env.DD_TRACE_DEBUG, // inherit to make debugging the sandbox easier - DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: pollInterval + DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: pollInterval, + ...env } }) t.axios = Axios.create({ @@ -97,28 +101,36 @@ function setup () { }) afterEach(async function () { - proc.kill() + t.proc.kill() await t.agent.stop() }) return t } -function getBreakpointInfo (stackIndex = 0) { - // First, get the filename of file that called this function - const testFile = new Error().stack - .split('\n')[stackIndex + 2] // +2 to skip this function + the first line, which is the error message - .split(' (')[1] - .slice(0, -1) - .split(':')[0] - - // Then, find the corresponding file in which the breakpoint exists - const filename = basename(testFile).replace('.spec', '') +function getBreakpointInfo ({ file, stackIndex = 0 }) { + if (!file) { + // First, get the filename of file that called this function + const testFile = new Error().stack + .split('\n')[stackIndex + 2] // +2 to skip this function + the first line, which is the error message + .split(' (')[1] + .slice(0, -1) + .split(':')[0] + + // Then, find the corresponding file in which the breakpoint(s) exists + file = join('target-app', basename(testFile).replace('.spec', '')) + } - // Finally, find the line number of the breakpoint - const line = readFileSync(join(__dirname, 'target-app', filename), 'utf8') - .split('\n') - .findIndex(line => line.includes('// BREAKPOINT')) + 1 + // Finally, find the line number(s) of the breakpoint(s) + const lines = readFileSync(join(__dirname, file), 'utf8').split('\n') + const result = [] + for (let i = 0; i < lines.length; i++) { + const index = lines[i].indexOf(BREAKPOINT_TOKEN) + if (index !== -1) { + const url = lines[i].slice(index + BREAKPOINT_TOKEN.length + 1).trim() + result.push({ file, line: i + 1, url }) + } + } - return { file: `debugger/target-app/${filename}`, line } + return result } diff --git a/integration-tests/esbuild/build-and-test-typescript.mjs b/integration-tests/esbuild/build-and-test-typescript.mjs index bba9500cdd3..2fd2966384d 100755 --- a/integration-tests/esbuild/build-and-test-typescript.mjs +++ b/integration-tests/esbuild/build-and-test-typescript.mjs @@ -18,8 +18,8 @@ await esbuild.build({ external: [ 'graphql/language/visitor', 'graphql/language/printer', - 'graphql/utilities', - ], + 'graphql/utilities' + ] }) console.log('ok') // eslint-disable-line no-console diff --git a/integration-tests/esbuild/complex-app.mjs b/integration-tests/esbuild/complex-app.mjs index 5f097655eeb..5936a2c3983 100755 --- a/integration-tests/esbuild/complex-app.mjs +++ b/integration-tests/esbuild/complex-app.mjs @@ -4,10 +4,11 @@ import 'dd-trace/init.js' import assert from 'assert' import express from 'express' import redis from 'redis' -const app = express() -const PORT = 3000 import pg from 'pg' import PGP from 'pg-promise' // transient dep of 'pg' + +const app = express() +const PORT = 3000 const pgp = PGP() assert.equal(redis.Graph.name, 'Graph') diff --git a/integration-tests/helpers/fake-agent.js b/integration-tests/helpers/fake-agent.js index f1054720d92..317584a5670 100644 --- a/integration-tests/helpers/fake-agent.js +++ b/integration-tests/helpers/fake-agent.js @@ -326,6 +326,7 @@ function buildExpressServer (agent) { res.status(200).send() agent.emit('debugger-input', { headers: req.headers, + query: req.query, payload: req.body }) }) @@ -363,6 +364,14 @@ function buildExpressServer (agent) { }) }) + // Ensure that any failure inside of Express isn't swallowed and returned as a 500, but instead crashes the test + app.use((err, req, res, next) => { + if (!err) next() + process.nextTick(() => { + throw err + }) + }) + return app } diff --git a/integration-tests/helpers/index.js b/integration-tests/helpers/index.js index 09cc6c5bee4..22074c3af20 100644 --- a/integration-tests/helpers/index.js +++ b/integration-tests/helpers/index.js @@ -306,7 +306,7 @@ async function spawnPluginIntegrationTestProc (cwd, serverFile, agentPort, stdio NODE_OPTIONS: `--loader=${hookFile}`, DD_TRACE_AGENT_PORT: agentPort } - env = { ...env, ...additionalEnvArgs } + env = { ...process.env, ...env, ...additionalEnvArgs } return spawnProc(path.join(cwd, serverFile), { cwd, env diff --git a/integration-tests/init.spec.js b/integration-tests/init.spec.js index 3c37004f607..fc274fb1480 100644 --- a/integration-tests/init.spec.js +++ b/integration-tests/init.spec.js @@ -20,7 +20,6 @@ const telemetryGood = ['complete', 'injection_forced:false'] const { engines } = require('../package.json') const supportedRange = engines.node const currentVersionIsSupported = semver.satisfies(process.versions.node, supportedRange) -const currentVersionCanLog = semver.satisfies(process.versions.node, '>=12.17.0') // These are on by default in release tests, so we'll turn them off for // more fine-grained control of these variables in these tests. @@ -35,12 +34,14 @@ function testInjectionScenarios (arg, filename, esmWorks = false) { const NODE_OPTIONS = `--no-warnings --${arg} ${path.join(__dirname, '..', filename)}` useEnv({ NODE_OPTIONS }) - context('without DD_INJECTION_ENABLED', () => { - it('should initialize the tracer', () => doTest('init/trace.js', 'true\n')) - it('should initialize instrumentation', () => doTest('init/instrument.js', 'true\n')) - it(`should ${esmWorks ? '' : 'not '}initialize ESM instrumentation`, () => - doTest('init/instrument.mjs', `${esmWorks}\n`)) - }) + if (currentVersionIsSupported) { + context('without DD_INJECTION_ENABLED', () => { + it('should initialize the tracer', () => doTest('init/trace.js', 'true\n')) + it('should initialize instrumentation', () => doTest('init/instrument.js', 'true\n')) + it(`should ${esmWorks ? '' : 'not '}initialize ESM instrumentation`, () => + doTest('init/instrument.mjs', `${esmWorks}\n`)) + }) + } context('with DD_INJECTION_ENABLED', () => { useEnv({ DD_INJECTION_ENABLED }) @@ -84,35 +85,12 @@ function testRuntimeVersionChecks (arg, filename) { } } - if (!currentVersionCanLog) { - context('when node version is too low for AsyncLocalStorage', () => { - useEnv({ NODE_OPTIONS }) - - it('should initialize the tracer, if no DD_INJECTION_ENABLED', () => - doTest('false\n')) - context('with DD_INJECTION_ENABLED', () => { - useEnv({ DD_INJECTION_ENABLED }) - - context('without debug', () => { - it('should not initialize the tracer', () => doTest('false\n')) - it('should not, if DD_INJECT_FORCE', () => doTestForced('false\n')) - }) - context('with debug', () => { - useEnv({ DD_TRACE_DEBUG }) - - it('should not initialize the tracer', () => - doTest('false\n')) - it('should initialize the tracer, if DD_INJECT_FORCE', () => - doTestForced('false\n')) - }) - }) - }) - } else if (!currentVersionIsSupported) { + if (!currentVersionIsSupported) { context('when node version is less than engines field', () => { useEnv({ NODE_OPTIONS }) - it('should initialize the tracer, if no DD_INJECTION_ENABLED', () => - doTest('true\n')) + it('should not initialize the tracer', () => + doTest('false\n')) context('with DD_INJECTION_ENABLED', () => { useEnv({ DD_INJECTION_ENABLED }) diff --git a/integration-tests/jest/jest.spec.js b/integration-tests/jest/jest.spec.js index 27b70329533..d8d9f8231a6 100644 --- a/integration-tests/jest/jest.spec.js +++ b/integration-tests/jest/jest.spec.js @@ -33,7 +33,11 @@ const { TEST_SOURCE_START, TEST_CODE_OWNERS, TEST_SESSION_NAME, - TEST_LEVEL_EVENT_TYPES + TEST_LEVEL_EVENT_TYPES, + DI_ERROR_DEBUG_INFO_CAPTURED, + DI_DEBUG_ERROR_FILE, + DI_DEBUG_ERROR_SNAPSHOT_ID, + DI_DEBUG_ERROR_LINE } = require('../../packages/dd-trace/src/plugins/util/test') const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/env') const { ERROR_MESSAGE } = require('../../packages/dd-trace/src/constants') @@ -57,7 +61,13 @@ describe('jest CommonJS', () => { let testOutput = '' before(async function () { - sandbox = await createSandbox(['jest', 'chai@v4', 'jest-jasmine2', 'jest-environment-jsdom'], true) + sandbox = await createSandbox([ + 'jest', + 'chai@v4', + 'jest-jasmine2', + 'jest-environment-jsdom', + 'office-addin-mock' + ], true) cwd = sandbox.folder startupTestFile = path.join(cwd, testFile) }) @@ -2399,4 +2409,276 @@ describe('jest CommonJS', () => { }) }) }) + + context('dynamic instrumentation', () => { + it('does not activate dynamic instrumentation if DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED is not set', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: true + }) + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.notProperty(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_FILE) + assert.notProperty(retriedTest.metrics, DI_DEBUG_ERROR_LINE) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_SNAPSHOT_ID) + }) + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + if (payloads.length > 0) { + throw new Error('Unexpected logs') + } + }, 5000) + + childProcess = exec(runTestsWithCoverageCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: 'dynamic-instrumentation/test-hit-breakpoint' + }, + stdio: 'inherit' + } + ) + + childProcess.on('exit', (code) => { + Promise.all([eventsPromise, logsPromise]).then(() => { + assert.equal(code, 0) + done() + }).catch(done) + }) + }) + + it('does not activate dynamic instrumentation if remote settings are disabled', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: false + }) + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.notProperty(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_FILE) + assert.notProperty(retriedTest.metrics, DI_DEBUG_ERROR_LINE) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_SNAPSHOT_ID) + }) + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + if (payloads.length > 0) { + throw new Error('Unexpected logs') + } + }, 5000) + + childProcess = exec(runTestsWithCoverageCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: 'dynamic-instrumentation/test-hit-breakpoint', + DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true' + }, + stdio: 'inherit' + } + ) + + childProcess.on('exit', (code) => { + Promise.all([eventsPromise, logsPromise]).then(() => { + assert.equal(code, 0) + done() + }).catch(done) + }) + }) + + it('runs retries with dynamic instrumentation', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: true + }) + let snapshotIdByTest, snapshotIdByLog + let spanIdByTest, spanIdByLog, traceIdByTest, traceIdByLog + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + assert.propertyVal( + retriedTest.meta, + DI_DEBUG_ERROR_FILE, + 'ci-visibility/dynamic-instrumentation/dependency.js' + ) + assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4) + assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID]) + + snapshotIdByTest = retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID] + spanIdByTest = retriedTest.span_id.toString() + traceIdByTest = retriedTest.trace_id.toString() + + const notRetriedTest = tests.find(test => test.meta[TEST_NAME].includes('is not retried')) + + assert.notProperty(notRetriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED) + }) + + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + const [{ logMessage: [diLog] }] = payloads + assert.deepInclude(diLog, { + ddsource: 'dd_debugger', + level: 'error' + }) + assert.equal(diLog.debugger.snapshot.language, 'javascript') + assert.deepInclude(diLog.debugger.snapshot.captures.lines['4'].locals, { + a: { + type: 'number', + value: '11' + }, + b: { + type: 'number', + value: '3' + }, + localVariable: { + type: 'number', + value: '2' + } + }) + spanIdByLog = diLog.dd.span_id + traceIdByLog = diLog.dd.trace_id + snapshotIdByLog = diLog.debugger.snapshot.id + }) + + childProcess = exec(runTestsWithCoverageCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: 'dynamic-instrumentation/test-hit-breakpoint', + DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true' + }, + stdio: 'inherit' + } + ) + + childProcess.on('exit', () => { + Promise.all([eventsPromise, logsPromise]).then(() => { + assert.equal(snapshotIdByTest, snapshotIdByLog) + assert.equal(spanIdByTest, spanIdByLog) + assert.equal(traceIdByTest, traceIdByLog) + done() + }).catch(done) + }) + }) + + it('does not crash if the retry does not hit the breakpoint', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: true + }) + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + assert.propertyVal( + retriedTest.meta, + DI_DEBUG_ERROR_FILE, + 'ci-visibility/dynamic-instrumentation/dependency.js' + ) + assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4) + assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID]) + }) + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + if (payloads.length > 0) { + throw new Error('Unexpected logs') + } + }, 5000) + + childProcess = exec(runTestsWithCoverageCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: 'dynamic-instrumentation/test-not-hit-breakpoint', + DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true' + }, + stdio: 'inherit' + } + ) + + childProcess.on('exit', (code) => { + Promise.all([eventsPromise, logsPromise]).then(() => { + assert.equal(code, 0) + done() + }).catch(done) + }) + }) + }) + + // This happens when using office-addin-mock + context('a test imports a file whose name includes a library we should bypass jest require cache for', () => { + it('does not crash', (done) => { + receiver.setSettings({ + itr_enabled: false, + code_coverage: false, + tests_skipping: false, + flaky_test_retries_enabled: false, + early_flake_detection: { + enabled: false + } + }) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + + assert.equal(tests.length, 1) + }) + + childProcess = exec(runTestsWithCoverageCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: 'office-addin-mock/test' + }, + stdio: 'inherit' + } + ) + + childProcess.on('exit', (code) => { + eventsPromise.then(() => { + assert.equal(code, 0) + done() + }).catch(done) + }) + }) + }) }) diff --git a/integration-tests/mocha/mocha.spec.js b/integration-tests/mocha/mocha.spec.js index 69763845044..d6d13673485 100644 --- a/integration-tests/mocha/mocha.spec.js +++ b/integration-tests/mocha/mocha.spec.js @@ -35,7 +35,11 @@ const { TEST_CODE_OWNERS, TEST_SESSION_NAME, TEST_LEVEL_EVENT_TYPES, - TEST_EARLY_FLAKE_ABORT_REASON + TEST_EARLY_FLAKE_ABORT_REASON, + DI_ERROR_DEBUG_INFO_CAPTURED, + DI_DEBUG_ERROR_FILE, + DI_DEBUG_ERROR_SNAPSHOT_ID, + DI_DEBUG_ERROR_LINE } = require('../../packages/dd-trace/src/plugins/util/test') const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/env') const { ERROR_MESSAGE } = require('../../packages/dd-trace/src/constants') @@ -2144,4 +2148,252 @@ describe('mocha CommonJS', function () { }) }) }) + + context('dynamic instrumentation', () => { + it('does not activate dynamic instrumentation if DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED is not set', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: true + }) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.notProperty(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_FILE) + assert.notProperty(retriedTest.metrics, DI_DEBUG_ERROR_LINE) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_SNAPSHOT_ID) + }) + + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + if (payloads.length > 0) { + throw new Error('Unexpected logs') + } + }, 5000) + + childProcess = exec( + runTestsWithCoverageCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: JSON.stringify([ + './dynamic-instrumentation/test-hit-breakpoint' + ]) + }, + stdio: 'inherit' + } + ) + + childProcess.on('exit', (code) => { + Promise.all([eventsPromise, logsPromise]).then(() => { + assert.equal(code, 0) + done() + }).catch(done) + }) + }) + + it('does not activate dynamic instrumentation if remote settings are disabled', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: false + }) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.notProperty(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_FILE) + assert.notProperty(retriedTest.metrics, DI_DEBUG_ERROR_LINE) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_SNAPSHOT_ID) + }) + + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + if (payloads.length > 0) { + throw new Error('Unexpected logs') + } + }, 5000) + + childProcess = exec( + runTestsWithCoverageCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: JSON.stringify([ + './dynamic-instrumentation/test-hit-breakpoint' + ]), + DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true' + }, + stdio: 'inherit' + } + ) + + childProcess.on('exit', (code) => { + Promise.all([eventsPromise, logsPromise]).then(() => { + assert.equal(code, 0) + done() + }).catch(done) + }) + }) + + it('runs retries with dynamic instrumentation', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: true + }) + + let snapshotIdByTest, snapshotIdByLog + let spanIdByTest, spanIdByLog, traceIdByTest, traceIdByLog + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + assert.propertyVal( + retriedTest.meta, + DI_DEBUG_ERROR_FILE, + 'ci-visibility/dynamic-instrumentation/dependency.js' + ) + assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4) + assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID]) + + snapshotIdByTest = retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID] + spanIdByTest = retriedTest.span_id.toString() + traceIdByTest = retriedTest.trace_id.toString() + + const notRetriedTest = tests.find(test => test.meta[TEST_NAME].includes('is not retried')) + + assert.notProperty(notRetriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED) + }) + + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + const [{ logMessage: [diLog] }] = payloads + assert.deepInclude(diLog, { + ddsource: 'dd_debugger', + level: 'error' + }) + assert.equal(diLog.debugger.snapshot.language, 'javascript') + assert.deepInclude(diLog.debugger.snapshot.captures.lines['4'].locals, { + a: { + type: 'number', + value: '11' + }, + b: { + type: 'number', + value: '3' + }, + localVariable: { + type: 'number', + value: '2' + } + }) + spanIdByLog = diLog.dd.span_id + traceIdByLog = diLog.dd.trace_id + snapshotIdByLog = diLog.debugger.snapshot.id + }, 5000) + + childProcess = exec( + 'node ./ci-visibility/run-mocha.js', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: JSON.stringify([ + './dynamic-instrumentation/test-hit-breakpoint' + ]), + DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true' + }, + stdio: 'inherit' + } + ) + + childProcess.on('exit', () => { + Promise.all([eventsPromise, logsPromise]).then(() => { + assert.equal(snapshotIdByTest, snapshotIdByLog) + assert.equal(spanIdByTest, spanIdByLog) + assert.equal(traceIdByTest, traceIdByLog) + done() + }).catch(done) + }) + }) + + it('does not crash if the retry does not hit the breakpoint', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: true + }) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + assert.propertyVal( + retriedTest.meta, + DI_DEBUG_ERROR_FILE, + 'ci-visibility/dynamic-instrumentation/dependency.js' + ) + assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4) + assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID]) + }) + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + if (payloads.length > 0) { + throw new Error('Unexpected logs') + } + }, 5000) + + childProcess = exec( + 'node ./ci-visibility/run-mocha.js', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: JSON.stringify([ + './dynamic-instrumentation/test-not-hit-breakpoint' + ]), + DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: 'true' + }, + stdio: 'inherit' + } + ) + + childProcess.on('exit', () => { + Promise.all([eventsPromise, logsPromise]).then(() => { + done() + }).catch(done) + }) + }) + }) }) diff --git a/integration-tests/playwright/playwright.spec.js b/integration-tests/playwright/playwright.spec.js index 440cf13d637..3f6a49e01b7 100644 --- a/integration-tests/playwright/playwright.spec.js +++ b/integration-tests/playwright/playwright.spec.js @@ -123,11 +123,15 @@ versions.forEach((version) => { }) assert.includeMembers(testEvents.map(test => test.content.resource), [ - 'landing-page-test.js.should work with passing tests', - 'landing-page-test.js.should work with skipped tests', - 'landing-page-test.js.should work with fixme', - 'landing-page-test.js.should work with annotated tests', - 'todo-list-page-test.js.should work with failing tests', + 'landing-page-test.js.highest-level-describe' + + ' leading and trailing spaces should work with passing tests', + 'landing-page-test.js.highest-level-describe' + + ' leading and trailing spaces should work with skipped tests', + 'landing-page-test.js.highest-level-describe' + + ' leading and trailing spaces should work with fixme', + 'landing-page-test.js.highest-level-describe' + + ' leading and trailing spaces should work with annotated tests', + 'todo-list-page-test.js.playwright should work with failing tests', 'todo-list-page-test.js.should work with fixme root' ]) @@ -155,7 +159,7 @@ versions.forEach((version) => { assert.property(stepEvent.content.meta, 'playwright.step') }) const annotatedTest = testEvents.find(test => - test.content.resource === 'landing-page-test.js.should work with annotated tests' + test.content.resource.endsWith('should work with annotated tests') ) assert.propertyVal(annotatedTest.content.meta, 'test.memory.usage', 'low') @@ -187,8 +191,8 @@ versions.forEach((version) => { const events = payloads.flatMap(({ payload }) => payload.events) const testEvents = events.filter(event => event.type === 'test') assert.includeMembers(testEvents.map(test => test.content.resource), [ - 'playwright-tests-ts/one-test.js.should work with passing tests', - 'playwright-tests-ts/one-test.js.should work with skipped tests' + 'playwright-tests-ts/one-test.js.playwright should work with passing tests', + 'playwright-tests-ts/one-test.js.playwright should work with skipped tests' ]) assert.include(testOutput, '1 passed') assert.include(testOutput, '1 skipped') @@ -263,16 +267,17 @@ versions.forEach((version) => { { playwright: { 'landing-page-test.js': [ - // 'should work with passing tests', // it will be considered new - 'should work with skipped tests', - 'should work with fixme', - 'should work with annotated tests' + // it will be considered new + // 'highest-level-describe leading and trailing spaces should work with passing tests', + 'highest-level-describe leading and trailing spaces should work with skipped tests', + 'highest-level-describe leading and trailing spaces should work with fixme', + 'highest-level-describe leading and trailing spaces should work with annotated tests' ], 'skipped-suite-test.js': [ 'should work with fixme root' ], 'todo-list-page-test.js': [ - 'should work with failing tests', + 'playwright should work with failing tests', 'should work with fixme root' ] } @@ -288,8 +293,7 @@ versions.forEach((version) => { const tests = events.filter(event => event.type === 'test').map(event => event.content) const newTests = tests.filter(test => - test.resource === - 'landing-page-test.js.should work with passing tests' + test.resource.endsWith('should work with passing tests') ) newTests.forEach(test => { assert.propertyVal(test.meta, TEST_IS_NEW, 'true') @@ -337,16 +341,17 @@ versions.forEach((version) => { { playwright: { 'landing-page-test.js': [ - // 'should work with passing tests', // it will be considered new - 'should work with skipped tests', - 'should work with fixme', - 'should work with annotated tests' + // it will be considered new + // 'highest-level-describe leading and trailing spaces should work with passing tests', + 'highest-level-describe leading and trailing spaces should work with skipped tests', + 'highest-level-describe leading and trailing spaces should work with fixme', + 'highest-level-describe leading and trailing spaces should work with annotated tests' ], 'skipped-suite-test.js': [ 'should work with fixme root' ], 'todo-list-page-test.js': [ - 'should work with failing tests', + 'playwright should work with failing tests', 'should work with fixme root' ] } @@ -359,8 +364,7 @@ versions.forEach((version) => { const tests = events.filter(event => event.type === 'test').map(event => event.content) const newTests = tests.filter(test => - test.resource === - 'landing-page-test.js.should work with passing tests' + test.resource.endsWith('should work with passing tests') ) newTests.forEach(test => { assert.notProperty(test.meta, TEST_IS_NEW) @@ -406,16 +410,18 @@ versions.forEach((version) => { { playwright: { 'landing-page-test.js': [ - 'should work with passing tests', - // 'should work with skipped tests', // new but not retried because it's skipped - // 'should work with fixme', // new but not retried because it's skipped - 'should work with annotated tests' + 'highest-level-describe leading and trailing spaces should work with passing tests', + // new but not retried because it's skipped + // 'highest-level-describe leading and trailing spaces should work with skipped tests', + // new but not retried because it's skipped + // 'highest-level-describe leading and trailing spaces should work with fixme', + 'highest-level-describe leading and trailing spaces should work with annotated tests' ], 'skipped-suite-test.js': [ 'should work with fixme root' ], 'todo-list-page-test.js': [ - 'should work with failing tests', + 'playwright should work with failing tests', 'should work with fixme root' ] } @@ -428,9 +434,8 @@ versions.forEach((version) => { const tests = events.filter(event => event.type === 'test').map(event => event.content) const newTests = tests.filter(test => - test.resource === - 'landing-page-test.js.should work with skipped tests' || - test.resource === 'landing-page-test.js.should work with fixme' + test.resource.endsWith('should work with skipped tests') || + test.resource.endsWith('should work with fixme') ) // no retries assert.equal(newTests.length, 2) diff --git a/integration-tests/profiler/fstest.js b/integration-tests/profiler/fstest.js new file mode 100644 index 00000000000..c65887c102e --- /dev/null +++ b/integration-tests/profiler/fstest.js @@ -0,0 +1,40 @@ +const fs = require('fs') +const os = require('os') +const path = require('path') + +const tracer = require('dd-trace').init() +tracer.profilerStarted().then(() => { + tracer.trace('x', (_, done) => { + setImmediate(() => { + // Generate 1MB of random data + const buffer = Buffer.alloc(1024 * 1024) + for (let i = 0; i < buffer.length; i++) { + buffer[i] = Math.floor(Math.random() * 256) + } + + // Create a temporary file + const tempFilePath = path.join(os.tmpdir(), 'tempfile.txt') + + fs.writeFile(tempFilePath, buffer, (err) => { + if (err) throw err + + // Read the data back + setImmediate(() => { + fs.readFile(tempFilePath, (err, readData) => { + setImmediate(() => { + // Delete the temporary file + fs.unlink(tempFilePath, (err) => { + if (err) throw err + }) + done() + }) + if (err) throw err + if (Buffer.compare(buffer, readData) !== 0) { + throw new Error('Data read from file is different from data written to file') + } + }) + }) + }) + }) + }) +}) diff --git a/integration-tests/profiler/profiler.spec.js b/integration-tests/profiler/profiler.spec.js index 172c186f1eb..6c7f4942e1e 100644 --- a/integration-tests/profiler/profiler.spec.js +++ b/integration-tests/profiler/profiler.spec.js @@ -75,6 +75,12 @@ function processExitPromise (proc, timeout, expectBadExit = false) { } async function getLatestProfile (cwd, pattern) { + const pprofGzipped = await readLatestFile(cwd, pattern) + const pprofUnzipped = zlib.gunzipSync(pprofGzipped) + return { profile: Profile.decode(pprofUnzipped), encoded: pprofGzipped.toString('base64') } +} + +async function readLatestFile (cwd, pattern) { const dirEntries = await fs.readdir(cwd) // Get the latest file matching the pattern const pprofEntries = dirEntries.filter(name => pattern.test(name)) @@ -83,9 +89,7 @@ async function getLatestProfile (cwd, pattern) { .map(name => ({ name, modified: fsync.statSync(path.join(cwd, name), { bigint: true }).mtimeNs })) .reduce((a, b) => a.modified > b.modified ? a : b) .name - const pprofGzipped = await fs.readFile(path.join(cwd, pprofEntry)) - const pprofUnzipped = zlib.gunzipSync(pprofGzipped) - return { profile: Profile.decode(pprofUnzipped), encoded: pprofGzipped.toString('base64') } + return await fs.readFile(path.join(cwd, pprofEntry)) } function expectTimeout (messagePromise, allowErrors = false) { @@ -100,15 +104,115 @@ function expectTimeout (messagePromise, allowErrors = false) { ) } +class TimelineEventProcessor { + constructor (strings, encoded) { + this.strings = strings + this.encoded = encoded + } +} + +class NetworkEventProcessor extends TimelineEventProcessor { + constructor (strings, encoded) { + super(strings, encoded) + + this.hostKey = strings.dedup('host') + this.addressKey = strings.dedup('address') + this.portKey = strings.dedup('port') + } + + processLabel (label, processedLabels) { + switch (label.key) { + case this.hostKey: + processedLabels.host = label.str + return true + case this.addressKey: + processedLabels.address = label.str + return true + case this.portKey: + processedLabels.port = label.num + return true + default: + return false + } + } + + decorateEvent (ev, pl) { + // Exactly one of these is defined + assert.isTrue(!!pl.address !== !!pl.host, this.encoded) + if (pl.address) { + ev.address = this.strings.strings[pl.address] + } else { + ev.host = this.strings.strings[pl.host] + } + if (pl.port) { + ev.port = pl.port + } + } +} + async function gatherNetworkTimelineEvents (cwd, scriptFilePath, eventType, args) { + return gatherTimelineEvents(cwd, scriptFilePath, eventType, args, NetworkEventProcessor) +} + +class FilesystemEventProcessor extends TimelineEventProcessor { + constructor (strings, encoded) { + super(strings, encoded) + + this.fdKey = strings.dedup('fd') + this.fileKey = strings.dedup('file') + this.flagKey = strings.dedup('flag') + this.modeKey = strings.dedup('mode') + this.pathKey = strings.dedup('path') + } + + processLabel (label, processedLabels) { + switch (label.key) { + case this.fdKey: + processedLabels.fd = label.num + return true + case this.fileKey: + processedLabels.file = label.str + return true + case this.flagKey: + processedLabels.flag = label.str + return true + case this.modeKey: + processedLabels.mode = label.str + return true + case this.pathKey: + processedLabels.path = label.str + return true + default: + return false + } + } + + decorateEvent (ev, pl) { + ev.fd = pl.fd + ev.file = this.strings.strings[pl.file] + ev.flag = this.strings.strings[pl.flag] + ev.mode = this.strings.strings[pl.mode] + ev.path = this.strings.strings[pl.path] + for (const [k, v] of Object.entries(ev)) { + if (v === undefined) { + delete ev[k] + } + } + } +} + +async function gatherFilesystemTimelineEvents (cwd, scriptFilePath) { + return gatherTimelineEvents(cwd, scriptFilePath, 'fs', [], FilesystemEventProcessor) +} + +async function gatherTimelineEvents (cwd, scriptFilePath, eventType, args, Processor) { const procStart = BigInt(Date.now() * 1000000) const proc = fork(path.join(cwd, scriptFilePath), args, { cwd, env: { - DD_PROFILING_PROFILERS: 'wall', DD_PROFILING_EXPORTERS: 'file', DD_PROFILING_ENABLED: 1, - DD_PROFILING_EXPERIMENTAL_TIMELINE_ENABLED: 1 + DD_INTERNAL_PROFILING_TIMELINE_SAMPLING_ENABLED: 0 // capture all events } }) @@ -120,54 +224,50 @@ async function gatherNetworkTimelineEvents (cwd, scriptFilePath, eventType, args const strings = profile.stringTable const tsKey = strings.dedup('end_timestamp_ns') const eventKey = strings.dedup('event') - const hostKey = strings.dedup('host') - const addressKey = strings.dedup('address') - const portKey = strings.dedup('port') - const nameKey = strings.dedup('operation') + const operationKey = strings.dedup('operation') const spanIdKey = strings.dedup('span id') const localRootSpanIdKey = strings.dedup('local root span id') const eventValue = strings.dedup(eventType) const events = [] + const processor = new Processor(strings, encoded) for (const sample of profile.sample) { - let ts, event, host, address, port, name, spanId, localRootSpanId + let ts, event, operation, spanId, localRootSpanId + const processedLabels = {} + const unexpectedLabels = [] for (const label of sample.label) { switch (label.key) { case tsKey: ts = label.num; break - case nameKey: name = label.str; break + case operationKey: operation = label.str; break case eventKey: event = label.str; break - case hostKey: host = label.str; break - case addressKey: address = label.str; break - case portKey: port = label.num; break case spanIdKey: spanId = label.str; break case localRootSpanIdKey: localRootSpanId = label.str; break - default: assert.fail(`Unexpected label key ${label.key} ${strings.strings[label.key]} ${encoded}`) + default: + if (!processor.processLabel(label, processedLabels)) { + unexpectedLabels.push(label.key) + } } } // Timestamp must be defined and be between process start and end time assert.isDefined(ts, encoded) assert.isTrue(ts <= procEnd, encoded) assert.isTrue(ts >= procStart, encoded) - if (process.platform !== 'win32') { - assert.isDefined(spanId, encoded) - assert.isDefined(localRootSpanId, encoded) - } else { - assert.isUndefined(spanId, encoded) - assert.isUndefined(localRootSpanId, encoded) - } - // Gather only DNS events; ignore sporadic GC events + // Gather only tested events if (event === eventValue) { - assert.isDefined(name, encoded) - // Exactly one of these is defined - assert.isTrue(!!address !== !!host, encoded) - const ev = { name: strings.strings[name] } - if (address) { - ev.address = strings.strings[address] + if (process.platform !== 'win32') { + assert.isDefined(spanId, encoded) + assert.isDefined(localRootSpanId, encoded) } else { - ev.host = strings.strings[host] + assert.isUndefined(spanId, encoded) + assert.isUndefined(localRootSpanId, encoded) } - if (port) { - ev.port = port + assert.isDefined(operation, encoded) + if (unexpectedLabels.length > 0) { + const labelsStr = JSON.stringify(unexpectedLabels) + const labelsStrStr = unexpectedLabels.map(k => strings.strings[k]).join(',') + assert.fail(`Unexpected labels: ${labelsStr}\n${labelsStrStr}\n${encoded}`) } + const ev = { operation: strings.strings[operation] } + processor.decorateEvent(ev, processedLabels) events.push(ev) } } @@ -205,18 +305,18 @@ describe('profiler', () => { const proc = fork(path.join(cwd, 'profiler/codehotspots.js'), { cwd, env: { - DD_PROFILING_PROFILERS: 'wall', DD_PROFILING_EXPORTERS: 'file', - DD_PROFILING_ENABLED: 1, - DD_PROFILING_CODEHOTSPOTS_ENABLED: 1, - DD_PROFILING_ENDPOINT_COLLECTION_ENABLED: 1, - DD_PROFILING_EXPERIMENTAL_TIMELINE_ENABLED: 1 + DD_PROFILING_ENABLED: 1 } }) await processExitPromise(proc, 30000) const procEnd = BigInt(Date.now() * 1000000) + // Must've counted the number of times each endpoint was hit + const event = JSON.parse((await readLatestFile(cwd, /^event_.+\.json$/)).toString()) + assert.deepEqual(event.endpoint_counts, { 'endpoint-0': 1, 'endpoint-1': 1, 'endpoint-2': 1 }) + const { profile, encoded } = await getLatestProfile(cwd, /^wall_.+\.pprof$/) // We check the profile for following invariants: @@ -314,14 +414,30 @@ describe('profiler', () => { assert.equal(endpoints.size, 3, encoded) }) + it('fs timeline events work', async () => { + const fsEvents = await gatherFilesystemTimelineEvents(cwd, 'profiler/fstest.js') + assert.equal(fsEvents.length, 6) + const path = fsEvents[0].path + const fd = fsEvents[1].fd + assert(path.endsWith('tempfile.txt')) + assert.sameDeepMembers(fsEvents, [ + { flag: 'w', mode: '', operation: 'open', path }, + { fd, operation: 'write' }, + { fd, operation: 'close' }, + { file: path, operation: 'writeFile' }, + { operation: 'readFile', path }, + { operation: 'unlink', path } + ]) + }) + it('dns timeline events work', async () => { const dnsEvents = await gatherNetworkTimelineEvents(cwd, 'profiler/dnstest.js', 'dns') assert.sameDeepMembers(dnsEvents, [ - { name: 'lookup', host: 'example.org' }, - { name: 'lookup', host: 'example.com' }, - { name: 'lookup', host: 'datadoghq.com' }, - { name: 'queryA', host: 'datadoghq.com' }, - { name: 'lookupService', address: '13.224.103.60', port: 80 } + { operation: 'lookup', host: 'example.org' }, + { operation: 'lookup', host: 'example.com' }, + { operation: 'lookup', host: 'datadoghq.com' }, + { operation: 'queryA', host: 'datadoghq.com' }, + { operation: 'lookupService', address: '13.224.103.60', port: 80 } ]) }) @@ -357,8 +473,8 @@ describe('profiler', () => { // The profiled program should have two TCP connection events to the two // servers. assert.sameDeepMembers(events, [ - { name: 'connect', host: '127.0.0.1', port: port1 }, - { name: 'connect', host: '127.0.0.1', port: port2 } + { operation: 'connect', host: '127.0.0.1', port: port1 }, + { operation: 'connect', host: '127.0.0.1', port: port2 } ]) } finally { server2.close() diff --git a/integration-tests/test-api-manual.spec.js b/integration-tests/test-api-manual.spec.js index 419c7c736c5..c403168206a 100644 --- a/integration-tests/test-api-manual.spec.js +++ b/integration-tests/test-api-manual.spec.js @@ -10,24 +10,20 @@ const { getCiVisAgentlessConfig } = require('./helpers') const { FakeCiVisIntake } = require('./ci-visibility-intake') -const webAppServer = require('./ci-visibility/web-app-server') const { TEST_STATUS } = require('../packages/dd-trace/src/plugins/util/test') describe('test-api-manual', () => { - let sandbox, cwd, receiver, childProcess, webAppPort + let sandbox, cwd, receiver, childProcess before(async () => { sandbox = await createSandbox([], true) cwd = sandbox.folder - webAppPort = await getPort() - webAppServer.listen(webAppPort) }) after(async () => { await sandbox.remove() - await new Promise(resolve => webAppServer.close(resolve)) }) beforeEach(async function () { diff --git a/integration-tests/test-optimization-startup.spec.js b/integration-tests/test-optimization-startup.spec.js new file mode 100644 index 00000000000..a15d49cf8ef --- /dev/null +++ b/integration-tests/test-optimization-startup.spec.js @@ -0,0 +1,84 @@ +'use strict' + +const { exec } = require('child_process') + +const getPort = require('get-port') +const { assert } = require('chai') + +const { createSandbox } = require('./helpers') +const { FakeCiVisIntake } = require('./ci-visibility-intake') + +const packageManagers = ['yarn', 'npm', 'pnpm'] + +describe('test optimization startup', () => { + let sandbox, cwd, receiver, childProcess, processOutput + + before(async () => { + sandbox = await createSandbox(packageManagers, true) + cwd = sandbox.folder + }) + + after(async () => { + await sandbox.remove() + }) + + beforeEach(async function () { + processOutput = '' + const port = await getPort() + receiver = await new FakeCiVisIntake(port).start() + }) + + afterEach(async () => { + childProcess.kill() + await receiver.stop() + }) + + packageManagers.forEach(packageManager => { + it(`skips initialization for ${packageManager}`, (done) => { + childProcess = exec(`node ./node_modules/.bin/${packageManager} -v`, + { + cwd, + env: { + ...process.env, + NODE_OPTIONS: '-r dd-trace/ci/init', + DD_TRACE_DEBUG: '1' + }, + stdio: 'pipe' + } + ) + + childProcess.stdout.on('data', (chunk) => { + processOutput += chunk.toString() + }) + + childProcess.on('exit', () => { + assert.include(processOutput, 'dd-trace is not initialized in a package manager') + done() + }) + }) + }) + + it('does not skip initialization for non package managers', (done) => { + childProcess = exec('node -e "console.log(\'hello!\')"', + { + cwd, + env: { + ...process.env, + NODE_OPTIONS: '-r dd-trace/ci/init', + DD_TRACE_DEBUG: '1' + }, + stdio: 'pipe' + } + ) + + childProcess.stdout.on('data', (chunk) => { + processOutput += chunk.toString() + }) + + childProcess.on('exit', () => { + assert.include(processOutput, 'hello!') + assert.notInclude(processOutput, 'dd-trace is not initialized in a package manager') + done() + }) + }) +}) diff --git a/integration-tests/vitest/vitest.spec.js b/integration-tests/vitest/vitest.spec.js index de38feee9da..2007baefd52 100644 --- a/integration-tests/vitest/vitest.spec.js +++ b/integration-tests/vitest/vitest.spec.js @@ -24,7 +24,11 @@ const { TEST_NAME, TEST_EARLY_FLAKE_ENABLED, TEST_EARLY_FLAKE_ABORT_REASON, - TEST_SUITE + TEST_SUITE, + DI_ERROR_DEBUG_INFO_CAPTURED, + DI_DEBUG_ERROR_FILE, + DI_DEBUG_ERROR_LINE, + DI_DEBUG_ERROR_SNAPSHOT_ID } = require('../../packages/dd-trace/src/plugins/util/test') const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/env') @@ -896,5 +900,251 @@ versions.forEach((version) => { }) }) }) + + // dynamic instrumentation only supported from >=2.0.0 + if (version === 'latest') { + context('dynamic instrumentation', () => { + it('does not activate it if DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED is not set', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: true + }) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.notProperty(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_FILE) + assert.notProperty(retriedTest.metrics, DI_DEBUG_ERROR_LINE) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_SNAPSHOT_ID) + }) + + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + if (payloads.length > 0) { + throw new Error('Unexpected logs') + } + }, 5000) + + childProcess = exec( + './node_modules/.bin/vitest run --retry=1', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TEST_DIR: 'ci-visibility/vitest-tests/dynamic-instrumentation*', + NODE_OPTIONS: '--import dd-trace/register.js -r dd-trace/ci/init' + }, + stdio: 'pipe' + } + ) + + childProcess.on('exit', () => { + Promise.all([eventsPromise, logsPromise]).then(() => { + done() + }).catch(done) + }) + }) + + it('does not activate dynamic instrumentation if remote settings are disabled', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: false + }) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.notProperty(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_FILE) + assert.notProperty(retriedTest.metrics, DI_DEBUG_ERROR_LINE) + assert.notProperty(retriedTest.meta, DI_DEBUG_ERROR_SNAPSHOT_ID) + }) + + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + if (payloads.length > 0) { + throw new Error('Unexpected logs') + } + }, 5000) + + childProcess = exec( + './node_modules/.bin/vitest run --retry=1', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TEST_DIR: 'ci-visibility/vitest-tests/dynamic-instrumentation*', + NODE_OPTIONS: '--import dd-trace/register.js -r dd-trace/ci/init', + DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: '1' + }, + stdio: 'pipe' + } + ) + + childProcess.on('exit', () => { + Promise.all([eventsPromise, logsPromise]).then(() => { + done() + }).catch(done) + }) + }) + + it('runs retries with dynamic instrumentation', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: true + }) + + let snapshotIdByTest, snapshotIdByLog + let spanIdByTest, spanIdByLog, traceIdByTest, traceIdByLog + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + assert.propertyVal( + retriedTest.meta, + DI_DEBUG_ERROR_FILE, + 'ci-visibility/vitest-tests/bad-sum.mjs' + ) + assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4) + assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID]) + + snapshotIdByTest = retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID] + spanIdByTest = retriedTest.span_id.toString() + traceIdByTest = retriedTest.trace_id.toString() + + const notRetriedTest = tests.find(test => test.meta[TEST_NAME].includes('is not retried')) + + assert.notProperty(notRetriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED) + }) + + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + const [{ logMessage: [diLog] }] = payloads + assert.deepInclude(diLog, { + ddsource: 'dd_debugger', + level: 'error' + }) + assert.equal(diLog.debugger.snapshot.language, 'javascript') + assert.deepInclude(diLog.debugger.snapshot.captures.lines['4'].locals, { + a: { + type: 'number', + value: '11' + }, + b: { + type: 'number', + value: '2' + }, + localVar: { + type: 'number', + value: '10' + } + }) + spanIdByLog = diLog.dd.span_id + traceIdByLog = diLog.dd.trace_id + snapshotIdByLog = diLog.debugger.snapshot.id + }, 5000) + + childProcess = exec( + './node_modules/.bin/vitest run --retry=1', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TEST_DIR: 'ci-visibility/vitest-tests/dynamic-instrumentation*', + NODE_OPTIONS: '--import dd-trace/register.js -r dd-trace/ci/init', + DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: '1' + }, + stdio: 'pipe' + } + ) + + childProcess.on('exit', () => { + Promise.all([eventsPromise, logsPromise]).then(() => { + assert.equal(snapshotIdByTest, snapshotIdByLog) + assert.equal(spanIdByTest, spanIdByLog) + assert.equal(traceIdByTest, traceIdByLog) + done() + }).catch(done) + }) + }) + + it('does not crash if the retry does not hit the breakpoint', (done) => { + receiver.setSettings({ + flaky_test_retries_enabled: true, + di_enabled: true + }) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_IS_RETRY] === 'true') + + assert.equal(retriedTests.length, 1) + const [retriedTest] = retriedTests + + assert.propertyVal(retriedTest.meta, DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + assert.propertyVal( + retriedTest.meta, + DI_DEBUG_ERROR_FILE, + 'ci-visibility/vitest-tests/bad-sum.mjs' + ) + assert.equal(retriedTest.metrics[DI_DEBUG_ERROR_LINE], 4) + assert.exists(retriedTest.meta[DI_DEBUG_ERROR_SNAPSHOT_ID]) + }) + + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/logs'), (payloads) => { + if (payloads.length > 0) { + throw new Error('Unexpected logs') + } + }, 5000) + + childProcess = exec( + './node_modules/.bin/vitest run --retry=1', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TEST_DIR: 'ci-visibility/vitest-tests/breakpoint-not-hit*', + NODE_OPTIONS: '--import dd-trace/register.js -r dd-trace/ci/init', + DD_TEST_DYNAMIC_INSTRUMENTATION_ENABLED: '1' + }, + stdio: 'pipe' + } + ) + + childProcess.on('exit', () => { + Promise.all([eventsPromise, logsPromise]).then(() => { + done() + }).catch(done) + }) + }) + }) + } }) }) diff --git a/loader-hook.mjs b/loader-hook.mjs index 40bbdbade81..fc2a250e3a1 100644 --- a/loader-hook.mjs +++ b/loader-hook.mjs @@ -1 +1,5 @@ +// TODO(bengl): Not sure why `import/export` fails on this line, but it's just +// a passthrough to another module so it should be fine. Disabling for now. + +// eslint-disable-next-line import/export export * from 'import-in-the-middle/hook.mjs' diff --git a/package.json b/package.json index 26fe1a5fabe..9b0abdb34db 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "env": "bash ./plugin-env", "preinstall": "node scripts/preinstall.js", "bench": "node benchmark", - "bench:profiler": "node benchmark/profiler", "bench:e2e": "SERVICES=mongo yarn services && cd benchmark/e2e && node benchmark-run.js --duration=30", "bench:e2e:ci-visibility": "node benchmark/e2e-ci/benchmark-run.js", "type:doc": "cd docs && yarn && yarn build", @@ -82,11 +81,11 @@ "node": ">=18" }, "dependencies": { - "@datadog/libdatadog": "^0.2.2", + "@datadog/libdatadog": "^0.3.0", "@datadog/native-appsec": "8.3.0", - "@datadog/native-iast-rewriter": "2.5.0", + "@datadog/native-iast-rewriter": "2.6.1", "@datadog/native-iast-taint-tracking": "3.2.0", - "@datadog/native-metrics": "^3.0.1", + "@datadog/native-metrics": "^3.1.0", "@datadog/pprof": "5.4.1", "@datadog/sketches-js": "^2.1.0", "@isaacs/ttlcache": "^1.4.1", @@ -96,7 +95,6 @@ "dc-polyfill": "^0.1.4", "ignore": "^5.2.4", "import-in-the-middle": "1.11.2", - "int64-buffer": "^0.1.9", "istanbul-lib-coverage": "3.2.0", "jest-docblock": "^29.7.0", "koalas": "^1.0.2", @@ -104,20 +102,23 @@ "lodash.sortby": "^4.7.0", "lru-cache": "^7.14.0", "module-details-from-path": "^1.0.3", - "msgpack-lite": "^0.1.26", "opentracing": ">=0.12.1", - "path-to-regexp": "^0.1.10", + "path-to-regexp": "^0.1.12", "pprof-format": "^2.1.0", "protobufjs": "^7.2.5", "retry": "^0.13.1", "rfdc": "^1.3.1", "semver": "^7.5.4", "shell-quote": "^1.8.1", + "source-map": "^0.7.4", "tlhunter-sorted-set": "^0.1.0" }, "devDependencies": { "@apollo/server": "^4.11.0", - "@types/node": "^16.18.103", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.11.1", + "@stylistic/eslint-plugin-js": "^2.8.0", + "@types/node": "^16.0.0", "autocannon": "^4.5.2", "aws-sdk": "^2.1446.0", "axios": "^1.7.4", @@ -135,14 +136,17 @@ "eslint-plugin-mocha": "^10.4.3", "eslint-plugin-n": "^16.6.2", "eslint-plugin-promise": "^6.4.0", - "express": "^4.18.2", + "express": "^4.21.2", "get-port": "^3.2.0", "glob": "^7.1.6", + "globals": "^15.10.0", "graphql": "0.13.2", + "int64-buffer": "^0.1.9", "jszip": "^3.5.0", "knex": "^2.4.2", "mkdirp": "^3.0.1", "mocha": "^9", + "msgpack-lite": "^0.1.26", "multer": "^1.4.5-lts.1", "nock": "^11.3.3", "nyc": "^15.1.0", @@ -151,6 +155,7 @@ "sinon": "^16.1.3", "sinon-chai": "^3.7.0", "tap": "^16.3.7", - "tiktoken": "^1.0.15" + "tiktoken": "^1.0.15", + "yaml": "^2.5.0" } } diff --git a/packages/datadog-core/src/storage.js b/packages/datadog-core/src/storage.js index d28420ed259..15c9fff239c 100644 --- a/packages/datadog-core/src/storage.js +++ b/packages/datadog-core/src/storage.js @@ -2,12 +2,47 @@ const { AsyncLocalStorage } = require('async_hooks') +class DatadogStorage { + constructor () { + this._storage = new AsyncLocalStorage() + } + + disable () { + this._storage.disable() + } + + enterWith (store) { + const handle = {} + stores.set(handle, store) + this._storage.enterWith(handle) + } + + exit (callback, ...args) { + this._storage.exit(callback, ...args) + } + + getStore () { + const handle = this._storage.getStore() + return stores.get(handle) + } + + run (store, fn, ...args) { + const prior = this._storage.getStore() + this.enterWith(store) + try { + return Reflect.apply(fn, null, args) + } finally { + this._storage.enterWith(prior) + } + } +} + const storages = Object.create(null) -const legacyStorage = new AsyncLocalStorage() +const legacyStorage = new DatadogStorage() const storage = function (namespace) { if (!storages[namespace]) { - storages[namespace] = new AsyncLocalStorage() + storages[namespace] = new DatadogStorage() } return storages[namespace] } @@ -18,4 +53,6 @@ storage.exit = legacyStorage.exit.bind(legacyStorage) storage.getStore = legacyStorage.getStore.bind(legacyStorage) storage.run = legacyStorage.run.bind(legacyStorage) +const stores = new WeakMap() + module.exports = storage diff --git a/packages/datadog-core/test/storage.spec.js b/packages/datadog-core/test/storage.spec.js index 89839f1fca3..e5bca4e7d5d 100644 --- a/packages/datadog-core/test/storage.spec.js +++ b/packages/datadog-core/test/storage.spec.js @@ -3,6 +3,7 @@ require('../../dd-trace/test/setup/tap') const { expect } = require('chai') +const { executionAsyncResource } = require('async_hooks') const storage = require('../src/storage') describe('storage', () => { @@ -47,4 +48,16 @@ describe('storage', () => { it('should return the same storage for a namespace', () => { expect(storage('test')).to.equal(testStorage) }) + + it('should not have its store referenced by the underlying async resource', () => { + const resource = executionAsyncResource() + + testStorage.enterWith({ internal: 'internal' }) + + for (const sym of Object.getOwnPropertySymbols(resource)) { + if (sym.toString() === 'Symbol(kResourceStore)' && resource[sym]) { + expect(resource[sym]).to.not.have.property('internal') + } + } + }) }) diff --git a/packages/datadog-instrumentations/src/aerospike.js b/packages/datadog-instrumentations/src/aerospike.js index 724c518e050..497a64aaf80 100644 --- a/packages/datadog-instrumentations/src/aerospike.js +++ b/packages/datadog-instrumentations/src/aerospike.js @@ -40,7 +40,7 @@ function wrapProcess (process) { addHook({ name: 'aerospike', file: 'lib/commands/command.js', - versions: ['^3.16.2', '4', '5'] + versions: ['4', '5'] }, commandFactory => { return shimmer.wrapFunction(commandFactory, f => wrapCreateCommand(f)) diff --git a/packages/datadog-instrumentations/src/cucumber.js b/packages/datadog-instrumentations/src/cucumber.js index 0f84d717381..7b9a2db5a02 100644 --- a/packages/datadog-instrumentations/src/cucumber.js +++ b/packages/datadog-instrumentations/src/cucumber.js @@ -126,6 +126,20 @@ function getTestStatusFromRetries (testStatuses) { return 'pass' } +function getErrorFromCucumberResult (cucumberResult) { + if (!cucumberResult.message) { + return + } + + const [message] = cucumberResult.message.split('\n') + const error = new Error(message) + if (cucumberResult.exception) { + error.type = cucumberResult.exception.type + } + error.stack = cucumberResult.message + return error +} + function getChannelPromise (channelToPublishTo) { return new Promise(resolve => { sessionAsyncResource.runInAsyncScope(() => { @@ -230,9 +244,19 @@ function wrapRun (pl, isLatestVersion) { if (testCase?.testCaseFinished) { const { testCaseFinished: { willBeRetried } } = testCase if (willBeRetried) { // test case failed and will be retried + let error + try { + const cucumberResult = this.getWorstStepResult() + error = getErrorFromCucumberResult(cucumberResult) + } catch (e) { + // ignore error + } + const failedAttemptAsyncResource = numAttemptToAsyncResource.get(numAttempt) + const isRetry = numAttempt++ > 0 failedAttemptAsyncResource.runInAsyncScope(() => { - testRetryCh.publish(numAttempt++ > 0) // the current span will be finished and a new one will be created + // the current span will be finished and a new one will be created + testRetryCh.publish({ isRetry, error }) }) const newAsyncResource = new AsyncResource('bound-anonymous-fn') @@ -251,7 +275,7 @@ function wrapRun (pl, isLatestVersion) { }) promise.finally(() => { const result = this.getWorstStepResult() - const { status, skipReason, errorMessage } = isLatestVersion + const { status, skipReason } = isLatestVersion ? getStatusFromResultLatest(result) : getStatusFromResult(result) @@ -270,8 +294,10 @@ function wrapRun (pl, isLatestVersion) { } const attemptAsyncResource = numAttemptToAsyncResource.get(numAttempt) + const error = getErrorFromCucumberResult(result) + attemptAsyncResource.runInAsyncScope(() => { - testFinishCh.publish({ status, skipReason, errorMessage, isNew, isEfdRetry, isFlakyRetry: numAttempt > 0 }) + testFinishCh.publish({ status, skipReason, error, isNew, isEfdRetry, isFlakyRetry: numAttempt > 0 }) }) }) return promise diff --git a/packages/datadog-instrumentations/src/express.js b/packages/datadog-instrumentations/src/express.js index 74e159fb042..1b328ba4c13 100644 --- a/packages/datadog-instrumentations/src/express.js +++ b/packages/datadog-instrumentations/src/express.js @@ -59,8 +59,6 @@ function wrapResponseRender (render) { addHook({ name: 'express', versions: ['>=4'] }, express => { shimmer.wrap(express.application, 'handle', wrapHandle) - shimmer.wrap(express.Router, 'use', wrapRouterMethod) - shimmer.wrap(express.Router, 'route', wrapRouterMethod) shimmer.wrap(express.response, 'json', wrapResponseJson) shimmer.wrap(express.response, 'jsonp', wrapResponseJson) @@ -69,6 +67,20 @@ addHook({ name: 'express', versions: ['>=4'] }, express => { return express }) +addHook({ name: 'express', versions: ['4'] }, express => { + shimmer.wrap(express.Router, 'use', wrapRouterMethod) + shimmer.wrap(express.Router, 'route', wrapRouterMethod) + + return express +}) + +addHook({ name: 'express', versions: ['>=5.0.0'] }, express => { + shimmer.wrap(express.Router.prototype, 'use', wrapRouterMethod) + shimmer.wrap(express.Router.prototype, 'route', wrapRouterMethod) + + return express +}) + const queryParserReadCh = channel('datadog:query:read:finish') function publishQueryParsedAndNext (req, res, next) { @@ -88,7 +100,7 @@ function publishQueryParsedAndNext (req, res, next) { addHook({ name: 'express', - versions: ['>=4'], + versions: ['4'], file: 'lib/middleware/query.js' }, query => { return shimmer.wrapFunction(query, query => function () { @@ -129,7 +141,29 @@ addHook({ name: 'express', versions: ['>=4.0.0 <4.3.0'] }, express => { return express }) -addHook({ name: 'express', versions: ['>=4.3.0'] }, express => { +addHook({ name: 'express', versions: ['>=4.3.0 <5.0.0'] }, express => { shimmer.wrap(express.Router, 'process_params', wrapProcessParamsMethod(2)) return express }) + +const queryReadCh = channel('datadog:express:query:finish') + +addHook({ name: 'express', file: ['lib/request.js'], versions: ['>=5.0.0'] }, request => { + const requestDescriptor = Object.getOwnPropertyDescriptor(request, 'query') + + shimmer.wrap(requestDescriptor, 'get', function (originalGet) { + return function wrappedGet () { + const query = originalGet.apply(this, arguments) + + if (queryReadCh.hasSubscribers && query) { + queryReadCh.publish({ query }) + } + + return query + } + }) + + Object.defineProperty(request, 'query', requestDescriptor) + + return request +}) diff --git a/packages/datadog-instrumentations/src/fs.js b/packages/datadog-instrumentations/src/fs.js index 9ae201b9860..894c1b6ef33 100644 --- a/packages/datadog-instrumentations/src/fs.js +++ b/packages/datadog-instrumentations/src/fs.js @@ -13,6 +13,9 @@ const errorChannel = channel('apm:fs:operation:error') const ddFhSym = Symbol('ddFileHandle') let kHandle, kDirReadPromisified, kDirClosePromisified +// Update packages/dd-trace/src/profiling/profilers/event_plugins/fs.js if you make changes to param names in any of +// the following objects. + const paramsByMethod = { access: ['path', 'mode'], appendFile: ['path', 'data', 'options'], diff --git a/packages/datadog-instrumentations/src/helpers/bundler-register.js b/packages/datadog-instrumentations/src/helpers/bundler-register.js index a5dfead9669..6c11329bc36 100644 --- a/packages/datadog-instrumentations/src/helpers/bundler-register.js +++ b/packages/datadog-instrumentations/src/helpers/bundler-register.js @@ -30,12 +30,12 @@ dc.subscribe(CHANNEL, (payload) => { try { hooks[payload.package]() } catch (err) { - log.error(`esbuild-wrapped ${payload.package} missing in list of hooks`) + log.error('esbuild-wrapped %s missing in list of hooks', payload.package) throw err } if (!instrumentations[payload.package]) { - log.error(`esbuild-wrapped ${payload.package} missing in list of instrumentations`) + log.error('esbuild-wrapped %s missing in list of instrumentations', payload.package) return } @@ -47,7 +47,7 @@ dc.subscribe(CHANNEL, (payload) => { loadChannel.publish({ name, version: payload.version, file }) payload.module = hook(payload.module, payload.version) } catch (e) { - log.error(e) + log.error('Error executing bundler hook', e) } } }) diff --git a/packages/datadog-instrumentations/src/helpers/hooks.js b/packages/datadog-instrumentations/src/helpers/hooks.js index 4261d4dae44..4ea35f50218 100644 --- a/packages/datadog-instrumentations/src/helpers/hooks.js +++ b/packages/datadog-instrumentations/src/helpers/hooks.js @@ -111,7 +111,6 @@ module.exports = { protobufjs: () => require('../protobufjs'), pug: () => require('../pug'), q: () => require('../q'), - qs: () => require('../qs'), redis: () => require('../redis'), restify: () => require('../restify'), rhea: () => require('../rhea'), diff --git a/packages/datadog-instrumentations/src/helpers/register.js b/packages/datadog-instrumentations/src/helpers/register.js index 4b4185423c0..5a28f066c1f 100644 --- a/packages/datadog-instrumentations/src/helpers/register.js +++ b/packages/datadog-instrumentations/src/helpers/register.js @@ -7,7 +7,7 @@ const Hook = require('./hook') const requirePackageJson = require('../../../dd-trace/src/require-package-json') const log = require('../../../dd-trace/src/log') const checkRequireCache = require('../check_require_cache') -const telemetry = require('../../../dd-trace/src/telemetry/init-telemetry') +const telemetry = require('../../../dd-trace/src/guardrails/telemetry') const { DD_TRACE_DISABLED_INSTRUMENTATIONS = '', @@ -103,8 +103,7 @@ for (const packageName of names) { try { version = version || getVersion(moduleBaseDir) } catch (e) { - log.error(`Error getting version for "${name}": ${e.message}`) - log.error(e) + log.error('Error getting version for "%s": %s', name, e.message, e) continue } if (typeof namesAndSuccesses[`${name}@${version}`] === 'undefined') { @@ -146,7 +145,7 @@ for (const packageName of names) { `integration:${name}`, `integration_version:${version}` ]) - log.info(`Found incompatible integration version: ${nameVersion}`) + log.info('Found incompatible integration version: %s', nameVersion) seenCombo.add(nameVersion) } } diff --git a/packages/datadog-instrumentations/src/http/client.js b/packages/datadog-instrumentations/src/http/client.js index 29547df61dc..6ab01a34513 100644 --- a/packages/datadog-instrumentations/src/http/client.js +++ b/packages/datadog-instrumentations/src/http/client.js @@ -39,7 +39,7 @@ function patch (http, methodName) { try { args = normalizeArgs.apply(null, arguments) } catch (e) { - log.error(e) + log.error('Error normalising http req arguments', e) return request.apply(this, arguments) } diff --git a/packages/datadog-instrumentations/src/jest.js b/packages/datadog-instrumentations/src/jest.js index b17a4137c96..2d27fdc0acb 100644 --- a/packages/datadog-instrumentations/src/jest.js +++ b/packages/datadog-instrumentations/src/jest.js @@ -133,6 +133,7 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { this.isEarlyFlakeDetectionEnabled = this.testEnvironmentOptions._ddIsEarlyFlakeDetectionEnabled this.isFlakyTestRetriesEnabled = this.testEnvironmentOptions._ddIsFlakyTestRetriesEnabled this.flakyTestRetriesCount = this.testEnvironmentOptions._ddFlakyTestRetriesCount + this.isDiEnabled = this.testEnvironmentOptions._ddIsDiEnabled if (this.isEarlyFlakeDetectionEnabled) { const hasKnownTests = !!knownTests.jest @@ -237,7 +238,6 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { name: removeEfdStringFromTestName(testName), suite: this.testSuite, testSourceFile: this.testSourceFile, - runner: 'jest-circus', displayName: this.displayName, testParameters, frameworkVersion: jestVersion, @@ -274,13 +274,23 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { } } if (event.name === 'test_done') { + const probe = {} const asyncResource = asyncResources.get(event.test) asyncResource.runInAsyncScope(() => { let status = 'pass' if (event.test.errors && event.test.errors.length) { status = 'fail' - const formattedError = formatJestError(event.test.errors[0]) - testErrCh.publish(formattedError) + const numRetries = this.global[RETRY_TIMES] + const numTestExecutions = event.test?.invocations + const willBeRetried = numRetries > 0 && numTestExecutions - 1 < numRetries + + const error = formatJestError(event.test.errors[0]) + testErrCh.publish({ + error, + willBeRetried, + probe, + isDiEnabled: this.isDiEnabled + }) } testRunFinishCh.publish({ status, @@ -302,6 +312,9 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { } } }) + if (probe.setProbePromise) { + await probe.setProbePromise + } } if (event.name === 'test_skip' || event.name === 'test_todo') { const asyncResource = new AsyncResource('bound-anonymous-fn') @@ -310,7 +323,6 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { name: getJestTestName(event.test), suite: this.testSuite, testSourceFile: this.testSourceFile, - runner: 'jest-circus', displayName: this.displayName, frameworkVersion: jestVersion, testStartLine: getTestLineStart(event.test.asyncError, this.testSuite) @@ -445,7 +457,7 @@ function cliWrapper (cli, jestVersion) { earlyFlakeDetectionFaultyThreshold = libraryConfig.earlyFlakeDetectionFaultyThreshold } } catch (err) { - log.error(err) + log.error('Jest library configuration error', err) } if (isEarlyFlakeDetectionEnabled) { @@ -466,7 +478,7 @@ function cliWrapper (cli, jestVersion) { isEarlyFlakeDetectionEnabled = false } } catch (err) { - log.error(err) + log.error('Jest known tests error', err) } } @@ -485,7 +497,7 @@ function cliWrapper (cli, jestVersion) { skippableSuites = receivedSkippableSuites } } catch (err) { - log.error(err) + log.error('Jest test-suite skippable error', err) } } @@ -780,6 +792,7 @@ addHook({ _ddRepositoryRoot, _ddIsFlakyTestRetriesEnabled, _ddFlakyTestRetriesCount, + _ddIsDiEnabled, ...restOfTestEnvironmentOptions } = testEnvironmentOptions @@ -855,12 +868,18 @@ addHook({ const LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE = [ 'selenium-webdriver', + 'selenium-webdriver/chrome', + 'selenium-webdriver/edge', + 'selenium-webdriver/safari', + 'selenium-webdriver/firefox', + 'selenium-webdriver/ie', + 'selenium-webdriver/chromium', 'winston' ] function shouldBypassJestRequireEngine (moduleName) { return ( - LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE.some(library => moduleName.includes(library)) + LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE.includes(moduleName) ) } diff --git a/packages/datadog-instrumentations/src/mocha/utils.js b/packages/datadog-instrumentations/src/mocha/utils.js index 2b51fd6e73b..ce462f13256 100644 --- a/packages/datadog-instrumentations/src/mocha/utils.js +++ b/packages/datadog-instrumentations/src/mocha/utils.js @@ -284,8 +284,9 @@ function getOnTestRetryHandler () { const asyncResource = getTestAsyncResource(test) if (asyncResource) { const isFirstAttempt = test._currentRetry === 0 + const willBeRetried = test._currentRetry < test._retries asyncResource.runInAsyncScope(() => { - testRetryCh.publish({ isFirstAttempt, err }) + testRetryCh.publish({ isFirstAttempt, err, willBeRetried }) }) } const key = getTestToArKey(test) diff --git a/packages/datadog-instrumentations/src/mysql2.js b/packages/datadog-instrumentations/src/mysql2.js index 096eec0e80e..bd5c48daf56 100644 --- a/packages/datadog-instrumentations/src/mysql2.js +++ b/packages/datadog-instrumentations/src/mysql2.js @@ -8,7 +8,7 @@ const { const shimmer = require('../../datadog-shimmer') const semver = require('semver') -addHook({ name: 'mysql2', file: 'lib/connection.js', versions: ['>=1'] }, (Connection, version) => { +function wrapConnection (Connection, version) { const startCh = channel('apm:mysql2:query:start') const finishCh = channel('apm:mysql2:query:finish') const errorCh = channel('apm:mysql2:query:error') @@ -151,9 +151,8 @@ addHook({ name: 'mysql2', file: 'lib/connection.js', versions: ['>=1'] }, (Conne } }, cmd)) } -}) - -addHook({ name: 'mysql2', file: 'lib/pool.js', versions: ['>=1'] }, (Pool, version) => { +} +function wrapPool (Pool, version) { const startOuterQueryCh = channel('datadog:mysql2:outerquery:start') const shouldEmitEndAfterQueryAbort = semver.intersects(version, '>=1.3.3') @@ -221,10 +220,9 @@ addHook({ name: 'mysql2', file: 'lib/pool.js', versions: ['>=1'] }, (Pool, versi }) return Pool -}) +} -// PoolNamespace.prototype.query does not exist in mysql2<2.3.0 -addHook({ name: 'mysql2', file: 'lib/pool_cluster.js', versions: ['>=2.3.0'] }, PoolCluster => { +function wrapPoolCluster (PoolCluster) { const startOuterQueryCh = channel('datadog:mysql2:outerquery:start') const wrappedPoolNamespaces = new WeakSet() @@ -297,4 +295,11 @@ addHook({ name: 'mysql2', file: 'lib/pool_cluster.js', versions: ['>=2.3.0'] }, }) return PoolCluster -}) +} + +addHook({ name: 'mysql2', file: 'lib/base/connection.js', versions: ['>=3.11.5'] }, wrapConnection) +addHook({ name: 'mysql2', file: 'lib/connection.js', versions: ['1 - 3.11.4'] }, wrapConnection) +addHook({ name: 'mysql2', file: 'lib/pool.js', versions: ['1 - 3.11.4'] }, wrapPool) + +// PoolNamespace.prototype.query does not exist in mysql2<2.3.0 +addHook({ name: 'mysql2', file: 'lib/pool_cluster.js', versions: ['2.3.0 - 3.11.4'] }, wrapPoolCluster) diff --git a/packages/datadog-instrumentations/src/next.js b/packages/datadog-instrumentations/src/next.js index 56ce695fe76..770d340d567 100644 --- a/packages/datadog-instrumentations/src/next.js +++ b/packages/datadog-instrumentations/src/next.js @@ -2,7 +2,6 @@ const { channel, addHook } = require('./helpers/instrument') const shimmer = require('../../datadog-shimmer') -const { DD_MAJOR } = require('../../../version') const startChannel = channel('apm:next:request:start') const finishChannel = channel('apm:next:request:finish') @@ -221,7 +220,7 @@ addHook({ addHook({ name: 'next', - versions: DD_MAJOR >= 4 ? ['>=10.2 <11.1'] : ['>=9.5 <11.1'], + versions: ['>=10.2 <11.1'], file: 'dist/next-server/server/serve-static.js' }, serveStatic => shimmer.wrap(serveStatic, 'serveStatic', wrapServeStatic)) @@ -248,7 +247,11 @@ addHook({ name: 'next', versions: ['>=13.2'], file: 'dist/server/next-server.js' return nextServer }) -addHook({ name: 'next', versions: ['>=11.1 <13.2'], file: 'dist/server/next-server.js' }, nextServer => { +addHook({ + name: 'next', + versions: ['>=11.1 <13.2'], + file: 'dist/server/next-server.js' +}, nextServer => { const Server = nextServer.default shimmer.wrap(Server.prototype, 'handleApiRequest', wrapHandleApiRequest) return nextServer @@ -256,7 +259,7 @@ addHook({ name: 'next', versions: ['>=11.1 <13.2'], file: 'dist/server/next-serv addHook({ name: 'next', - versions: DD_MAJOR >= 4 ? ['>=10.2 <11.1'] : ['>=9.5 <11.1'], + versions: ['>=10.2 <11.1'], file: 'dist/next-server/server/next-server.js' }, nextServer => { const Server = nextServer.default diff --git a/packages/datadog-instrumentations/src/passport-http.js b/packages/datadog-instrumentations/src/passport-http.js index 0969d2d3fc9..3b930a1a1cc 100644 --- a/packages/datadog-instrumentations/src/passport-http.js +++ b/packages/datadog-instrumentations/src/passport-http.js @@ -1,22 +1,10 @@ 'use strict' -const shimmer = require('../../datadog-shimmer') const { addHook } = require('./helpers/instrument') -const { wrapVerify } = require('./passport-utils') +const { strategyHook } = require('./passport-utils') addHook({ name: 'passport-http', file: 'lib/passport-http/strategies/basic.js', versions: ['>=0.3.0'] -}, BasicStrategy => { - return shimmer.wrapFunction(BasicStrategy, BasicStrategy => function () { - const type = 'http' - - if (typeof arguments[0] === 'function') { - arguments[0] = wrapVerify(arguments[0], false, type) - } else { - arguments[1] = wrapVerify(arguments[1], (arguments[0] && arguments[0].passReqToCallback), type) - } - return BasicStrategy.apply(this, arguments) - }) -}) +}, strategyHook) diff --git a/packages/datadog-instrumentations/src/passport-local.js b/packages/datadog-instrumentations/src/passport-local.js index dab74eb470e..c6dcec9a48d 100644 --- a/packages/datadog-instrumentations/src/passport-local.js +++ b/packages/datadog-instrumentations/src/passport-local.js @@ -1,22 +1,10 @@ 'use strict' -const shimmer = require('../../datadog-shimmer') const { addHook } = require('./helpers/instrument') -const { wrapVerify } = require('./passport-utils') +const { strategyHook } = require('./passport-utils') addHook({ name: 'passport-local', file: 'lib/strategy.js', versions: ['>=1.0.0'] -}, Strategy => { - return shimmer.wrapFunction(Strategy, Strategy => function () { - const type = 'local' - - if (typeof arguments[0] === 'function') { - arguments[0] = wrapVerify(arguments[0], false, type) - } else { - arguments[1] = wrapVerify(arguments[1], (arguments[0] && arguments[0].passReqToCallback), type) - } - return Strategy.apply(this, arguments) - }) -}) +}, strategyHook) diff --git a/packages/datadog-instrumentations/src/passport-utils.js b/packages/datadog-instrumentations/src/passport-utils.js index 7969ab486b4..de1cd090a71 100644 --- a/packages/datadog-instrumentations/src/passport-utils.js +++ b/packages/datadog-instrumentations/src/passport-utils.js @@ -5,33 +5,57 @@ const { channel } = require('./helpers/instrument') const passportVerifyChannel = channel('datadog:passport:verify:finish') -function wrapVerifiedAndPublish (username, password, verified, type) { - if (!passportVerifyChannel.hasSubscribers) { - return verified - } +function wrapVerifiedAndPublish (framework, username, verified) { + return shimmer.wrapFunction(verified, function wrapVerified (verified) { + return function wrappedVerified (err, user) { + // if there is an error, it's neither an auth success nor a failure + if (!err) { + const abortController = new AbortController() + + passportVerifyChannel.publish({ framework, login: username, user, success: !!user, abortController }) + + if (abortController.signal.aborted) return + } - // eslint-disable-next-line n/handle-callback-err - return shimmer.wrapFunction(verified, verified => function (err, user, info) { - const credentials = { type, username } - passportVerifyChannel.publish({ credentials, user }) - return verified.apply(this, arguments) + return verified.apply(this, arguments) + } }) } -function wrapVerify (verify, passReq, type) { - if (passReq) { - return function (req, username, password, verified) { - arguments[3] = wrapVerifiedAndPublish(username, password, verified, type) - return verify.apply(this, arguments) +function wrapVerify (verify) { + return function wrappedVerify (req, username, password, verified) { + if (passportVerifyChannel.hasSubscribers) { + const framework = `passport-${this.name}` + + // replace the callback with our own wrapper to get the result + if (this._passReqToCallback) { + arguments[3] = wrapVerifiedAndPublish(framework, arguments[1], arguments[3]) + } else { + arguments[2] = wrapVerifiedAndPublish(framework, arguments[0], arguments[2]) + } } - } else { - return function (username, password, verified) { - arguments[2] = wrapVerifiedAndPublish(username, password, verified, type) - return verify.apply(this, arguments) + + return verify.apply(this, arguments) + } +} + +function wrapStrategy (Strategy) { + return function wrappedStrategy () { + // verify function can be either the first or second argument + if (typeof arguments[0] === 'function') { + arguments[0] = wrapVerify(arguments[0]) + } else { + arguments[1] = wrapVerify(arguments[1]) } + + return Strategy.apply(this, arguments) } } +function strategyHook (Strategy) { + return shimmer.wrapFunction(Strategy, wrapStrategy) +} + module.exports = { - wrapVerify + strategyHook } diff --git a/packages/datadog-instrumentations/src/pg.js b/packages/datadog-instrumentations/src/pg.js index 55642d82e96..331557cd239 100644 --- a/packages/datadog-instrumentations/src/pg.js +++ b/packages/datadog-instrumentations/src/pg.js @@ -62,17 +62,17 @@ function wrapQuery (query) { abortController }) - const finish = asyncResource.bind(function (error) { + const finish = asyncResource.bind(function (error, res) { if (error) { errorCh.publish(error) } - finishCh.publish() + finishCh.publish({ result: res?.rows }) }) if (abortController.signal.aborted) { const error = abortController.signal.reason || new Error('Aborted') - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len // Based on: https://github.com/brianc/node-postgres/blob/54eb0fa216aaccd727765641e7d1cf5da2bc483d/packages/pg/lib/client.js#L510 const reusingQuery = typeof pgQuery.submit === 'function' const callback = arguments[arguments.length - 1] @@ -119,15 +119,15 @@ function wrapQuery (query) { if (newQuery.callback) { const originalCallback = callbackResource.bind(newQuery.callback) newQuery.callback = function (err, res) { - finish(err) + finish(err, res) return originalCallback.apply(this, arguments) } } else if (newQuery.once) { newQuery .once('error', finish) - .once('end', () => finish()) + .once('end', (res) => finish(null, res)) } else { - newQuery.then(() => finish(), finish) + newQuery.then((res) => finish(null, res), finish) } try { diff --git a/packages/datadog-instrumentations/src/playwright.js b/packages/datadog-instrumentations/src/playwright.js index e8332d65c8d..4eab55b1797 100644 --- a/packages/datadog-instrumentations/src/playwright.js +++ b/packages/datadog-instrumentations/src/playwright.js @@ -47,7 +47,7 @@ function isNewTest (test) { const testSuite = getTestSuitePath(test._requireFile, rootDir) const testsForSuite = knownTests?.playwright?.[testSuite] || [] - return !testsForSuite.includes(test.title) + return !testsForSuite.includes(getTestFullname(test)) } function getSuiteType (test, type) { @@ -224,10 +224,21 @@ function testWillRetry (test, testStatus) { return testStatus === 'fail' && test.results.length <= test.retries } +function getTestFullname (test) { + let parent = test.parent + const names = [test.title] + while (parent?._type === 'describe' || parent?._isDescribe) { + if (parent.title) { + names.unshift(parent.title) + } + parent = parent.parent + } + return names.join(' ') +} + function testBeginHandler (test, browserName) { const { _requireFile: testSuiteAbsolutePath, - title: testName, _type, location: { line: testSourceLine @@ -238,6 +249,8 @@ function testBeginHandler (test, browserName) { return } + const testName = getTestFullname(test) + const isNewTestSuite = !startedSuites.includes(testSuiteAbsolutePath) if (isNewTestSuite) { @@ -412,7 +425,7 @@ function runnerHook (runnerExport, playwrightVersion) { } } catch (e) { isEarlyFlakeDetectionEnabled = false - log.error(e) + log.error('Playwright session start error', e) } if (isEarlyFlakeDetectionEnabled && semver.gte(playwrightVersion, MINIMUM_SUPPORTED_VERSION_EFD)) { @@ -425,7 +438,7 @@ function runnerHook (runnerExport, playwrightVersion) { } } catch (err) { isEarlyFlakeDetectionEnabled = false - log.error(err) + log.error('Playwright known tests error', err) } } diff --git a/packages/datadog-instrumentations/src/qs.js b/packages/datadog-instrumentations/src/qs.js deleted file mode 100644 index 3901f61b169..00000000000 --- a/packages/datadog-instrumentations/src/qs.js +++ /dev/null @@ -1,24 +0,0 @@ -'use strict' - -const { addHook, channel } = require('./helpers/instrument') -const shimmer = require('../../datadog-shimmer') - -const qsParseCh = channel('datadog:qs:parse:finish') - -function wrapParse (originalParse) { - return function () { - const qsParsedObj = originalParse.apply(this, arguments) - if (qsParseCh.hasSubscribers && qsParsedObj) { - qsParseCh.publish({ qs: qsParsedObj }) - } - return qsParsedObj - } -} - -addHook({ - name: 'qs', - versions: ['>=1'] -}, qs => { - shimmer.wrap(qs, 'parse', wrapParse) - return qs -}) diff --git a/packages/datadog-instrumentations/src/router.js b/packages/datadog-instrumentations/src/router.js index 00fbb6cec1a..bc9ff6152e5 100644 --- a/packages/datadog-instrumentations/src/router.js +++ b/packages/datadog-instrumentations/src/router.js @@ -169,11 +169,107 @@ function createWrapRouterMethod (name) { const wrapRouterMethod = createWrapRouterMethod('router') -addHook({ name: 'router', versions: ['>=1'] }, Router => { +addHook({ name: 'router', versions: ['>=1 <2'] }, Router => { shimmer.wrap(Router.prototype, 'use', wrapRouterMethod) shimmer.wrap(Router.prototype, 'route', wrapRouterMethod) return Router }) +const queryParserReadCh = channel('datadog:query:read:finish') + +addHook({ name: 'router', versions: ['>=2'] }, Router => { + const WrappedRouter = shimmer.wrapFunction(Router, function (originalRouter) { + return function wrappedMethod () { + const router = originalRouter.apply(this, arguments) + + shimmer.wrap(router, 'handle', function wrapHandle (originalHandle) { + return function wrappedHandle (req, res, next) { + const abortController = new AbortController() + + if (queryParserReadCh.hasSubscribers && req) { + queryParserReadCh.publish({ req, res, query: req.query, abortController }) + + if (abortController.signal.aborted) return + } + + return originalHandle.apply(this, arguments) + } + }) + + return router + } + }) + + shimmer.wrap(WrappedRouter.prototype, 'use', wrapRouterMethod) + shimmer.wrap(WrappedRouter.prototype, 'route', wrapRouterMethod) + + return WrappedRouter +}) + +const routerParamStartCh = channel('datadog:router:param:start') +const visitedParams = new WeakSet() + +function wrapHandleRequest (original) { + return function wrappedHandleRequest (req, res, next) { + if (routerParamStartCh.hasSubscribers && Object.keys(req.params).length && !visitedParams.has(req.params)) { + visitedParams.add(req.params) + + const abortController = new AbortController() + + routerParamStartCh.publish({ + req, + res, + params: req?.params, + abortController + }) + + if (abortController.signal.aborted) return + } + + return original.apply(this, arguments) + } +} + +addHook({ + name: 'router', file: 'lib/layer.js', versions: ['>=2'] +}, Layer => { + shimmer.wrap(Layer.prototype, 'handleRequest', wrapHandleRequest) + return Layer +}) + +function wrapParam (original) { + return function wrappedProcessParams () { + arguments[1] = shimmer.wrapFunction(arguments[1], (originalFn) => { + return function wrappedFn (req, res) { + if (routerParamStartCh.hasSubscribers && Object.keys(req.params).length && !visitedParams.has(req.params)) { + visitedParams.add(req.params) + + const abortController = new AbortController() + + routerParamStartCh.publish({ + req, + res, + params: req?.params, + abortController + }) + + if (abortController.signal.aborted) return + } + + return originalFn.apply(this, arguments) + } + }) + + return original.apply(this, arguments) + } +} + +addHook({ + name: 'router', versions: ['>=2'] +}, router => { + shimmer.wrap(router.prototype, 'param', wrapParam) + return router +}) + module.exports = { createWrapRouterMethod } diff --git a/packages/datadog-instrumentations/src/sequelize.js b/packages/datadog-instrumentations/src/sequelize.js index 8ba56ee8909..d8e41b17704 100644 --- a/packages/datadog-instrumentations/src/sequelize.js +++ b/packages/datadog-instrumentations/src/sequelize.js @@ -13,7 +13,7 @@ addHook({ name: 'sequelize', versions: ['>=4'] }, Sequelize => { const finishCh = channel('datadog:sequelize:query:finish') shimmer.wrap(Sequelize.prototype, 'query', query => { - return function (sql) { + return function (sql, options) { if (!startCh.hasSubscribers) { return query.apply(this, arguments) } @@ -27,9 +27,14 @@ addHook({ name: 'sequelize', versions: ['>=4'] }, Sequelize => { dialect = this.dialect.name } - function onFinish () { + function onFinish (result) { + const type = options?.type || 'RAW' + if (type === 'RAW' && result?.length > 1) { + result = result[0] + } + asyncResource.bind(function () { - finishCh.publish() + finishCh.publish({ result }) }, this).apply(this) } @@ -40,7 +45,7 @@ addHook({ name: 'sequelize', versions: ['>=4'] }, Sequelize => { }) const promise = query.apply(this, arguments) - promise.then(onFinish, onFinish) + promise.then(onFinish, () => { onFinish() }) return promise }, this).apply(this, arguments) diff --git a/packages/datadog-instrumentations/src/url.js b/packages/datadog-instrumentations/src/url.js index 18edb0079e3..67bef7e8947 100644 --- a/packages/datadog-instrumentations/src/url.js +++ b/packages/datadog-instrumentations/src/url.js @@ -59,6 +59,10 @@ addHook({ name: names }, function (url) { isURL: true }) } + + static [Symbol.hasInstance] (instance) { + return instance instanceof URL + } } }) diff --git a/packages/datadog-instrumentations/src/vitest.js b/packages/datadog-instrumentations/src/vitest.js index f0117e0e8c0..de7c6d2dc30 100644 --- a/packages/datadog-instrumentations/src/vitest.js +++ b/packages/datadog-instrumentations/src/vitest.js @@ -117,6 +117,7 @@ function getSortWrapper (sort) { let isEarlyFlakeDetectionEnabled = false let earlyFlakeDetectionNumRetries = 0 let isEarlyFlakeDetectionFaulty = false + let isDiEnabled = false let knownTests = {} try { @@ -126,10 +127,12 @@ function getSortWrapper (sort) { flakyTestRetriesCount = libraryConfig.flakyTestRetriesCount isEarlyFlakeDetectionEnabled = libraryConfig.isEarlyFlakeDetectionEnabled earlyFlakeDetectionNumRetries = libraryConfig.earlyFlakeDetectionNumRetries + isDiEnabled = libraryConfig.isDiEnabled } } catch (e) { isFlakyTestRetriesEnabled = false isEarlyFlakeDetectionEnabled = false + isDiEnabled = false } if (isFlakyTestRetriesEnabled && !this.ctx.config.retry && flakyTestRetriesCount > 0) { @@ -169,6 +172,15 @@ function getSortWrapper (sort) { } } + if (isDiEnabled) { + try { + const workspaceProject = this.ctx.getCoreWorkspaceProject() + workspaceProject._provided._ddIsDiEnabled = isDiEnabled + } catch (e) { + log.warn('Could not send Dynamic Instrumentation configuration to workers.') + } + } + let testCodeCoverageLinesTotal if (this.ctx.coverageProvider?.generateCoverage) { @@ -298,13 +310,16 @@ addHook({ const testName = getTestName(task) let isNew = false let isEarlyFlakeDetectionEnabled = false + let isDiEnabled = false try { const { - _ddIsEarlyFlakeDetectionEnabled + _ddIsEarlyFlakeDetectionEnabled, + _ddIsDiEnabled } = globalThis.__vitest_worker__.providedContext isEarlyFlakeDetectionEnabled = _ddIsEarlyFlakeDetectionEnabled + isDiEnabled = _ddIsDiEnabled if (isEarlyFlakeDetectionEnabled) { isNew = newTasks.has(task) @@ -316,12 +331,22 @@ addHook({ // We finish the previous test here because we know it has failed already if (numAttempt > 0) { + const probe = {} const asyncResource = taskToAsync.get(task) const testError = task.result?.errors?.[0] if (asyncResource) { asyncResource.runInAsyncScope(() => { - testErrorCh.publish({ error: testError }) + testErrorCh.publish({ + error: testError, + willBeRetried: true, + probe, + isDiEnabled + }) }) + // We wait for the probe to be set + if (probe.setProbePromise) { + await probe.setProbePromise + } } } diff --git a/packages/datadog-instrumentations/test/express.spec.js b/packages/datadog-instrumentations/test/express.spec.js index d21b9be3e0a..534bfd041e8 100644 --- a/packages/datadog-instrumentations/test/express.spec.js +++ b/packages/datadog-instrumentations/test/express.spec.js @@ -14,7 +14,7 @@ withVersions('express', 'express', version => { }) before((done) => { - const express = require('../../../versions/express').get() + const express = require(`../../../versions/express@${version}`).get() const app = express() app.get('/', (req, res) => { requestBody() diff --git a/packages/datadog-instrumentations/test/passport-http.spec.js b/packages/datadog-instrumentations/test/passport-http.spec.js index 2918c935e20..5cb0282ec2f 100644 --- a/packages/datadog-instrumentations/test/passport-http.spec.js +++ b/packages/datadog-instrumentations/test/passport-http.spec.js @@ -1,8 +1,9 @@ 'use strict' const agent = require('../../dd-trace/test/plugins/agent') -const axios = require('axios') +const axios = require('axios').create({ validateStatus: null }) const dc = require('dc-polyfill') +const { storage } = require('../../datadog-core') withVersions('passport-http', 'passport-http', version => { describe('passport-http instrumentation', () => { @@ -10,7 +11,7 @@ withVersions('passport-http', 'passport-http', version => { let port, server, subscriberStub before(() => { - return agent.load(['express', 'passport', 'passport-http'], { client: false }) + return agent.load(['http', 'express', 'passport', 'passport-http'], { client: false }) }) before((done) => { @@ -19,7 +20,17 @@ withVersions('passport-http', 'passport-http', version => { const BasicStrategy = require(`../../../versions/passport-http@${version}`).get().BasicStrategy const app = express() - passport.use(new BasicStrategy((username, password, done) => { + function validateUser (req, username, password, done) { + // support with or without passReqToCallback + if (typeof done !== 'function') { + done = password + password = username + username = req + } + + // simulate db error + if (username === 'error') return done('error') + const users = [{ _id: 1, username: 'test', @@ -35,7 +46,18 @@ withVersions('passport-http', 'passport-http', version => { return done(null, user) } } - )) + + passport.use('basic', new BasicStrategy({ + usernameField: 'username', + passwordField: 'password', + passReqToCallback: false + }, validateUser)) + + passport.use('basic-withreq', new BasicStrategy({ + usernameField: 'username', + passwordField: 'password', + passReqToCallback: true + }, validateUser)) app.use(passport.initialize()) app.use(express.json()) @@ -44,16 +66,14 @@ withVersions('passport-http', 'passport-http', version => { passport.authenticate('basic', { successRedirect: '/grant', failureRedirect: '/deny', - passReqToCallback: false, session: false }) ) - app.post('/req', - passport.authenticate('basic', { + app.get('/req', + passport.authenticate('basic-withreq', { successRedirect: '/grant', failureRedirect: '/deny', - passReqToCallback: true, session: false }) ) @@ -66,9 +86,7 @@ withVersions('passport-http', 'passport-http', version => { res.send('Denied') }) - passportVerifyChannel.subscribe(function ({ credentials, user, err, info }) { - subscriberStub(arguments[0]) - }) + passportVerifyChannel.subscribe((data) => subscriberStub(data)) server = app.listen(0, () => { port = server.address().port @@ -85,6 +103,18 @@ withVersions('passport-http', 'passport-http', version => { return agent.close({ ritmReset: false }) }) + it('should not call subscriber when an error occurs', async () => { + const res = await axios.get(`http://localhost:${port}/`, { + headers: { + // error:1234 + Authorization: 'Basic ZXJyb3I6MTIzNA==' + } + }) + + expect(res.status).to.equal(500) + expect(subscriberStub).to.not.be.called + }) + it('should call subscriber with proper arguments on success', async () => { const res = await axios.get(`http://localhost:${port}/`, { headers: { @@ -95,16 +125,17 @@ withVersions('passport-http', 'passport-http', version => { expect(res.status).to.equal(200) expect(res.data).to.equal('Granted') - expect(subscriberStub).to.be.calledOnceWithExactly( - { - credentials: { type: 'http', username: 'test' }, - user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' } - } - ) + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-basic', + login: 'test', + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' }, + success: true, + abortController: new AbortController() + }) }) it('should call subscriber with proper arguments on success with passReqToCallback set to true', async () => { - const res = await axios.get(`http://localhost:${port}/`, { + const res = await axios.get(`http://localhost:${port}/req`, { headers: { // test:1234 Authorization: 'Basic dGVzdDoxMjM0' @@ -113,12 +144,13 @@ withVersions('passport-http', 'passport-http', version => { expect(res.status).to.equal(200) expect(res.data).to.equal('Granted') - expect(subscriberStub).to.be.calledOnceWithExactly( - { - credentials: { type: 'http', username: 'test' }, - user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' } - } - ) + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-basic', + login: 'test', + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' }, + success: true, + abortController: new AbortController() + }) }) it('should call subscriber with proper arguments on failure', async () => { @@ -131,12 +163,37 @@ withVersions('passport-http', 'passport-http', version => { expect(res.status).to.equal(200) expect(res.data).to.equal('Denied') - expect(subscriberStub).to.be.calledOnceWithExactly( - { - credentials: { type: 'http', username: 'test' }, - user: false + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-basic', + login: 'test', + user: false, + success: false, + abortController: new AbortController() + }) + }) + + it('should block when subscriber aborts', async () => { + subscriberStub = sinon.spy(({ abortController }) => { + storage.getStore().req.res.writeHead(403).end('Blocked') + abortController.abort() + }) + + const res = await axios.get(`http://localhost:${port}/`, { + headers: { + // test:1234 + Authorization: 'Basic dGVzdDoxMjM0' } - ) + }) + + expect(res.status).to.equal(403) + expect(res.data).to.equal('Blocked') + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-basic', + login: 'test', + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' }, + success: true, + abortController: new AbortController() + }) }) }) }) diff --git a/packages/datadog-instrumentations/test/passport-local.spec.js b/packages/datadog-instrumentations/test/passport-local.spec.js index d54f02b289f..bcfc2e56dc9 100644 --- a/packages/datadog-instrumentations/test/passport-local.spec.js +++ b/packages/datadog-instrumentations/test/passport-local.spec.js @@ -1,8 +1,9 @@ 'use strict' const agent = require('../../dd-trace/test/plugins/agent') -const axios = require('axios') +const axios = require('axios').create({ validateStatus: null }) const dc = require('dc-polyfill') +const { storage } = require('../../datadog-core') withVersions('passport-local', 'passport-local', version => { describe('passport-local instrumentation', () => { @@ -10,7 +11,7 @@ withVersions('passport-local', 'passport-local', version => { let port, server, subscriberStub before(() => { - return agent.load(['express', 'passport', 'passport-local'], { client: false }) + return agent.load(['http', 'express', 'passport', 'passport-local'], { client: false }) }) before((done) => { @@ -19,24 +20,44 @@ withVersions('passport-local', 'passport-local', version => { const LocalStrategy = require(`../../../versions/passport-local@${version}`).get().Strategy const app = express() - passport.use(new LocalStrategy({ usernameField: 'username', passwordField: 'password' }, - (username, password, done) => { - const users = [{ - _id: 1, - username: 'test', - password: '1234', - email: 'testuser@ddog.com' - }] - - const user = users.find(user => (user.username === username) && (user.password === password)) - - if (!user) { - return done(null, false) - } else { - return done(null, user) - } + function validateUser (req, username, password, done) { + // support with or without passReqToCallback + if (typeof done !== 'function') { + done = password + password = username + username = req } - )) + + // simulate db error + if (username === 'error') return done('error') + + const users = [{ + _id: 1, + username: 'test', + password: '1234', + email: 'testuser@ddog.com' + }] + + const user = users.find(user => (user.username === username) && (user.password === password)) + + if (!user) { + return done(null, false) + } else { + return done(null, user) + } + } + + passport.use('local', new LocalStrategy({ + usernameField: 'username', + passwordField: 'password', + passReqToCallback: false + }, validateUser)) + + passport.use('local-withreq', new LocalStrategy({ + usernameField: 'username', + passwordField: 'password', + passReqToCallback: true + }, validateUser)) app.use(passport.initialize()) app.use(express.json()) @@ -45,16 +66,14 @@ withVersions('passport-local', 'passport-local', version => { passport.authenticate('local', { successRedirect: '/grant', failureRedirect: '/deny', - passReqToCallback: false, session: false }) ) app.post('/req', - passport.authenticate('local', { + passport.authenticate('local-withreq', { successRedirect: '/grant', failureRedirect: '/deny', - passReqToCallback: true, session: false }) ) @@ -67,9 +86,7 @@ withVersions('passport-local', 'passport-local', version => { res.send('Denied') }) - passportVerifyChannel.subscribe(function ({ credentials, user, err, info }) { - subscriberStub(arguments[0]) - }) + passportVerifyChannel.subscribe((data) => subscriberStub(data)) server = app.listen(0, () => { port = server.address().port @@ -86,17 +103,25 @@ withVersions('passport-local', 'passport-local', version => { return agent.close({ ritmReset: false }) }) + it('should not call subscriber when an error occurs', async () => { + const res = await axios.post(`http://localhost:${port}/`, { username: 'error', password: '1234' }) + + expect(res.status).to.equal(500) + expect(subscriberStub).to.not.be.called + }) + it('should call subscriber with proper arguments on success', async () => { const res = await axios.post(`http://localhost:${port}/`, { username: 'test', password: '1234' }) expect(res.status).to.equal(200) expect(res.data).to.equal('Granted') - expect(subscriberStub).to.be.calledOnceWithExactly( - { - credentials: { type: 'local', username: 'test' }, - user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' } - } - ) + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-local', + login: 'test', + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' }, + success: true, + abortController: new AbortController() + }) }) it('should call subscriber with proper arguments on success with passReqToCallback set to true', async () => { @@ -104,12 +129,13 @@ withVersions('passport-local', 'passport-local', version => { expect(res.status).to.equal(200) expect(res.data).to.equal('Granted') - expect(subscriberStub).to.be.calledOnceWithExactly( - { - credentials: { type: 'local', username: 'test' }, - user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' } - } - ) + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-local', + login: 'test', + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' }, + success: true, + abortController: new AbortController() + }) }) it('should call subscriber with proper arguments on failure', async () => { @@ -117,12 +143,32 @@ withVersions('passport-local', 'passport-local', version => { expect(res.status).to.equal(200) expect(res.data).to.equal('Denied') - expect(subscriberStub).to.be.calledOnceWithExactly( - { - credentials: { type: 'local', username: 'test' }, - user: false - } - ) + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-local', + login: 'test', + user: false, + success: false, + abortController: new AbortController() + }) + }) + + it('should block when subscriber aborts', async () => { + subscriberStub = sinon.spy(({ abortController }) => { + storage.getStore().req.res.writeHead(403).end('Blocked') + abortController.abort() + }) + + const res = await axios.post(`http://localhost:${port}/`, { username: 'test', password: '1234' }) + + expect(res.status).to.equal(403) + expect(res.data).to.equal('Blocked') + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-local', + login: 'test', + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' }, + success: true, + abortController: new AbortController() + }) }) }) }) diff --git a/packages/datadog-instrumentations/test/passport-utils.spec.js b/packages/datadog-instrumentations/test/passport-utils.spec.js deleted file mode 100644 index 3cf6a64a60a..00000000000 --- a/packages/datadog-instrumentations/test/passport-utils.spec.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict' - -const proxyquire = require('proxyquire') -const { channel } = require('../src/helpers/instrument') - -const passportVerifyChannel = channel('datadog:passport:verify:finish') - -describe('passport-utils', () => { - const shimmer = { - wrap: sinon.stub() - } - - let passportUtils - - beforeEach(() => { - passportUtils = proxyquire('../src/passport-utils', { - '../../datadog-shimmer': shimmer - }) - }) - - it('should not call wrap when there is no subscribers', () => { - const wrap = passportUtils.wrapVerify(() => {}, false, 'type') - - wrap() - expect(shimmer.wrap).not.to.have.been.called - }) - - it('should call wrap when there is subscribers', () => { - const wrap = passportUtils.wrapVerify(() => {}, false, 'type') - - passportVerifyChannel.subscribe(() => {}) - - wrap() - expect(shimmer.wrap).to.have.been.called - }) -}) diff --git a/packages/datadog-instrumentations/test/url.spec.js b/packages/datadog-instrumentations/test/url.spec.js index defb8f08193..57b99e5f897 100644 --- a/packages/datadog-instrumentations/test/url.spec.js +++ b/packages/datadog-instrumentations/test/url.spec.js @@ -1,6 +1,7 @@ 'use strict' const agent = require('../../dd-trace/test/plugins/agent') +const { assert } = require('chai') const { channel } = require('../src/helpers/instrument') const names = ['url', 'node:url'] @@ -68,6 +69,13 @@ names.forEach(name => { }, sinon.match.any) }) + it('instanceof should work also for original instances', () => { + const OriginalUrl = Object.getPrototypeOf(url.URL) + const originalUrl = new OriginalUrl('https://www.datadoghq.com') + + assert.isTrue(originalUrl instanceof url.URL) + }) + ;['host', 'origin', 'hostname'].forEach(property => { it(`should publish on get ${property}`, () => { const urlObject = new url.URL('/path', 'https://www.datadoghq.com') diff --git a/packages/datadog-plugin-avsc/src/schema_iterator.js b/packages/datadog-plugin-avsc/src/schema_iterator.js index c748bbf9e75..0b4874ceea8 100644 --- a/packages/datadog-plugin-avsc/src/schema_iterator.js +++ b/packages/datadog-plugin-avsc/src/schema_iterator.js @@ -108,10 +108,15 @@ class SchemaExtractor { if (!builder.shouldExtractSchema(schemaName, depth)) { return false } - for (const field of schema.fields) { - if (!this.extractProperty(field, schemaName, field.name, builder, depth)) { - log.warn(`DSM: Unable to extract field with name: ${field.name} from Avro schema with name: ${schemaName}`) + if (schema.fields?.[Symbol.iterator]) { + for (const field of schema.fields) { + if (!this.extractProperty(field, schemaName, field.name, builder, depth)) { + log.warn('DSM: Unable to extract field with name: %s from Avro schema with name: %s', field.name, + schemaName) + } } + } else { + log.warn('DSM: schema.fields is not iterable from Avro schema with name: %s', schemaName) } } return true diff --git a/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime.js b/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime.js index 64e62e64d90..5df5df976f3 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime.js +++ b/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime.js @@ -11,7 +11,7 @@ const META = 'META' const STABILITY = 'STABILITY' const MISTRAL = 'MISTRAL' -const enabledOperations = ['invokeModel'] // TODO check if this is the correct operation +const enabledOperations = ['invokeModel'] class BedrockRuntime extends BaseAwsSdkPlugin { static get id () { return 'bedrock runtime' } diff --git a/packages/datadog-plugin-aws-sdk/src/services/dynamodb.js b/packages/datadog-plugin-aws-sdk/src/services/dynamodb.js index 4097586b2c5..cbca2192ad6 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/dynamodb.js +++ b/packages/datadog-plugin-aws-sdk/src/services/dynamodb.js @@ -1,6 +1,9 @@ 'use strict' const BaseAwsSdkPlugin = require('../base') +const log = require('../../../dd-trace/src/log') +const { DYNAMODB_PTR_KIND, SPAN_POINTER_DIRECTION } = require('../../../dd-trace/src/constants') +const { extractPrimaryKeys, generatePointerHash } = require('../util') class DynamoDb extends BaseAwsSdkPlugin { static get id () { return 'dynamodb' } @@ -48,6 +51,157 @@ class DynamoDb extends BaseAwsSdkPlugin { return tags } + + addSpanPointers (span, response) { + const request = response?.request + const operationName = request?.operation + + const hashes = [] + switch (operationName) { + case 'putItem': { + const hash = DynamoDb.calculatePutItemHash( + request?.params?.TableName, + request?.params?.Item, + this.getPrimaryKeyConfig() + ) + if (hash) hashes.push(hash) + break + } + case 'updateItem': + case 'deleteItem': { + const hash = DynamoDb.calculateHashWithKnownKeys(request?.params?.TableName, request?.params?.Key) + if (hash) hashes.push(hash) + break + } + case 'transactWriteItems': { + const transactItems = request?.params?.TransactItems || [] + for (const item of transactItems) { + if (item.Put) { + const hash = + DynamoDb.calculatePutItemHash(item.Put.TableName, item.Put.Item, this.getPrimaryKeyConfig()) + if (hash) hashes.push(hash) + } else if (item.Update || item.Delete) { + const operation = item.Update ? item.Update : item.Delete + const hash = DynamoDb.calculateHashWithKnownKeys(operation.TableName, operation.Key) + if (hash) hashes.push(hash) + } + } + break + } + case 'batchWriteItem': { + const requestItems = request?.params.RequestItems || {} + for (const [tableName, operations] of Object.entries(requestItems)) { + if (!Array.isArray(operations)) continue + for (const operation of operations) { + if (operation?.PutRequest) { + const hash = + DynamoDb.calculatePutItemHash(tableName, operation.PutRequest.Item, this.getPrimaryKeyConfig()) + if (hash) hashes.push(hash) + } else if (operation?.DeleteRequest) { + const hash = DynamoDb.calculateHashWithKnownKeys(tableName, operation.DeleteRequest.Key) + if (hash) hashes.push(hash) + } + } + } + break + } + } + + for (const hash of hashes) { + span.addSpanPointer(DYNAMODB_PTR_KIND, SPAN_POINTER_DIRECTION.DOWNSTREAM, hash) + } + } + + /** + * Parses primary key config from the `DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS` env var. + * Only runs when needed, and warns when missing or invalid config. + * @returns {Object|undefined} Parsed config from env var or undefined if empty/missing/invalid config. + */ + getPrimaryKeyConfig () { + if (this.dynamoPrimaryKeyConfig) { + // Return cached config if it exists + return this.dynamoPrimaryKeyConfig + } + + const configStr = this._tracerConfig?.aws?.dynamoDb?.tablePrimaryKeys + if (!configStr) { + log.warn('Missing DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS env variable. ' + + 'Please add your table\'s primary keys under this env variable.') + return + } + + try { + const parsedConfig = JSON.parse(configStr) + const config = {} + for (const [tableName, primaryKeys] of Object.entries(parsedConfig)) { + if (Array.isArray(primaryKeys) && primaryKeys.length > 0 && primaryKeys.length <= 2) { + config[tableName] = new Set(primaryKeys) + } else { + log.warn(`Invalid primary key configuration for table: ${tableName}.` + + 'Please fix the DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS env var.') + } + } + + this.dynamoPrimaryKeyConfig = config + return config + } catch (err) { + log.warn('Failed to parse DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS:', err.message) + } + } + + /** + * Calculates a hash for DynamoDB PutItem operations using table's configured primary keys. + * @param {string} tableName - Name of the DynamoDB table. + * @param {Object} item - Complete PutItem item parameter to be put. + * @param {Object.>} primaryKeyConfig - Mapping of table names to Sets of primary key names + * loaded from DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS. + * @returns {string|undefined} Hash combining table name and primary key/value pairs, or undefined if unable. + */ + static calculatePutItemHash (tableName, item, primaryKeyConfig) { + if (!tableName || !item) { + log.debug('Unable to calculate hash because missing required parameters') + return + } + if (!primaryKeyConfig) { + log.warn('Missing DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS env variable') + return + } + const primaryKeySet = primaryKeyConfig[tableName] + if (!primaryKeySet || !(primaryKeySet instanceof Set) || primaryKeySet.size === 0 || primaryKeySet.size > 2) { + log.warn( + `span pointers: failed to extract PutItem span pointer: table ${tableName} ` + + 'not found in primary key names or the DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS env var was invalid.' + + 'Please update the env var.' + ) + return + } + const keyValues = extractPrimaryKeys(primaryKeySet, item) + if (keyValues) { + return generatePointerHash([tableName, ...keyValues]) + } + } + + /** + * Calculates a hash for DynamoDB operations that have keys provided (UpdateItem, DeleteItem). + * @param {string} tableName - Name of the DynamoDB table. + * @param {Object} keysObject - Object containing primary key/value attributes in DynamoDB format. + * (e.g., { userId: { S: "123" }, sortKey: { N: "456" } }) + * @returns {string|undefined} Hash value combining table name and primary key/value pairs, or undefined if unable. + * + * @example + * calculateHashWithKnownKeys('UserTable', { userId: { S: "user123" }, timestamp: { N: "1234567" } }) + */ + static calculateHashWithKnownKeys (tableName, keysObject) { + if (!tableName || !keysObject) { + log.debug('Unable to calculate hash because missing parameters') + return + } + const keyNamesSet = new Set(Object.keys(keysObject)) + const keyValues = extractPrimaryKeys(keyNamesSet, keysObject) + if (keyValues) { + return generatePointerHash([tableName, ...keyValues]) + } + } } module.exports = DynamoDb diff --git a/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js b/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js index b316f75e6be..a5ca5f08de1 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js +++ b/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js @@ -45,7 +45,7 @@ class EventBridge extends BaseAwsSdkPlugin { } request.params.Entries[0].Detail = finalData } catch (e) { - log.error(e) + log.error('EventBridge error injecting request', e) } } } diff --git a/packages/datadog-plugin-aws-sdk/src/services/kinesis.js b/packages/datadog-plugin-aws-sdk/src/services/kinesis.js index 64a67d768ea..cdbd7c077e9 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/kinesis.js +++ b/packages/datadog-plugin-aws-sdk/src/services/kinesis.js @@ -97,7 +97,7 @@ class Kinesis extends BaseAwsSdkPlugin { parsedAttributes: decodedData._datadog } } catch (e) { - log.error(e) + log.error('Kinesis error extracting response', e) } } diff --git a/packages/datadog-plugin-aws-sdk/src/services/lambda.js b/packages/datadog-plugin-aws-sdk/src/services/lambda.js index f6ea874872e..b5fe1981c20 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/lambda.js +++ b/packages/datadog-plugin-aws-sdk/src/services/lambda.js @@ -43,7 +43,7 @@ class Lambda extends BaseAwsSdkPlugin { const newContextBase64 = Buffer.from(JSON.stringify(clientContext)).toString('base64') request.params.ClientContext = newContextBase64 } catch (err) { - log.error(err) + log.error('Lambda error injecting request', err) } } } diff --git a/packages/datadog-plugin-aws-sdk/src/services/s3.js b/packages/datadog-plugin-aws-sdk/src/services/s3.js index 5fcfb6ed165..d860223d67b 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/s3.js +++ b/packages/datadog-plugin-aws-sdk/src/services/s3.js @@ -2,7 +2,7 @@ const BaseAwsSdkPlugin = require('../base') const log = require('../../../dd-trace/src/log') -const { generatePointerHash } = require('../../../dd-trace/src/util') +const { generatePointerHash } = require('../util') const { S3_PTR_KIND, SPAN_POINTER_DIRECTION } = require('../../../dd-trace/src/constants') class S3 extends BaseAwsSdkPlugin { diff --git a/packages/datadog-plugin-aws-sdk/src/services/sqs.js b/packages/datadog-plugin-aws-sdk/src/services/sqs.js index e3a76c3e0b9..9857e46bf28 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/sqs.js +++ b/packages/datadog-plugin-aws-sdk/src/services/sqs.js @@ -163,7 +163,7 @@ class Sqs extends BaseAwsSdkPlugin { return JSON.parse(buffer) } } catch (e) { - log.error(e) + log.error('Sqs error parsing DD attributes', e) } } diff --git a/packages/datadog-plugin-aws-sdk/src/util.js b/packages/datadog-plugin-aws-sdk/src/util.js new file mode 100644 index 00000000000..4bb7e86c8cd --- /dev/null +++ b/packages/datadog-plugin-aws-sdk/src/util.js @@ -0,0 +1,92 @@ +'use strict' + +const crypto = require('crypto') +const log = require('../../dd-trace/src/log') + +/** + * Generates a unique hash from an array of strings by joining them with | before hashing. + * Used to uniquely identify AWS requests for span pointers. + * @param {string[]} components - Array of strings to hash + * @returns {string} A 32-character hash uniquely identifying the components + */ +function generatePointerHash (components) { + // If passing S3's ETag as a component, make sure any quotes have already been removed! + const dataToHash = components.join('|') + const hash = crypto.createHash('sha256').update(dataToHash).digest('hex') + return hash.substring(0, 32) +} + +/** + * Encodes a DynamoDB attribute value to Buffer for span pointer hashing. + * @param {Object} valueObject - DynamoDB value in AWS format ({ S: string } or { N: string } or { B: Buffer }) + * @returns {Buffer|undefined} Encoded value as Buffer, or undefined if invalid input. + * + * @example + * encodeValue({ S: "user123" }) -> Buffer("user123") + * encodeValue({ N: "42" }) -> Buffer("42") + * encodeValue({ B: Buffer([1, 2, 3]) }) -> Buffer([1, 2, 3]) + */ +function encodeValue (valueObject) { + if (!valueObject) { + return + } + + try { + const type = Object.keys(valueObject)[0] + const value = valueObject[type] + + switch (type) { + case 'S': + return Buffer.from(value) + case 'N': + return Buffer.from(value.toString()) + case 'B': + return Buffer.isBuffer(value) ? value : Buffer.from(value) + default: + log.debug(`Found unknown type while trying to create DynamoDB span pointer: ${type}`) + } + } catch (err) { + log.debug(`Failed to encode value while trying to create DynamoDB span pointer: ${err.message}`) + } +} + +/** + * Extracts and encodes primary key values from a DynamoDB item. + * Handles tables with single-key and two-key scenarios. + * + * @param {Set} keySet - Set of primary key names. + * @param {Object} keyValuePairs - Object containing key/value pairs. + * @returns {Array|undefined} [key1Name, key1Value, key2Name, key2Value], or undefined if invalid input. + * key2 entries are empty strings in the single-key case. + * @example + * extractPrimaryKeys(new Set(['userId']), {userId: {S: "user123"}}) + * // Returns ["userId", Buffer("user123"), "", ""] + * extractPrimaryKeys(new Set(['userId', 'timestamp']), {userId: {S: "user123"}, timestamp: {N: "1234}}) + * // Returns ["timestamp", Buffer.from("1234"), "userId", Buffer.from("user123")] + */ +const extractPrimaryKeys = (keySet, keyValuePairs) => { + const keyNames = Array.from(keySet) + if (keyNames.length === 0) { + return + } + + if (keyNames.length === 1) { + const value = encodeValue(keyValuePairs[keyNames[0]]) + if (value) { + return [keyNames[0], value, '', ''] + } + } else { + const [key1, key2] = keyNames.sort() + const value1 = encodeValue(keyValuePairs[key1]) + const value2 = encodeValue(keyValuePairs[key2]) + if (value1 && value2) { + return [key1, value1, key2, value2] + } + } +} + +module.exports = { + generatePointerHash, + encodeValue, + extractPrimaryKeys +} diff --git a/packages/datadog-plugin-aws-sdk/test/dynamodb.spec.js b/packages/datadog-plugin-aws-sdk/test/dynamodb.spec.js new file mode 100644 index 00000000000..7fba9babfb0 --- /dev/null +++ b/packages/datadog-plugin-aws-sdk/test/dynamodb.spec.js @@ -0,0 +1,831 @@ +'use strict' + +const agent = require('../../dd-trace/test/plugins/agent') +const { setup } = require('./spec_helpers') +const axios = require('axios') +const { DYNAMODB_PTR_KIND, SPAN_POINTER_DIRECTION } = require('../../dd-trace/src/constants') +const DynamoDb = require('../src/services/dynamodb') +const { generatePointerHash } = require('../src/util') + +/* eslint-disable no-console */ +async function resetLocalStackDynamo () { + try { + await axios.post('http://localhost:4566/reset') + console.log('LocalStack Dynamo reset successful') + } catch (error) { + console.error('Error resetting LocalStack Dynamo:', error.message) + } +} + +describe('Plugin', () => { + describe('aws-sdk (dynamodb)', function () { + setup() + + withVersions('aws-sdk', ['aws-sdk', '@aws-sdk/smithy-client'], (version, moduleName) => { + let tracer + let AWS + let dynamo + + const dynamoClientName = moduleName === '@aws-sdk/smithy-client' ? '@aws-sdk/client-dynamodb' : 'aws-sdk' + + // Test both cases: tables with only partition key and with partition+sort key. + const oneKeyTableName = 'OneKeyTable' + const twoKeyTableName = 'TwoKeyTable' + + describe('with configuration', () => { + before(() => { + tracer = require('../../dd-trace') + tracer.init() + return agent.load('aws-sdk') + }) + + before(async () => { + AWS = require(`../../../versions/${dynamoClientName}@${version}`).get() + dynamo = new AWS.DynamoDB({ endpoint: 'http://127.0.0.1:4566', region: 'us-east-1' }) + + const deleteTable = async (tableName) => { + if (dynamoClientName === '@aws-sdk/client-dynamodb') { + try { + await dynamo.deleteTable({ TableName: tableName }) + await new Promise(resolve => setTimeout(resolve, 1000)) + } catch (err) { + if (err.name !== 'ResourceNotFoundException') { + throw err + } + } + } else { + try { + if (typeof dynamo.deleteTable({}).promise === 'function') { + await dynamo.deleteTable({ TableName: tableName }).promise() + await dynamo.waitFor('tableNotExists', { TableName: tableName }).promise() + } else { + await new Promise((resolve, reject) => { + dynamo.deleteTable({ TableName: tableName }, (err) => { + if (err && err.code !== 'ResourceNotFoundException') { + reject(err) + } else { + resolve() + } + }) + }) + } + } catch (err) { + if (err.code !== 'ResourceNotFoundException') { + throw err + } + } + } + } + + const createTable = async (params) => { + if (dynamoClientName === '@aws-sdk/client-dynamodb') { + await dynamo.createTable(params) + } else { + if (typeof dynamo.createTable({}).promise === 'function') { + await dynamo.createTable(params).promise() + } else { + await new Promise((resolve, reject) => { + dynamo.createTable(params, (err, data) => { + if (err) reject(err) + else resolve(data) + }) + }) + } + } + } + + // Delete existing tables + await deleteTable(oneKeyTableName) + await deleteTable(twoKeyTableName) + + // Create tables + await createTable({ + TableName: oneKeyTableName, + KeySchema: [{ AttributeName: 'name', KeyType: 'HASH' }], + AttributeDefinitions: [{ AttributeName: 'name', AttributeType: 'S' }], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + }) + + await createTable({ + TableName: twoKeyTableName, + KeySchema: [ + { AttributeName: 'id', KeyType: 'HASH' }, + { AttributeName: 'binary', KeyType: 'RANGE' } + ], + AttributeDefinitions: [ + { AttributeName: 'id', AttributeType: 'N' }, + { AttributeName: 'binary', AttributeType: 'B' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + }) + }) + + after(async () => { + await resetLocalStackDynamo() + return agent.close({ ritmReset: false }) + }) + + describe('span pointers', () => { + beforeEach(() => { + DynamoDb.dynamoPrimaryKeyConfig = null + delete process.env.DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS + }) + + function testSpanPointers ({ expectedHashes, operation }) { + let expectedLength = 0 + if (expectedHashes) { + expectedLength = Array.isArray(expectedHashes) ? expectedHashes.length : 1 + } + return (done) => { + operation((err) => { + if (err) { + return done(err) + } + + agent.use(traces => { + try { + const span = traces[0][0] + const links = JSON.parse(span.meta?.['_dd.span_links'] || '[]') + expect(links).to.have.lengthOf(expectedLength) + + if (expectedHashes) { + if (Array.isArray(expectedHashes)) { + expectedHashes.forEach((hash, i) => { + expect(links[i].attributes['ptr.hash']).to.equal(hash) + }) + } else { + expect(links[0].attributes).to.deep.equal({ + 'ptr.kind': DYNAMODB_PTR_KIND, + 'ptr.dir': SPAN_POINTER_DIRECTION.DOWNSTREAM, + 'ptr.hash': expectedHashes, + 'link.kind': 'span-pointer' + }) + } + } + return done() + } catch (error) { + return done(error) + } + }).catch(error => { + done(error) + }) + }) + } + } + + describe('1-key table', () => { + it('should add span pointer for putItem when config is valid', () => { + testSpanPointers({ + expectedHashes: '27f424c8202ab35efbf8b0b444b1928f', + operation: (callback) => { + process.env.DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS = + '{"OneKeyTable": ["name"]}' + dynamo.putItem({ + TableName: oneKeyTableName, + Item: { + name: { S: 'test1' }, + foo: { S: 'bar1' } + } + }, callback) + } + }) + }) + + it('should not add links or error for putItem when config is invalid', () => { + testSpanPointers({ + operation: (callback) => { + process.env.DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS = '{"DifferentTable": ["test"]}' + dynamo.putItem({ + TableName: oneKeyTableName, + Item: { + name: { S: 'test2' }, + foo: { S: 'bar2' } + } + }, callback) + } + }) + }) + + it('should not add links or error for putItem when config is missing', () => { + testSpanPointers({ + operation: (callback) => { + process.env.DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS = null + dynamo.putItem({ + TableName: oneKeyTableName, + Item: { + name: { S: 'test3' }, + foo: { S: 'bar3' } + } + }, callback) + } + }) + }) + + it('should add span pointer for updateItem', () => { + testSpanPointers({ + expectedHashes: '27f424c8202ab35efbf8b0b444b1928f', + operation: (callback) => { + dynamo.updateItem({ + TableName: oneKeyTableName, + Key: { name: { S: 'test1' } }, + AttributeUpdates: { + foo: { + Action: 'PUT', + Value: { S: 'bar4' } + } + } + }, callback) + } + }) + }) + + it('should add span pointer for deleteItem', () => { + testSpanPointers({ + expectedHashes: '27f424c8202ab35efbf8b0b444b1928f', + operation: (callback) => { + dynamo.deleteItem({ + TableName: oneKeyTableName, + Key: { name: { S: 'test1' } } + }, callback) + } + }) + }) + + it('should add span pointers for transactWriteItems', () => { + // Skip for older versions that don't support transactWriteItems + if (typeof dynamo.transactWriteItems !== 'function') { + return + } + testSpanPointers({ + expectedHashes: [ + '955ab85fc7d1d63fe4faf18696514f13', + '856c95a173d9952008a70283175041fc', + '9682c132f1900106a792f166d0619e0b' + ], + operation: (callback) => { + process.env.DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS = '{"OneKeyTable": ["name"]}' + dynamo.transactWriteItems({ + TransactItems: [ + { + Put: { + TableName: oneKeyTableName, + Item: { + name: { S: 'test4' }, + foo: { S: 'bar4' } + } + } + }, + { + Update: { + TableName: oneKeyTableName, + Key: { name: { S: 'test2' } }, + UpdateExpression: 'SET foo = :newfoo', + ExpressionAttributeValues: { + ':newfoo': { S: 'bar5' } + } + } + }, + { + Delete: { + TableName: oneKeyTableName, + Key: { name: { S: 'test3' } } + } + } + ] + }, callback) + } + }) + }) + + it('should add span pointers for batchWriteItem', () => { + // Skip for older versions that don't support batchWriteItem + if (typeof dynamo.batchWriteItem !== 'function') { + return + } + testSpanPointers({ + expectedHashes: [ + '955ab85fc7d1d63fe4faf18696514f13', + '9682c132f1900106a792f166d0619e0b' + ], + operation: (callback) => { + process.env.DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS = '{"OneKeyTable": ["name"]}' + dynamo.batchWriteItem({ + RequestItems: { + [oneKeyTableName]: [ + { + PutRequest: { + Item: { + name: { S: 'test4' }, + foo: { S: 'bar4' } + } + } + }, + { + DeleteRequest: { + Key: { + name: { S: 'test3' } + } + } + } + ] + } + }, callback) + } + }) + }) + }) + + describe('2-key table', () => { + it('should add span pointer for putItem when config is valid', () => { + testSpanPointers({ + expectedHashes: 'cc32f0e49ee05d3f2820ccc999bfe306', + operation: (callback) => { + process.env.DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS = '{"TwoKeyTable": ["id", "binary"]}' + dynamo.putItem({ + TableName: twoKeyTableName, + Item: { + id: { N: '1' }, + binary: { B: Buffer.from('Hello world 1') } + } + }, callback) + } + }) + }) + + it('should not add links or error for putItem when config is invalid', () => { + testSpanPointers({ + operation: (callback) => { + process.env.DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS = '{"DifferentTable": ["test"]}' + dynamo.putItem({ + TableName: twoKeyTableName, + Item: { + id: { N: '2' }, + binary: { B: Buffer.from('Hello world 2') } + } + }, callback) + } + }) + }) + + it('should not add links or error for putItem when config is missing', () => { + testSpanPointers({ + operation: (callback) => { + process.env.DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS = null + dynamo.putItem({ + TableName: twoKeyTableName, + Item: { + id: { N: '3' }, + binary: { B: Buffer.from('Hello world 3') } + } + }, callback) + } + }) + }) + + it('should add span pointer for updateItem', function (done) { + dynamo.putItem({ + TableName: twoKeyTableName, + Item: { + id: { N: '100' }, + binary: { B: Buffer.from('abc') } + } + }, async function (err) { + if (err) { + return done(err) + } + await new Promise(resolve => setTimeout(resolve, 100)) + testSpanPointers({ + expectedHashes: '5dac7d25254d596482a3c2c187e51046', + operation: (callback) => { + dynamo.updateItem({ + TableName: twoKeyTableName, + Key: { + id: { N: '100' }, + binary: { B: Buffer.from('abc') } + }, + AttributeUpdates: { + someOtherField: { + Action: 'PUT', + Value: { S: 'new value' } + } + } + }, callback) + } + })(done) + }) + }) + + it('should add span pointer for deleteItem', function (done) { + dynamo.putItem({ + TableName: twoKeyTableName, + Item: { + id: { N: '200' }, + binary: { B: Buffer.from('Hello world') } + } + }, async function (err) { + if (err) return done(err) + await new Promise(resolve => setTimeout(resolve, 100)) + testSpanPointers({ + expectedHashes: 'c356b0dd48c734d889e95122750c2679', + operation: (callback) => { + dynamo.deleteItem({ + TableName: twoKeyTableName, + Key: { + id: { N: '200' }, + binary: { B: Buffer.from('Hello world') } + } + }, callback) + } + })(done) + }) + }) + + it('should add span pointers for transactWriteItems', () => { + // Skip for older versions that don't support transactWriteItems + if (typeof dynamo.transactWriteItems !== 'function') { + return + } + testSpanPointers({ + expectedHashes: [ + 'dd071963cd90e4b3088043f0b9a9f53c', + '7794824f72d673ac7844353bc3ea25d9', + '8a6f801cc4e7d1d5e0dd37e0904e6316' + ], + operation: (callback) => { + process.env.DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS = '{"TwoKeyTable": ["id", "binary"]}' + dynamo.transactWriteItems({ + TransactItems: [ + { + Put: { + TableName: twoKeyTableName, + Item: { + id: { N: '4' }, + binary: { B: Buffer.from('Hello world 4') } + } + } + }, + { + Update: { + TableName: twoKeyTableName, + Key: { + id: { N: '2' }, + binary: { B: Buffer.from('Hello world 2') } + }, + AttributeUpdates: { + someOtherField: { + Action: 'PUT', + Value: { S: 'new value' } + } + } + } + }, + { + Delete: { + TableName: twoKeyTableName, + Key: { + id: { N: '3' }, + binary: { B: Buffer.from('Hello world 3') } + } + } + } + ] + }, callback) + } + }) + }) + + it('should add span pointers for batchWriteItem', () => { + // Skip for older versions that don't support batchWriteItem + if (typeof dynamo.batchWriteItem !== 'function') { + return + } + testSpanPointers({ + expectedHashes: [ + '1f64650acbe1ae4d8413049c6bd9bbe8', + '8a6f801cc4e7d1d5e0dd37e0904e6316' + ], + operation: (callback) => { + process.env.DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS = '{"TwoKeyTable": ["id", "binary"]}' + dynamo.batchWriteItem({ + RequestItems: { + [twoKeyTableName]: [ + { + PutRequest: { + Item: { + id: { N: '5' }, + binary: { B: Buffer.from('Hello world 5') } + } + } + }, + { + DeleteRequest: { + Key: { + id: { N: '3' }, + binary: { B: Buffer.from('Hello world 3') } + } + } + } + ] + } + }, callback) + } + }) + }) + }) + }) + }) + }) + + describe('getPrimaryKeyConfig', () => { + let dynamoDbInstance + + beforeEach(() => { + dynamoDbInstance = new DynamoDb() + dynamoDbInstance.dynamoPrimaryKeyConfig = null + dynamoDbInstance._tracerConfig = {} + }) + + it('should return cached config if available', () => { + const cachedConfig = { Table1: new Set(['key1']) } + dynamoDbInstance.dynamoPrimaryKeyConfig = cachedConfig + + const result = dynamoDbInstance.getPrimaryKeyConfig() + expect(result).to.equal(cachedConfig) + }) + + it('should return undefined when config str is missing', () => { + const result = dynamoDbInstance.getPrimaryKeyConfig() + expect(result).to.be.undefined + }) + + it('should parse valid config with single table', () => { + const configStr = '{"Table1": ["key1", "key2"]}' + dynamoDbInstance._tracerConfig = { aws: { dynamoDb: { tablePrimaryKeys: configStr } } } + + const result = dynamoDbInstance.getPrimaryKeyConfig() + expect(result).to.deep.equal({ + Table1: new Set(['key1', 'key2']) + }) + }) + + it('should parse valid config with multiple tables', () => { + const configStr = '{"Table1": ["key1"], "Table2": ["key2", "key3"]}' + dynamoDbInstance._tracerConfig = { aws: { dynamoDb: { tablePrimaryKeys: configStr } } } + + const result = dynamoDbInstance.getPrimaryKeyConfig() + expect(result).to.deep.equal({ + Table1: new Set(['key1']), + Table2: new Set(['key2', 'key3']) + }) + }) + }) + + describe('calculatePutItemHash', () => { + it('generates correct hash for single string key', () => { + const tableName = 'UserTable' + const item = { userId: { S: 'user123' }, name: { S: 'John' } } + const keyConfig = { UserTable: new Set(['userId']) } + + const actualHash = DynamoDb.calculatePutItemHash(tableName, item, keyConfig) + const expectedHash = generatePointerHash([tableName, 'userId', 'user123', '', '']) + expect(actualHash).to.equal(expectedHash) + }) + + it('generates correct hash for single number key', () => { + const tableName = 'OrderTable' + const item = { orderId: { N: '98765' }, total: { N: '50.00' } } + const keyConfig = { OrderTable: new Set(['orderId']) } + + const actualHash = DynamoDb.calculatePutItemHash(tableName, item, keyConfig) + const expectedHash = generatePointerHash([tableName, 'orderId', '98765', '', '']) + expect(actualHash).to.equal(expectedHash) + }) + + it('generates correct hash for single binary key', () => { + const tableName = 'BinaryTable' + const binaryData = Buffer.from([1, 2, 3]) + const item = { binaryId: { B: binaryData }, data: { S: 'test' } } + const keyConfig = { BinaryTable: new Set(['binaryId']) } + + const actualHash = DynamoDb.calculatePutItemHash(tableName, item, keyConfig) + const expectedHash = generatePointerHash([tableName, 'binaryId', binaryData, '', '']) + expect(actualHash).to.equal(expectedHash) + }) + + it('generates correct hash for string-string key', () => { + const tableName = 'UserEmailTable' + const item = { + userId: { S: 'user123' }, + email: { S: 'test@example.com' }, + verified: { BOOL: true } + } + const keyConfig = { UserEmailTable: new Set(['userId', 'email']) } + + const actualHash = DynamoDb.calculatePutItemHash(tableName, item, keyConfig) + const expectedHash = generatePointerHash([tableName, 'email', 'test@example.com', 'userId', 'user123']) + expect(actualHash).to.equal(expectedHash) + }) + + it('generates correct hash for string-number key', () => { + const tableName = 'UserActivityTable' + const item = { + userId: { S: 'user123' }, + timestamp: { N: '1234567' }, + action: { S: 'login' } + } + const keyConfig = { UserActivityTable: new Set(['userId', 'timestamp']) } + + const actualHash = DynamoDb.calculatePutItemHash(tableName, item, keyConfig) + const expectedHash = generatePointerHash([tableName, 'timestamp', '1234567', 'userId', 'user123']) + expect(actualHash).to.equal(expectedHash) + }) + + it('generates correct hash for binary-binary key', () => { + const tableName = 'BinaryTable' + const binary1 = Buffer.from('abc') + const binary2 = Buffer.from('1ef230') + const item = { + key1: { B: binary1 }, + key2: { B: binary2 }, + data: { S: 'test' } + } + const keyConfig = { BinaryTable: new Set(['key1', 'key2']) } + + const actualHash = DynamoDb.calculatePutItemHash(tableName, item, keyConfig) + const expectedHash = generatePointerHash([tableName, 'key1', binary1, 'key2', binary2]) + expect(actualHash).to.equal(expectedHash) + }) + + it('generates unique hashes for different tables', () => { + const item = { userId: { S: 'user123' } } + const keyConfig = { + Table1: new Set(['userId']), + Table2: new Set(['userId']) + } + + const hash1 = DynamoDb.calculatePutItemHash('Table1', item, keyConfig) + const hash2 = DynamoDb.calculatePutItemHash('Table2', item, keyConfig) + expect(hash1).to.not.equal(hash2) + }) + + describe('edge cases', () => { + it('returns undefined for unknown table', () => { + const tableName = 'UnknownTable' + const item = { userId: { S: 'user123' } } + const keyConfig = { KnownTable: new Set(['userId']) } + + const result = DynamoDb.calculatePutItemHash(tableName, item, keyConfig) + expect(result).to.be.undefined + }) + + it('returns undefined for empty primary key config', () => { + const tableName = 'UserTable' + const item = { userId: { S: 'user123' } } + + const result = DynamoDb.calculatePutItemHash(tableName, item, {}) + expect(result).to.be.undefined + }) + + it('returns undefined for invalid primary key config', () => { + const tableName = 'UserTable' + const item = { userId: { S: 'user123' } } + const invalidConfig = { UserTable: ['userId'] } // Array instead of Set + + const result = DynamoDb.calculatePutItemHash(tableName, item, invalidConfig) + expect(result).to.be.undefined + }) + + it('returns undefined when missing attributes in item', () => { + const tableName = 'UserTable' + const item = { someOtherField: { S: 'value' } } + const keyConfig = { UserTable: new Set(['userId']) } + + const actualHash = DynamoDb.calculatePutItemHash(tableName, item, keyConfig) + expect(actualHash).to.be.undefined + }) + + it('returns undefined for Set with more than 2 keys', () => { + const tableName = 'TestTable' + const item = { key1: { S: 'value1' }, key2: { S: 'value2' }, key3: { S: 'value3' } } + const keyConfig = { TestTable: new Set(['key1', 'key2', 'key3']) } + + const result = DynamoDb.calculatePutItemHash(tableName, item, keyConfig) + expect(result).to.be.undefined + }) + + it('returns undefined for empty keyConfig', () => { + const result = DynamoDb.calculatePutItemHash('TestTable', {}, {}) + expect(result).to.be.undefined + }) + + it('returns undefined for undefined keyConfig', () => { + const result = DynamoDb.calculatePutItemHash('TestTable', {}, undefined) + expect(result).to.be.undefined + }) + }) + }) + + describe('calculateHashWithKnownKeys', () => { + it('generates correct hash for single string key', () => { + const tableName = 'UserTable' + const keys = { userId: { S: 'user123' } } + const actualHash = DynamoDb.calculateHashWithKnownKeys(tableName, keys) + const expectedHash = generatePointerHash([tableName, 'userId', 'user123', '', '']) + expect(actualHash).to.equal(expectedHash) + }) + + it('generates correct hash for single number key', () => { + const tableName = 'OrderTable' + const keys = { orderId: { N: '98765' } } + const actualHash = DynamoDb.calculateHashWithKnownKeys(tableName, keys) + const expectedHash = generatePointerHash([tableName, 'orderId', '98765', '', '']) + expect(actualHash).to.equal(expectedHash) + }) + + it('generates correct hash for single binary key', () => { + const tableName = 'BinaryTable' + const binaryData = Buffer.from([1, 2, 3]) + const keys = { binaryId: { B: binaryData } } + const actualHash = DynamoDb.calculateHashWithKnownKeys(tableName, keys) + const expectedHash = generatePointerHash([tableName, 'binaryId', binaryData, '', '']) + expect(actualHash).to.equal(expectedHash) + }) + + it('generates correct hash for string-string key', () => { + const tableName = 'UserEmailTable' + const keys = { + userId: { S: 'user123' }, + email: { S: 'test@example.com' } + } + const actualHash = DynamoDb.calculateHashWithKnownKeys(tableName, keys) + const expectedHash = generatePointerHash([tableName, 'email', 'test@example.com', 'userId', 'user123']) + expect(actualHash).to.equal(expectedHash) + }) + + it('generates correct hash for string-number key', () => { + const tableName = 'UserActivityTable' + const keys = { + userId: { S: 'user123' }, + timestamp: { N: '1234567' } + } + const actualHash = DynamoDb.calculateHashWithKnownKeys(tableName, keys) + const expectedHash = generatePointerHash([tableName, 'timestamp', '1234567', 'userId', 'user123']) + expect(actualHash).to.equal(expectedHash) + }) + + it('generates correct hash for binary-binary key', () => { + const tableName = 'BinaryTable' + const binary1 = Buffer.from('abc') + const binary2 = Buffer.from('1ef230') + const keys = { + key1: { B: binary1 }, + key2: { B: binary2 } + } + const actualHash = DynamoDb.calculateHashWithKnownKeys(tableName, keys) + const expectedHash = generatePointerHash([tableName, 'key1', binary1, 'key2', binary2]) + expect(actualHash).to.equal(expectedHash) + }) + + it('generates unique hashes', () => { + const keys = { userId: { S: 'user123' } } + const hash1 = DynamoDb.calculateHashWithKnownKeys('Table1', keys) + const hash2 = DynamoDb.calculateHashWithKnownKeys('Table2', keys) + expect(hash1).to.not.equal(hash2) + }) + + describe('edge cases', () => { + it('handles empty keys object', () => { + const tableName = 'UserTable' + const hash = DynamoDb.calculateHashWithKnownKeys(tableName, {}) + expect(hash).to.be.undefined + }) + + it('handles invalid key types', () => { + const tableName = 'UserTable' + const keys = { userId: { INVALID: 'user123' } } + const hash = DynamoDb.calculateHashWithKnownKeys(tableName, keys) + expect(hash).to.be.undefined + }) + + it('handles null keys object', () => { + const hash = DynamoDb.calculateHashWithKnownKeys('TestTable', null) + expect(hash).to.be.undefined + }) + + it('handles undefined keys object', () => { + const hash = DynamoDb.calculateHashWithKnownKeys('TestTable', undefined) + expect(hash).to.be.undefined + }) + + it('handles mixed valid and invalid key types', () => { + const keys = { + validKey: { S: 'test' }, + invalidKey: { INVALID: 'value' } + } + const hash = DynamoDb.calculateHashWithKnownKeys('TestTable', keys) + expect(hash).to.be.undefined + }) + }) + }) + }) +}) diff --git a/packages/datadog-plugin-aws-sdk/test/eventbridge.spec.js b/packages/datadog-plugin-aws-sdk/test/eventbridge.spec.js index 3f65acdab0b..342af3ea723 100644 --- a/packages/datadog-plugin-aws-sdk/test/eventbridge.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/eventbridge.spec.js @@ -1,4 +1,4 @@ -/* eslint-disable max-len */ +/* eslint-disable @stylistic/js/max-len */ 'use strict' const EventBridge = require('../src/services/eventbridge') diff --git a/packages/datadog-plugin-aws-sdk/test/kinesis.spec.js b/packages/datadog-plugin-aws-sdk/test/kinesis.spec.js index 04c3ba796ee..2e3bf356f3e 100644 --- a/packages/datadog-plugin-aws-sdk/test/kinesis.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/kinesis.spec.js @@ -1,4 +1,4 @@ -/* eslint-disable max-len */ +/* eslint-disable @stylistic/js/max-len */ 'use strict' const sinon = require('sinon') diff --git a/packages/datadog-plugin-aws-sdk/test/sns.spec.js b/packages/datadog-plugin-aws-sdk/test/sns.spec.js index 7b62156f06c..b205c652669 100644 --- a/packages/datadog-plugin-aws-sdk/test/sns.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/sns.spec.js @@ -1,4 +1,4 @@ -/* eslint-disable max-len */ +/* eslint-disable @stylistic/js/max-len */ 'use strict' const sinon = require('sinon') diff --git a/packages/datadog-plugin-aws-sdk/test/stepfunctions.spec.js b/packages/datadog-plugin-aws-sdk/test/stepfunctions.spec.js index ed77ecd51b2..44677b4efed 100644 --- a/packages/datadog-plugin-aws-sdk/test/stepfunctions.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/stepfunctions.spec.js @@ -1,4 +1,4 @@ -/* eslint-disable max-len */ +/* eslint-disable @stylistic/js/max-len */ 'use strict' const semver = require('semver') diff --git a/packages/datadog-plugin-aws-sdk/test/util.spec.js b/packages/datadog-plugin-aws-sdk/test/util.spec.js new file mode 100644 index 00000000000..68bf57a7bfc --- /dev/null +++ b/packages/datadog-plugin-aws-sdk/test/util.spec.js @@ -0,0 +1,213 @@ +const { generatePointerHash, encodeValue, extractPrimaryKeys } = require('../src/util') + +describe('generatePointerHash', () => { + describe('should generate a valid hash for S3 object with', () => { + it('basic values', () => { + const hash = generatePointerHash(['some-bucket', 'some-key.data', 'ab12ef34']) + expect(hash).to.equal('e721375466d4116ab551213fdea08413') + }) + + it('non-ascii key', () => { + const hash = generatePointerHash(['some-bucket', 'some-key.你好', 'ab12ef34']) + expect(hash).to.equal('d1333a04b9928ab462b5c6cadfa401f4') + }) + + it('multipart-upload', () => { + const hash = generatePointerHash(['some-bucket', 'some-key.data', 'ab12ef34-5']) + expect(hash).to.equal('2b90dffc37ebc7bc610152c3dc72af9f') + }) + }) + + describe('should generate a valid hash for DynamoDB item with', () => { + it('one string primary key', () => { + const hash = generatePointerHash(['some-table', 'some-key', 'some-value', '', '']) + expect(hash).to.equal('7f1aee721472bcb48701d45c7c7f7821') + }) + + it('one buffered binary primary key', () => { + const hash = generatePointerHash(['some-table', 'some-key', Buffer.from('some-value'), '', '']) + expect(hash).to.equal('7f1aee721472bcb48701d45c7c7f7821') + }) + + it('one number primary key', () => { + const hash = generatePointerHash(['some-table', 'some-key', '123.456', '', '']) + expect(hash).to.equal('434a6dba3997ce4dbbadc98d87a0cc24') + }) + + it('one buffered number primary key', () => { + const hash = generatePointerHash(['some-table', 'some-key', Buffer.from('123.456'), '', '']) + expect(hash).to.equal('434a6dba3997ce4dbbadc98d87a0cc24') + }) + + it('string and number primary key', () => { + // sort primary keys lexicographically + const hash = generatePointerHash(['some-table', 'other-key', '123', 'some-key', 'some-value']) + expect(hash).to.equal('7aa1b80b0e49bd2078a5453399f4dd67') + }) + + it('buffered string and number primary key', () => { + const hash = generatePointerHash([ + 'some-table', + 'other-key', + Buffer.from('123'), + 'some-key', Buffer.from('some-value') + ]) + expect(hash).to.equal('7aa1b80b0e49bd2078a5453399f4dd67') + }) + }) +}) + +describe('encodeValue', () => { + describe('basic type handling', () => { + it('handles string (S) type correctly', () => { + const result = encodeValue({ S: 'hello world' }) + expect(Buffer.isBuffer(result)).to.be.true + expect(result).to.deep.equal(Buffer.from('hello world')) + }) + + it('handles number (N) as string type correctly', () => { + const result = encodeValue({ N: '123.45' }) + expect(Buffer.isBuffer(result)).to.be.true + expect(result).to.deep.equal(Buffer.from('123.45')) + }) + + it('handles number (N) as type string or number the same', () => { + const result1 = encodeValue({ N: 456.78 }) + const result2 = encodeValue({ N: '456.78' }) + expect(Buffer.isBuffer(result1)).to.be.true + expect(result1).to.deep.equal(result2) + }) + + it('handles binary (B) type correctly', () => { + const binaryData = Buffer.from([1, 2, 3]) + const result = encodeValue({ B: binaryData }) + expect(Buffer.isBuffer(result)).to.be.true + expect(result).to.deep.equal(binaryData) + }) + }) + + describe('edge cases', () => { + it('returns undefined for null input', () => { + const result = encodeValue(null) + expect(result).to.be.undefined + }) + + it('returns undefined for undefined input', () => { + const result = encodeValue(undefined) + expect(result).to.be.undefined + }) + + it('returns undefined for unsupported type', () => { + const result = encodeValue({ A: 'abc' }) + expect(result).to.be.undefined + }) + + it('returns undefined for malformed input', () => { + const result = encodeValue({}) + expect(result).to.be.undefined + }) + + it('handles empty string values', () => { + const result = encodeValue({ S: '' }) + expect(Buffer.isBuffer(result)).to.be.true + expect(result.length).to.equal(0) + }) + + it('handles empty buffer', () => { + const result = encodeValue({ B: Buffer.from([]) }) + expect(Buffer.isBuffer(result)).to.be.true + expect(result.length).to.equal(0) + }) + }) +}) + +describe('extractPrimaryKeys', () => { + describe('single key table', () => { + it('handles string key', () => { + const keySet = new Set(['userId']) + const item = { userId: { S: 'user123' } } + const result = extractPrimaryKeys(keySet, item) + expect(result).to.deep.equal(['userId', Buffer.from('user123'), '', '']) + }) + + it('handles number key', () => { + const keySet = new Set(['timestamp']) + const item = { timestamp: { N: '1234567' } } + const result = extractPrimaryKeys(keySet, item) + expect(result).to.deep.equal(['timestamp', Buffer.from('1234567'), '', '']) + }) + + it('handles binary key', () => { + const keySet = new Set(['binaryId']) + const binaryData = Buffer.from([1, 2, 3]) + const item = { binaryId: { B: binaryData } } + const result = extractPrimaryKeys(keySet, item) + expect(result).to.deep.equal(['binaryId', binaryData, '', '']) + }) + }) + + describe('double key table', () => { + it('handles and sorts string-string keys', () => { + const keySet = new Set(['userId', 'email']) + const item = { + userId: { S: 'user123' }, + email: { S: 'test@example.com' } + } + const result = extractPrimaryKeys(keySet, item) + expect(result).to.deep.equal(['email', Buffer.from('test@example.com'), 'userId', Buffer.from('user123')]) + }) + + it('handles and sorts string-number keys', () => { + const keySet = new Set(['timestamp', 'userId']) + const item = { + timestamp: { N: '1234567' }, + userId: { S: 'user123' } + } + const result = extractPrimaryKeys(keySet, item) + expect(result).to.deep.equal(['timestamp', Buffer.from('1234567'), 'userId', Buffer.from('user123')]) + }) + }) + + describe('edge cases', () => { + it('returns undefined when missing values', () => { + const keySet = new Set(['userId', 'timestamp']) + const item = { userId: { S: 'user123' } } // timestamp missing + const result = extractPrimaryKeys(keySet, item) + expect(result).to.be.undefined + }) + + it('returns undefined when invalid value types', () => { + const keySet = new Set(['userId', 'timestamp']) + const item = { + userId: { S: 'user123' }, + timestamp: { INVALID: '1234567' } + } + const result = extractPrimaryKeys(keySet, item) + expect(result).to.be.undefined + }) + + it('handles empty Set input', () => { + const result = extractPrimaryKeys(new Set([]), {}) + expect(result).to.be.undefined + }) + + it('returns undefined when null values in item', () => { + const keySet = new Set(['key1', 'key2']) + const item = { + key1: null, + key2: { S: 'value2' } + } + const result = extractPrimaryKeys(keySet, item) + expect(result).to.be.undefined + }) + + it('returns undefined when undefined values in item', () => { + const keySet = new Set(['key1', 'key2']) + const item = { + key2: { S: 'value2' } + } + const result = extractPrimaryKeys(keySet, item) + expect(result).to.be.undefined + }) + }) +}) diff --git a/packages/datadog-plugin-cassandra-driver/test/integration-test/server.mjs b/packages/datadog-plugin-cassandra-driver/test/integration-test/server.mjs index 91ff60029fb..c65ebffe78d 100644 --- a/packages/datadog-plugin-cassandra-driver/test/integration-test/server.mjs +++ b/packages/datadog-plugin-cassandra-driver/test/integration-test/server.mjs @@ -9,4 +9,4 @@ const client = new Client({ await client.connect() await client.execute('SELECT now() FROM local;') -await client.shutdown() \ No newline at end of file +await client.shutdown() diff --git a/packages/datadog-plugin-child_process/src/scrub-cmd-params.js b/packages/datadog-plugin-child_process/src/scrub-cmd-params.js index b5fb59bb781..595d8f5746a 100644 --- a/packages/datadog-plugin-child_process/src/scrub-cmd-params.js +++ b/packages/datadog-plugin-child_process/src/scrub-cmd-params.js @@ -6,7 +6,7 @@ const ALLOWED_ENV_VARIABLES = ['LD_PRELOAD', 'LD_LIBRARY_PATH', 'PATH'] const PROCESS_DENYLIST = ['md5'] const VARNAMES_REGEX = /\$([\w\d_]*)(?:[^\w\d_]|$)/gmi -// eslint-disable-next-line max-len +// eslint-disable-next-line @stylistic/js/max-len const PARAM_PATTERN = '^-{0,2}(?:p(?:ass(?:w(?:or)?d)?)?|address|api[-_]?key|e?mail|secret(?:[-_]?key)?|a(?:ccess|uth)[-_]?token|mysql_pwd|credentials|(?:stripe)?token)$' const regexParam = new RegExp(PARAM_PATTERN, 'i') const ENV_PATTERN = '^(\\w+=\\w+;)*\\w+=\\w+;?$' diff --git a/packages/datadog-plugin-cucumber/src/index.js b/packages/datadog-plugin-cucumber/src/index.js index d24f97c33e6..1c4403b7ce6 100644 --- a/packages/datadog-plugin-cucumber/src/index.js +++ b/packages/datadog-plugin-cucumber/src/index.js @@ -26,7 +26,12 @@ const { TEST_MODULE, TEST_MODULE_ID, TEST_SUITE, - CUCUMBER_IS_PARALLEL + CUCUMBER_IS_PARALLEL, + TEST_NAME, + DI_ERROR_DEBUG_INFO_CAPTURED, + DI_DEBUG_ERROR_SNAPSHOT_ID, + DI_DEBUG_ERROR_FILE, + DI_DEBUG_ERROR_LINE } = require('../../dd-trace/src/plugins/util/test') const { RESOURCE_NAME } = require('../../../ext/tags') const { COMPONENT, ERROR_MESSAGE } = require('../../dd-trace/src/constants') @@ -46,6 +51,7 @@ const { const id = require('../../dd-trace/src/id') const isCucumberWorker = !!process.env.CUCUMBER_WORKER_ID +const debuggerParameterPerTest = new Map() function getTestSuiteTags (testSuiteSpan) { const suiteTags = { @@ -220,14 +226,40 @@ class CucumberPlugin extends CiPlugin { const testSpan = this.startTestSpan(testName, testSuite, extraTags) this.enter(testSpan, store) + + const debuggerParameters = debuggerParameterPerTest.get(testName) + + if (debuggerParameters) { + const spanContext = testSpan.context() + + // TODO: handle race conditions with this.retriedTestIds + this.retriedTestIds = { + spanId: spanContext.toSpanId(), + traceId: spanContext.toTraceId() + } + const { snapshotId, file, line } = debuggerParameters + + // TODO: should these be added on test:end if and only if the probe is hit? + // Sync issues: `hitProbePromise` might be resolved after the test ends + testSpan.setTag(DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + testSpan.setTag(DI_DEBUG_ERROR_SNAPSHOT_ID, snapshotId) + testSpan.setTag(DI_DEBUG_ERROR_FILE, file) + testSpan.setTag(DI_DEBUG_ERROR_LINE, line) + } }) - this.addSub('ci:cucumber:test:retry', (isFlakyRetry) => { + this.addSub('ci:cucumber:test:retry', ({ isRetry, error }) => { const store = storage.getStore() const span = store.span - if (isFlakyRetry) { + if (isRetry) { span.setTag(TEST_IS_RETRY, 'true') } + span.setTag('error', error) + if (this.di && error && this.libraryConfig?.isDiEnabled) { + const testName = span.context()._tags[TEST_NAME] + const debuggerParameters = this.addDiProbe(error) + debuggerParameterPerTest.set(testName, debuggerParameters) + } span.setTag(TEST_STATUS, 'fail') span.finish() finishAllTraceSpans(span) @@ -281,6 +313,7 @@ class CucumberPlugin extends CiPlugin { isStep, status, skipReason, + error, errorMessage, isNew, isEfdRetry, @@ -302,7 +335,9 @@ class CucumberPlugin extends CiPlugin { span.setTag(TEST_SKIP_REASON, skipReason) } - if (errorMessage) { + if (error) { + span.setTag('error', error) + } else if (errorMessage) { // we can't get a full error in cucumber steps span.setTag(ERROR_MESSAGE, errorMessage) } diff --git a/packages/datadog-plugin-cypress/src/cypress-plugin.js b/packages/datadog-plugin-cypress/src/cypress-plugin.js index 0a7d0debe48..2ed62070fda 100644 --- a/packages/datadog-plugin-cypress/src/cypress-plugin.js +++ b/packages/datadog-plugin-cypress/src/cypress-plugin.js @@ -223,7 +223,7 @@ class CypressPlugin { this.libraryConfigurationPromise = getLibraryConfiguration(this.tracer, this.testConfiguration) .then((libraryConfigurationResponse) => { if (libraryConfigurationResponse.err) { - log.error(libraryConfigurationResponse.err) + log.error('Cypress plugin library config response error', libraryConfigurationResponse.err) } else { const { libraryConfig: { @@ -360,7 +360,7 @@ class CypressPlugin { this.testConfiguration ) if (knownTestsResponse.err) { - log.error(knownTestsResponse.err) + log.error('Cypress known tests response error', knownTestsResponse.err) this.isEarlyFlakeDetectionEnabled = false } else { // We use TEST_FRAMEWORK_NAME for the name of the module @@ -374,7 +374,7 @@ class CypressPlugin { this.testConfiguration ) if (skippableTestsResponse.err) { - log.error(skippableTestsResponse.err) + log.error('Cypress skippable tests response error', skippableTestsResponse.err) } else { const { skippableTests, correlationId } = skippableTestsResponse this.testsToSkip = skippableTests || [] diff --git a/packages/datadog-plugin-elasticsearch/test/integration-test/server.mjs b/packages/datadog-plugin-elasticsearch/test/integration-test/server.mjs index a54efd22e4d..f3f2cc1d9a7 100644 --- a/packages/datadog-plugin-elasticsearch/test/integration-test/server.mjs +++ b/packages/datadog-plugin-elasticsearch/test/integration-test/server.mjs @@ -3,4 +3,4 @@ import { Client } from '@elastic/elasticsearch' const client = new Client({ node: 'http://localhost:9200' }) -await client.ping() \ No newline at end of file +await client.ping() diff --git a/packages/datadog-plugin-express/test/index.spec.js b/packages/datadog-plugin-express/test/index.spec.js index 55a608f4adf..8899c34ecb3 100644 --- a/packages/datadog-plugin-express/test/index.spec.js +++ b/packages/datadog-plugin-express/test/index.spec.js @@ -2,6 +2,7 @@ const { AsyncLocalStorage } = require('async_hooks') const axios = require('axios') +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 plugin = require('../src') @@ -214,34 +215,56 @@ describe('Plugin', () => { agent .use(traces => { const spans = sort(traces[0]) + const isExpress4 = semver.intersects(version, '<5.0.0') + let index = 0 + + const rootSpan = spans[index++] + expect(rootSpan).to.have.property('resource', 'GET /app/user/:id') + expect(rootSpan).to.have.property('name', 'express.request') + expect(rootSpan.meta).to.have.property('component', 'express') + + if (isExpress4) { + expect(spans[index]).to.have.property('resource', 'query') + expect(spans[index]).to.have.property('name', 'express.middleware') + expect(spans[index].parent_id.toString()).to.equal(rootSpan.span_id.toString()) + expect(spans[index].meta).to.have.property('component', 'express') + index++ + + expect(spans[index]).to.have.property('resource', 'expressInit') + expect(spans[index]).to.have.property('name', 'express.middleware') + expect(spans[index].parent_id.toString()).to.equal(rootSpan.span_id.toString()) + expect(spans[index].meta).to.have.property('component', 'express') + index++ + } - expect(spans[0]).to.have.property('resource', 'GET /app/user/:id') - expect(spans[0]).to.have.property('name', 'express.request') - expect(spans[0].meta).to.have.property('component', 'express') - expect(spans[1]).to.have.property('resource', 'query') - expect(spans[1]).to.have.property('name', 'express.middleware') - expect(spans[1].parent_id.toString()).to.equal(spans[0].span_id.toString()) - expect(spans[1].meta).to.have.property('component', 'express') - expect(spans[2]).to.have.property('resource', 'expressInit') - expect(spans[2]).to.have.property('name', 'express.middleware') - expect(spans[2].parent_id.toString()).to.equal(spans[0].span_id.toString()) - expect(spans[2].meta).to.have.property('component', 'express') - expect(spans[3]).to.have.property('resource', 'named') - expect(spans[3]).to.have.property('name', 'express.middleware') - expect(spans[3].parent_id.toString()).to.equal(spans[0].span_id.toString()) - expect(spans[3].meta).to.have.property('component', 'express') - expect(spans[4]).to.have.property('resource', 'router') - expect(spans[4]).to.have.property('name', 'express.middleware') - expect(spans[4].parent_id.toString()).to.equal(spans[0].span_id.toString()) - expect(spans[4].meta).to.have.property('component', 'express') - expect(spans[5].resource).to.match(/^bound\s.*$/) - expect(spans[5]).to.have.property('name', 'express.middleware') - expect(spans[5].parent_id.toString()).to.equal(spans[4].span_id.toString()) - expect(spans[5].meta).to.have.property('component', 'express') - expect(spans[6]).to.have.property('resource', '') - expect(spans[6]).to.have.property('name', 'express.middleware') - expect(spans[6].parent_id.toString()).to.equal(spans[5].span_id.toString()) - expect(spans[6].meta).to.have.property('component', 'express') + expect(spans[index]).to.have.property('resource', 'named') + expect(spans[index]).to.have.property('name', 'express.middleware') + expect(spans[index].parent_id.toString()).to.equal(rootSpan.span_id.toString()) + expect(spans[index].meta).to.have.property('component', 'express') + index++ + + expect(spans[index]).to.have.property('resource', 'router') + expect(spans[index]).to.have.property('name', 'express.middleware') + expect(spans[index].parent_id.toString()).to.equal(rootSpan.span_id.toString()) + expect(spans[index].meta).to.have.property('component', 'express') + index++ + + if (isExpress4) { + expect(spans[index].resource).to.match(/^bound\s.*$/) + } else { + expect(spans[index]).to.have.property('resource', 'handle') + } + expect(spans[index]).to.have.property('name', 'express.middleware') + expect(spans[index].parent_id.toString()).to.equal(spans[index - 1].span_id.toString()) + expect(spans[index].meta).to.have.property('component', 'express') + index++ + + expect(spans[index]).to.have.property('resource', '') + expect(spans[index]).to.have.property('name', 'express.middleware') + expect(spans[index].parent_id.toString()).to.equal(spans[index - 1].span_id.toString()) + expect(spans[index].meta).to.have.property('component', 'express') + + expect(index).to.equal(spans.length - 1) }) .then(done) .catch(done) @@ -277,12 +300,14 @@ describe('Plugin', () => { .use(traces => { const spans = sort(traces[0]) + const breakingSpanIndex = semver.intersects(version, '<5.0.0') ? 3 : 1 + expect(spans[0]).to.have.property('resource', 'GET /user/:id') expect(spans[0]).to.have.property('name', 'express.request') expect(spans[0].meta).to.have.property('component', 'express') - expect(spans[3]).to.have.property('resource', 'breaking') - expect(spans[3]).to.have.property('name', 'express.middleware') - expect(spans[3].meta).to.have.property('component', 'express') + expect(spans[breakingSpanIndex]).to.have.property('resource', 'breaking') + expect(spans[breakingSpanIndex]).to.have.property('name', 'express.middleware') + expect(spans[breakingSpanIndex].meta).to.have.property('component', 'express') }) .then(done) .catch(done) @@ -321,12 +346,13 @@ describe('Plugin', () => { agent .use(traces => { const spans = sort(traces[0]) + const errorSpanIndex = semver.intersects(version, '<5.0.0') ? 4 : 2 expect(spans[0]).to.have.property('name', 'express.request') - expect(spans[4]).to.have.property('name', 'express.middleware') - expect(spans[4].meta).to.have.property(ERROR_TYPE, error.name) + expect(spans[errorSpanIndex]).to.have.property('name', 'express.middleware') + expect(spans[errorSpanIndex].meta).to.have.property(ERROR_TYPE, error.name) expect(spans[0].meta).to.have.property('component', 'express') - expect(spans[4].meta).to.have.property('component', 'express') + expect(spans[errorSpanIndex].meta).to.have.property('component', 'express') }) .then(done) .catch(done) @@ -398,14 +424,14 @@ describe('Plugin', () => { const router = express.Router() router.use('/', (req, res, next) => next()) - router.use('*', (req, res, next) => next()) + router.use('/*splat', (req, res, next) => next()) router.use('/bar', (req, res, next) => next()) router.use('/bar', (req, res, next) => { res.status(200).send() }) app.use('/', (req, res, next) => next()) - app.use('*', (req, res, next) => next()) + app.use('/*splat', (req, res, next) => next()) app.use('/foo/bar', (req, res, next) => next()) app.use('/foo', router) @@ -1138,17 +1164,18 @@ describe('Plugin', () => { agent .use(traces => { const spans = sort(traces[0]) + const secondErrorIndex = spans.length - 2 expect(spans[0]).to.have.property('error', 1) expect(spans[0].meta).to.have.property(ERROR_TYPE, error.name) expect(spans[0].meta).to.have.property(ERROR_MESSAGE, error.message) expect(spans[0].meta).to.have.property(ERROR_STACK, error.stack) expect(spans[0].meta).to.have.property('component', 'express') - expect(spans[3]).to.have.property('error', 1) - expect(spans[3].meta).to.have.property(ERROR_TYPE, error.name) - expect(spans[3].meta).to.have.property(ERROR_MESSAGE, error.message) - expect(spans[3].meta).to.have.property(ERROR_STACK, error.stack) - expect(spans[3].meta).to.have.property('component', 'express') + expect(spans[secondErrorIndex]).to.have.property('error', 1) + expect(spans[secondErrorIndex].meta).to.have.property(ERROR_TYPE, error.name) + expect(spans[secondErrorIndex].meta).to.have.property(ERROR_MESSAGE, error.message) + expect(spans[secondErrorIndex].meta).to.have.property(ERROR_STACK, error.stack) + expect(spans[secondErrorIndex].meta).to.have.property('component', 'express') }) .then(done) .catch(done) @@ -1175,16 +1202,17 @@ describe('Plugin', () => { agent .use(traces => { const spans = sort(traces[0]) + const secondErrorIndex = spans.length - 2 expect(spans[0]).to.have.property('error', 1) expect(spans[0].meta).to.have.property(ERROR_TYPE, error.name) expect(spans[0].meta).to.have.property(ERROR_MESSAGE, error.message) expect(spans[0].meta).to.have.property(ERROR_STACK, error.stack) expect(spans[0].meta).to.have.property('component', 'express') - expect(spans[3]).to.have.property('error', 1) - expect(spans[3].meta).to.have.property(ERROR_TYPE, error.name) - expect(spans[3].meta).to.have.property(ERROR_MESSAGE, error.message) - expect(spans[3].meta).to.have.property(ERROR_STACK, error.stack) + expect(spans[secondErrorIndex]).to.have.property('error', 1) + expect(spans[secondErrorIndex].meta).to.have.property(ERROR_TYPE, error.name) + expect(spans[secondErrorIndex].meta).to.have.property(ERROR_MESSAGE, error.message) + expect(spans[secondErrorIndex].meta).to.have.property(ERROR_STACK, error.stack) expect(spans[0].meta).to.have.property('component', 'express') }) .then(done) @@ -1199,6 +1227,11 @@ describe('Plugin', () => { }) it('should support capturing groups in routes', done => { + if (semver.intersects(version, '>=5.0.0')) { + this.skip && this.skip() // mocha allows dynamic skipping, tap does not + return done() + } + const app = express() app.get('/:path(*)', (req, res) => { @@ -1224,6 +1257,32 @@ describe('Plugin', () => { }) }) + it('should support wildcard path prefix matching in routes', done => { + const app = express() + + app.get('/*user', (req, res) => { + res.status(200).send() + }) + + appListener = app.listen(0, 'localhost', () => { + const port = appListener.address().port + + agent + .use(traces => { + const spans = sort(traces[0]) + + expect(spans[0]).to.have.property('resource', 'GET /*user') + expect(spans[0].meta).to.have.property('http.url', `http://localhost:${port}/user`) + }) + .then(done) + .catch(done) + + axios + .get(`http://localhost:${port}/user`) + .catch(done) + }) + }) + it('should keep the properties untouched on nested router handlers', () => { const router = express.Router() const childRouter = express.Router() @@ -1234,7 +1293,12 @@ describe('Plugin', () => { router.use('/users', childRouter) - const layer = router.stack.find(layer => layer.regexp.test('/users')) + const layer = router.stack.find(layer => { + if (semver.intersects(version, '>=5.0.0')) { + return layer.matchers.find(matcher => matcher('/users')) + } + return layer.regexp.test('/users') + }) expect(layer.handle).to.have.ownProperty('stack') }) diff --git a/packages/datadog-plugin-express/test/integration-test/client.spec.js b/packages/datadog-plugin-express/test/integration-test/client.spec.js index a5c08d60ecb..c13a4249892 100644 --- a/packages/datadog-plugin-express/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-express/test/integration-test/client.spec.js @@ -7,6 +7,7 @@ const { spawnPluginIntegrationTestProc } = require('../../../../integration-tests/helpers') const { assert } = require('chai') +const semver = require('semver') describe('esm', () => { let agent @@ -36,13 +37,14 @@ describe('esm', () => { it('is instrumented', async () => { proc = await spawnPluginIntegrationTestProc(sandbox.folder, 'server.mjs', agent.port) + const numberOfSpans = semver.intersects(version, '<5.0.0') ? 4 : 3 return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.propertyVal(headers, 'host', `127.0.0.1:${agent.port}`) assert.isArray(payload) assert.strictEqual(payload.length, 1) assert.isArray(payload[0]) - assert.strictEqual(payload[0].length, 4) + assert.strictEqual(payload[0].length, numberOfSpans) assert.propertyVal(payload[0][0], 'name', 'express.request') assert.propertyVal(payload[0][1], 'name', 'express.middleware') }) diff --git a/packages/datadog-plugin-google-cloud-pubsub/test/integration-test/server.mjs b/packages/datadog-plugin-google-cloud-pubsub/test/integration-test/server.mjs index f315996ba58..fc3ab176f24 100644 --- a/packages/datadog-plugin-google-cloud-pubsub/test/integration-test/server.mjs +++ b/packages/datadog-plugin-google-cloud-pubsub/test/integration-test/server.mjs @@ -8,4 +8,4 @@ const [subscription] = await topic.createSubscription('foo') await topic.publishMessage({ data: Buffer.from('Test message!') }) await subscription.close() -await pubsub.close() \ No newline at end of file +await pubsub.close() diff --git a/packages/datadog-plugin-graphql/test/integration-test/server.mjs b/packages/datadog-plugin-graphql/test/integration-test/server.mjs index 822155d1710..d7aab2d1b3b 100644 --- a/packages/datadog-plugin-graphql/test/integration-test/server.mjs +++ b/packages/datadog-plugin-graphql/test/integration-test/server.mjs @@ -15,8 +15,8 @@ const schema = new graphql.GraphQLSchema({ }) }) -await graphql.graphql({ - schema, - source: `query MyQuery { hello(name: "world") }`, +await graphql.graphql({ + schema, + source: 'query MyQuery { hello(name: "world") }', variableValues: { who: 'world' } }) diff --git a/packages/datadog-plugin-grpc/src/client.js b/packages/datadog-plugin-grpc/src/client.js index 1b130a1f93e..db8dd89b9bf 100644 --- a/packages/datadog-plugin-grpc/src/client.js +++ b/packages/datadog-plugin-grpc/src/client.js @@ -62,7 +62,7 @@ class GrpcClientPlugin extends ClientPlugin { return parentStore } - error ({ span, error }) { + error ({ span = this.activeSpan, error }) { this.addCode(span, error.code) if (error.code && !this._tracerConfig.grpc.client.error.statuses.includes(error.code)) { return @@ -108,7 +108,7 @@ class GrpcClientPlugin extends ClientPlugin { } addCode (span, code) { - if (code !== undefined) { + if (code !== undefined && span) { span.setTag('grpc.status.code', code) } } diff --git a/packages/datadog-plugin-grpc/src/util.js b/packages/datadog-plugin-grpc/src/util.js index 1c1937e7ea7..ec7d0f33570 100644 --- a/packages/datadog-plugin-grpc/src/util.js +++ b/packages/datadog-plugin-grpc/src/util.js @@ -54,7 +54,7 @@ module.exports = { } if (config.hasOwnProperty(filter)) { - log.error(`Expected '${filter}' to be an array or function.`) + log.error('Expected \'%s\' to be an array or function.', filter) } return () => ({}) diff --git a/packages/datadog-plugin-jest/src/index.js b/packages/datadog-plugin-jest/src/index.js index 4362094b0be..0287f837653 100644 --- a/packages/datadog-plugin-jest/src/index.js +++ b/packages/datadog-plugin-jest/src/index.js @@ -22,7 +22,12 @@ const { TEST_EARLY_FLAKE_ABORT_REASON, JEST_DISPLAY_NAME, TEST_IS_RUM_ACTIVE, - TEST_BROWSER_DRIVER + TEST_BROWSER_DRIVER, + DI_ERROR_DEBUG_INFO_CAPTURED, + DI_DEBUG_ERROR_SNAPSHOT_ID, + DI_DEBUG_ERROR_FILE, + DI_DEBUG_ERROR_LINE, + TEST_NAME } = require('../../dd-trace/src/plugins/util/test') const { COMPONENT } = require('../../dd-trace/src/constants') const id = require('../../dd-trace/src/id') @@ -39,6 +44,7 @@ const { } = require('../../dd-trace/src/ci-visibility/telemetry') const isJestWorker = !!process.env.JEST_WORKER_ID +const debuggerParameterPerTest = new Map() // https://github.com/facebook/jest/blob/d6ad15b0f88a05816c2fe034dd6900d28315d570/packages/jest-worker/src/types.ts#L38 const CHILD_MESSAGE_END = 2 @@ -155,6 +161,7 @@ class JestPlugin extends CiPlugin { config._ddRepositoryRoot = this.repositoryRoot config._ddIsFlakyTestRetriesEnabled = this.libraryConfig?.isFlakyTestRetriesEnabled ?? false config._ddFlakyTestRetriesCount = this.libraryConfig?.flakyTestRetriesCount + config._ddIsDiEnabled = this.libraryConfig?.isDiEnabled ?? false }) }) @@ -301,6 +308,29 @@ class JestPlugin extends CiPlugin { const span = this.startTestSpan(test) this.enter(span, store) + + const { name: testName } = test + + const debuggerParameters = debuggerParameterPerTest.get(testName) + + // If we have a debugger probe, we need to add the snapshot id to the span + if (debuggerParameters) { + const spanContext = span.context() + + // TODO: handle race conditions with this.retriedTestIds + this.retriedTestIds = { + spanId: spanContext.toSpanId(), + traceId: spanContext.toTraceId() + } + const { snapshotId, file, line } = debuggerParameters + + // TODO: should these be added on test:end if and only if the probe is hit? + // Sync issues: `hitProbePromise` might be resolved after the test ends + span.setTag(DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + span.setTag(DI_DEBUG_ERROR_SNAPSHOT_ID, snapshotId) + span.setTag(DI_DEBUG_ERROR_FILE, file) + span.setTag(DI_DEBUG_ERROR_LINE, line) + } }) this.addSub('ci:jest:test:finish', ({ status, testStartLine }) => { @@ -326,13 +356,19 @@ class JestPlugin extends CiPlugin { finishAllTraceSpans(span) }) - this.addSub('ci:jest:test:err', (error) => { + this.addSub('ci:jest:test:err', ({ error, willBeRetried, probe, isDiEnabled }) => { if (error) { const store = storage.getStore() if (store && store.span) { const span = store.span span.setTag(TEST_STATUS, 'fail') span.setTag('error', error) + if (willBeRetried && this.di && isDiEnabled) { + // if we use numTestExecutions, we have to remove the breakpoint after each execution + const testName = span.context()._tags[TEST_NAME] + const debuggerParameters = this.addDiProbe(error, probe) + debuggerParameterPerTest.set(testName, debuggerParameters) + } } } }) @@ -348,7 +384,6 @@ class JestPlugin extends CiPlugin { const { suite, name, - runner, displayName, testParameters, frameworkVersion, @@ -360,7 +395,7 @@ class JestPlugin extends CiPlugin { } = test const extraTags = { - [JEST_TEST_RUNNER]: runner, + [JEST_TEST_RUNNER]: 'jest-circus', [TEST_PARAMETERS]: testParameters, [TEST_FRAMEWORK_VERSION]: frameworkVersion } diff --git a/packages/datadog-plugin-microgateway-core/test/integration-test/server.mjs b/packages/datadog-plugin-microgateway-core/test/integration-test/server.mjs index bf174c489da..ce72c80e82d 100644 --- a/packages/datadog-plugin-microgateway-core/test/integration-test/server.mjs +++ b/packages/datadog-plugin-microgateway-core/test/integration-test/server.mjs @@ -6,7 +6,7 @@ import getPort from 'get-port' const port = await getPort() const gateway = Gateway({ edgemicro: { - port: port, + port, logging: { level: 'info', dir: os.tmpdir() } }, proxies: [] diff --git a/packages/datadog-plugin-mocha/src/index.js b/packages/datadog-plugin-mocha/src/index.js index 03d201f17b8..1b40b9c5a1c 100644 --- a/packages/datadog-plugin-mocha/src/index.js +++ b/packages/datadog-plugin-mocha/src/index.js @@ -30,7 +30,12 @@ const { TEST_SUITE, MOCHA_IS_PARALLEL, TEST_IS_RUM_ACTIVE, - TEST_BROWSER_DRIVER + TEST_BROWSER_DRIVER, + TEST_NAME, + DI_ERROR_DEBUG_INFO_CAPTURED, + DI_DEBUG_ERROR_SNAPSHOT_ID, + DI_DEBUG_ERROR_FILE, + DI_DEBUG_ERROR_LINE } = require('../../dd-trace/src/plugins/util/test') const { COMPONENT } = require('../../dd-trace/src/constants') const { @@ -47,6 +52,8 @@ const { const id = require('../../dd-trace/src/id') const log = require('../../dd-trace/src/log') +const debuggerParameterPerTest = new Map() + function getTestSuiteLevelVisibilityTags (testSuiteSpan) { const testSuiteSpanContext = testSuiteSpan.context() const suiteTags = { @@ -185,6 +192,28 @@ class MochaPlugin extends CiPlugin { const store = storage.getStore() const span = this.startTestSpan(testInfo) + const { testName } = testInfo + + const debuggerParameters = debuggerParameterPerTest.get(testName) + + if (debuggerParameters) { + const spanContext = span.context() + + // TODO: handle race conditions with this.retriedTestIds + this.retriedTestIds = { + spanId: spanContext.toSpanId(), + traceId: spanContext.toTraceId() + } + const { snapshotId, file, line } = debuggerParameters + + // TODO: should these be added on test:end if and only if the probe is hit? + // Sync issues: `hitProbePromise` might be resolved after the test ends + span.setTag(DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + span.setTag(DI_DEBUG_ERROR_SNAPSHOT_ID, snapshotId) + span.setTag(DI_DEBUG_ERROR_FILE, file) + span.setTag(DI_DEBUG_ERROR_LINE, line) + } + this.enter(span, store) }) @@ -242,7 +271,7 @@ class MochaPlugin extends CiPlugin { } }) - this.addSub('ci:mocha:test:retry', ({ isFirstAttempt, err }) => { + this.addSub('ci:mocha:test:retry', ({ isFirstAttempt, willBeRetried, err }) => { const store = storage.getStore() const span = store?.span if (span) { @@ -265,6 +294,11 @@ class MochaPlugin extends CiPlugin { browserDriver: spanTags[TEST_BROWSER_DRIVER] } ) + if (willBeRetried && this.di && this.libraryConfig?.isDiEnabled) { + const testName = span.context()._tags[TEST_NAME] + const debuggerParameters = this.addDiProbe(err) + debuggerParameterPerTest.set(testName, debuggerParameters) + } span.finish() finishAllTraceSpans(span) diff --git a/packages/datadog-plugin-mongodb-core/test/integration-test/server.mjs b/packages/datadog-plugin-mongodb-core/test/integration-test/server.mjs index 11fa3ac576b..0c643c53a7b 100644 --- a/packages/datadog-plugin-mongodb-core/test/integration-test/server.mjs +++ b/packages/datadog-plugin-mongodb-core/test/integration-test/server.mjs @@ -7,4 +7,3 @@ const db = client.db('test_db') const collection = db.collection('test_collection') collection.insertOne({ a: 1 }, {}, () => {}) setTimeout(() => { client.close() }, 1500) - diff --git a/packages/datadog-plugin-mongodb-core/test/integration-test/server2.mjs b/packages/datadog-plugin-mongodb-core/test/integration-test/server2.mjs index 39127aaab23..c11c934993d 100644 --- a/packages/datadog-plugin-mongodb-core/test/integration-test/server2.mjs +++ b/packages/datadog-plugin-mongodb-core/test/integration-test/server2.mjs @@ -15,7 +15,7 @@ const connectPromise = new Promise((resolve, reject) => { await server.connect() await connectPromise -server.insert(`test.your_collection_name`, [{ a: 1 }], {}, (err) => { +server.insert('test.your_collection_name', [{ a: 1 }], {}, (err) => { if (err) { return } diff --git a/packages/datadog-plugin-net/test/integration-test/server.mjs b/packages/datadog-plugin-net/test/integration-test/server.mjs index 4575498e13a..fc7ec19a696 100644 --- a/packages/datadog-plugin-net/test/integration-test/server.mjs +++ b/packages/datadog-plugin-net/test/integration-test/server.mjs @@ -14,4 +14,4 @@ client.on('end', () => { client.on('error', (err) => { client.end() -}) \ No newline at end of file +}) diff --git a/packages/datadog-plugin-next/test/integration-test/client.spec.js b/packages/datadog-plugin-next/test/integration-test/client.spec.js index 054e2fc6357..5bd4825ce93 100644 --- a/packages/datadog-plugin-next/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-next/test/integration-test/client.spec.js @@ -30,7 +30,7 @@ describe('esm', () => { before(async function () { // next builds slower in the CI, match timeout with unit tests this.timeout(120 * 1000) - sandbox = await createSandbox([`'next@${version}'`, 'react', 'react-dom'], + sandbox = await createSandbox([`'next@${version}'`, 'react@^18.2.0', 'react-dom@^18.2.0'], false, ['./packages/datadog-plugin-next/test/integration-test/*'], BUILD_COMMAND) }) diff --git a/packages/datadog-plugin-openai/test/integration-test/server.mjs b/packages/datadog-plugin-openai/test/integration-test/server.mjs index 56a046d56c0..62d812baea8 100644 --- a/packages/datadog-plugin-openai/test/integration-test/server.mjs +++ b/packages/datadog-plugin-openai/test/integration-test/server.mjs @@ -23,7 +23,7 @@ nock('https://api.openai.com:443') ]) const openaiApp = new openai.OpenAIApi(new openai.Configuration({ - apiKey: 'sk-DATADOG-ACCEPTANCE-TESTS', + apiKey: 'sk-DATADOG-ACCEPTANCE-TESTS' })) await openaiApp.createCompletion({ diff --git a/packages/datadog-plugin-opensearch/test/integration-test/server.mjs b/packages/datadog-plugin-opensearch/test/integration-test/server.mjs index 0b45b5eefb2..21be1cead43 100644 --- a/packages/datadog-plugin-opensearch/test/integration-test/server.mjs +++ b/packages/datadog-plugin-opensearch/test/integration-test/server.mjs @@ -1,5 +1,5 @@ import 'dd-trace/init.js' import opensearch from '@opensearch-project/opensearch' -const client = new opensearch.Client({ node: `http://localhost:9201` }) +const client = new opensearch.Client({ node: 'http://localhost:9201' }) await client.ping() diff --git a/packages/datadog-plugin-oracledb/src/index.js b/packages/datadog-plugin-oracledb/src/index.js index 7c2f1da029f..eb4fa037cac 100644 --- a/packages/datadog-plugin-oracledb/src/index.js +++ b/packages/datadog-plugin-oracledb/src/index.js @@ -33,7 +33,7 @@ function getUrl (connectString) { try { return new URL(`http://${connectString}`) } catch (e) { - log.error(e) + log.error('Invalid oracle connection string', e) return {} } } diff --git a/packages/datadog-plugin-oracledb/test/integration-test/server.mjs b/packages/datadog-plugin-oracledb/test/integration-test/server.mjs index b50a7b36d13..739877fbcd7 100644 --- a/packages/datadog-plugin-oracledb/test/integration-test/server.mjs +++ b/packages/datadog-plugin-oracledb/test/integration-test/server.mjs @@ -7,13 +7,11 @@ const config = { user: 'test', password: 'Oracle18', connectString: `${hostname}:1521/xepdb1` -}; +} const dbQuery = 'select current_timestamp from dual' -let connection; - -connection = await oracledb.getConnection(config) +const connection = await oracledb.getConnection(config) await connection.execute(dbQuery) if (connection) { diff --git a/packages/datadog-plugin-router/test/index.spec.js b/packages/datadog-plugin-router/test/index.spec.js index ac208f0e2a1..31c3cde8bf5 100644 --- a/packages/datadog-plugin-router/test/index.spec.js +++ b/packages/datadog-plugin-router/test/index.spec.js @@ -71,8 +71,9 @@ describe('Plugin', () => { }) router.use('/parent', childRouter) - expect(router.stack[0].handle.hello).to.equal('goodbye') - expect(router.stack[0].handle.foo).to.equal('bar') + const index = router.stack.length - 1 + expect(router.stack[index].handle.hello).to.equal('goodbye') + expect(router.stack[index].handle.foo).to.equal('bar') }) it('should add the route to the request span', done => { diff --git a/packages/datadog-plugin-sharedb/test/integration-test/server.mjs b/packages/datadog-plugin-sharedb/test/integration-test/server.mjs index c0b93fbcab2..8b593029fc9 100644 --- a/packages/datadog-plugin-sharedb/test/integration-test/server.mjs +++ b/packages/datadog-plugin-sharedb/test/integration-test/server.mjs @@ -4,4 +4,4 @@ import ShareDB from 'sharedb' const backend = new ShareDB({ presence: true }) const connection = backend.connect() await connection.get('some-collection', 'some-id').fetch() -connection.close() \ No newline at end of file +connection.close() diff --git a/packages/datadog-plugin-vitest/src/index.js b/packages/datadog-plugin-vitest/src/index.js index 34617bdb1ac..ba2554bf9f9 100644 --- a/packages/datadog-plugin-vitest/src/index.js +++ b/packages/datadog-plugin-vitest/src/index.js @@ -17,7 +17,12 @@ const { TEST_SOURCE_START, TEST_IS_NEW, TEST_EARLY_FLAKE_ENABLED, - TEST_EARLY_FLAKE_ABORT_REASON + TEST_EARLY_FLAKE_ABORT_REASON, + TEST_NAME, + DI_ERROR_DEBUG_INFO_CAPTURED, + DI_DEBUG_ERROR_SNAPSHOT_ID, + DI_DEBUG_ERROR_FILE, + DI_DEBUG_ERROR_LINE } = require('../../dd-trace/src/plugins/util/test') const { COMPONENT } = require('../../dd-trace/src/constants') const { @@ -31,6 +36,8 @@ const { // This is because there's some loss of resolution. const MILLISECONDS_TO_SUBTRACT_FROM_FAILED_TEST_DURATION = 5 +const debuggerParameterPerTest = new Map() + class VitestPlugin extends CiPlugin { static get id () { return 'vitest' @@ -81,6 +88,26 @@ class VitestPlugin extends CiPlugin { extraTags ) + const debuggerParameters = debuggerParameterPerTest.get(testName) + + if (debuggerParameters) { + const spanContext = span.context() + + // TODO: handle race conditions with this.retriedTestIds + this.retriedTestIds = { + spanId: spanContext.toSpanId(), + traceId: spanContext.toTraceId() + } + const { snapshotId, file, line } = debuggerParameters + + // TODO: should these be added on test:end if and only if the probe is hit? + // Sync issues: `hitProbePromise` might be resolved after the test ends + span.setTag(DI_ERROR_DEBUG_INFO_CAPTURED, 'true') + span.setTag(DI_DEBUG_ERROR_SNAPSHOT_ID, snapshotId) + span.setTag(DI_DEBUG_ERROR_FILE, file) + span.setTag(DI_DEBUG_ERROR_LINE, line) + } + this.enter(span, store) }) @@ -110,11 +137,16 @@ class VitestPlugin extends CiPlugin { } }) - this.addSub('ci:vitest:test:error', ({ duration, error }) => { + this.addSub('ci:vitest:test:error', ({ duration, error, willBeRetried, probe, isDiEnabled }) => { const store = storage.getStore() const span = store?.span if (span) { + if (willBeRetried && this.di && isDiEnabled) { + const testName = span.context()._tags[TEST_NAME] + const debuggerParameters = this.addDiProbe(error, probe) + debuggerParameterPerTest.set(testName, debuggerParameters) + } this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'test', { hasCodeowners: !!span.context()._tags[TEST_CODE_OWNERS] }) diff --git a/packages/datadog-shimmer/src/shimmer.js b/packages/datadog-shimmer/src/shimmer.js index d12c4c130ef..e5f2f189381 100644 --- a/packages/datadog-shimmer/src/shimmer.js +++ b/packages/datadog-shimmer/src/shimmer.js @@ -6,8 +6,12 @@ const log = require('../../dd-trace/src/log') const unwrappers = new WeakMap() function copyProperties (original, wrapped) { - Object.setPrototypeOf(wrapped, original) - + // TODO getPrototypeOf is not fast. Should we instead do this in specific + // instrumentations where needed? + const proto = Object.getPrototypeOf(original) + if (proto !== Function.prototype) { + Object.setPrototypeOf(wrapped, proto) + } const props = Object.getOwnPropertyDescriptors(original) const keys = Reflect.ownKeys(props) @@ -136,7 +140,7 @@ function wrapMethod (target, name, wrapper, noAssert) { if (callState.completed) { // error was thrown after original function returned/resolved, so // it was us. log it. - log.error(e) + log.error('Shimmer error was thrown after original function returned/resolved', e) // original ran and returned something. return it. return callState.retVal } @@ -144,7 +148,7 @@ function wrapMethod (target, name, wrapper, noAssert) { if (!callState.called) { // error was thrown before original function was called, so // it was us. log it. - log.error(e) + log.error('Shimmer error was thrown before original function was called', e) // original never ran. call it unwrapped. return original.apply(this, args) } diff --git a/packages/dd-trace/src/appsec/addresses.js b/packages/dd-trace/src/appsec/addresses.js index cb540bc4e6f..20290baf9c4 100644 --- a/packages/dd-trace/src/appsec/addresses.js +++ b/packages/dd-trace/src/appsec/addresses.js @@ -1,5 +1,6 @@ 'use strict' +// TODO: reorder all this, it's a mess module.exports = { HTTP_INCOMING_BODY: 'server.request.body', HTTP_INCOMING_QUERY: 'server.request.query', @@ -20,6 +21,8 @@ module.exports = { HTTP_CLIENT_IP: 'http.client_ip', USER_ID: 'usr.id', + USER_LOGIN: 'usr.login', + WAF_CONTEXT_PROCESSOR: 'waf.context.processor', HTTP_OUTGOING_URL: 'server.io.net.url', @@ -28,6 +31,7 @@ module.exports = { DB_STATEMENT: 'server.db.statement', DB_SYSTEM: 'server.db.system', + EXEC_COMMAND: 'server.sys.exec.cmd', SHELL_COMMAND: 'server.sys.shell.cmd', LOGIN_SUCCESS: 'server.business_logic.users.login.success', diff --git a/packages/dd-trace/src/appsec/blocked_templates.js b/packages/dd-trace/src/appsec/blocked_templates.js index 1eb62e22df0..3017d4de9db 100644 --- a/packages/dd-trace/src/appsec/blocked_templates.js +++ b/packages/dd-trace/src/appsec/blocked_templates.js @@ -1,4 +1,4 @@ -/* eslint-disable max-len */ +/* eslint-disable @stylistic/js/max-len */ 'use strict' const html = `You've been blocked

Sorry, you cannot access this page. Please contact the customer service team.

` diff --git a/packages/dd-trace/src/appsec/channels.js b/packages/dd-trace/src/appsec/channels.js index 8e7f27211c6..1368e937dc9 100644 --- a/packages/dd-trace/src/appsec/channels.js +++ b/packages/dd-trace/src/appsec/channels.js @@ -19,6 +19,7 @@ module.exports = { nextBodyParsed: dc.channel('apm:next:body-parsed'), nextQueryParsed: dc.channel('apm:next:query-parsed'), expressProcessParams: dc.channel('datadog:express:process_params:start'), + routerParam: dc.channel('datadog:router:param:start'), responseBody: dc.channel('datadog:express:response:json:start'), responseWriteHead: dc.channel('apm:http:server:response:writeHead:start'), httpClientRequestStart: dc.channel('apm:http:client:request:start'), diff --git a/packages/dd-trace/src/appsec/iast/analyzers/code-injection-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/code-injection-analyzer.js index f8937417e42..3741c12ef8f 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/code-injection-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/code-injection-analyzer.js @@ -11,6 +11,10 @@ class CodeInjectionAnalyzer extends InjectionAnalyzer { onConfigure () { this.addSub('datadog:eval:call', ({ script }) => this.analyze(script)) } + + _areRangesVulnerable () { + return true + } } module.exports = new CodeInjectionAnalyzer() diff --git a/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-password-rules.js b/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-password-rules.js index 2e204b72830..04e243c8b5a 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-password-rules.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-password-rules.js @@ -1,4 +1,4 @@ -/* eslint-disable max-len */ +/* eslint-disable @stylistic/js/max-len */ 'use strict' const { NameAndValue } = require('./hardcoded-rule-type') diff --git a/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-secret-rules.js b/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-secret-rules.js index 88ec3d54254..1d61c5fcc91 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-secret-rules.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-secret-rules.js @@ -1,4 +1,4 @@ -/* eslint-disable max-len */ +/* eslint-disable @stylistic/js/max-len */ 'use strict' const { ValueOnly, NameAndValue } = require('./hardcoded-rule-type') diff --git a/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-secrets-rules.js b/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-secrets-rules.js index 88ec3d54254..1d61c5fcc91 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-secrets-rules.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-secrets-rules.js @@ -1,4 +1,4 @@ -/* eslint-disable max-len */ +/* eslint-disable @stylistic/js/max-len */ 'use strict' const { ValueOnly, NameAndValue } = require('./hardcoded-rule-type') diff --git a/packages/dd-trace/src/appsec/iast/analyzers/injection-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/injection-analyzer.js index cb4bc2866b0..f0d42bf95ae 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/injection-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/injection-analyzer.js @@ -1,12 +1,15 @@ 'use strict' const Analyzer = require('./vulnerability-analyzer') -const { isTainted, getRanges } = require('../taint-tracking/operations') +const { getRanges } = require('../taint-tracking/operations') +const { SQL_ROW_VALUE } = require('../taint-tracking/source-types') class InjectionAnalyzer extends Analyzer { _isVulnerable (value, iastContext) { - if (value) { - return isTainted(iastContext, value) + const ranges = value && getRanges(iastContext, value) + if (ranges?.length > 0) { + return this._areRangesVulnerable(ranges) } + return false } @@ -14,6 +17,10 @@ class InjectionAnalyzer extends Analyzer { const ranges = getRanges(iastContext, value) return { value, ranges } } + + _areRangesVulnerable (ranges) { + return ranges?.some(range => range.iinfo.type !== SQL_ROW_VALUE) + } } module.exports = InjectionAnalyzer diff --git a/packages/dd-trace/src/appsec/iast/analyzers/sql-injection-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/sql-injection-analyzer.js index 4d302ece1b6..8f7ca5a39ed 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/sql-injection-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/sql-injection-analyzer.js @@ -82,6 +82,10 @@ class SqlInjectionAnalyzer extends InjectionAnalyzer { return knexDialect.toUpperCase() } } + + _areRangesVulnerable () { + return true + } } module.exports = new SqlInjectionAnalyzer() diff --git a/packages/dd-trace/src/appsec/iast/analyzers/template-injection-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/template-injection-analyzer.js index 1be35933223..8a5af919b2d 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/template-injection-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/template-injection-analyzer.js @@ -13,6 +13,10 @@ class TemplateInjectionAnalyzer extends InjectionAnalyzer { this.addSub('datadog:handlebars:register-partial:start', ({ partial }) => this.analyze(partial)) this.addSub('datadog:pug:compile:start', ({ source }) => this.analyze(source)) } + + _areRangesVulnerable () { + return true + } } module.exports = new TemplateInjectionAnalyzer() diff --git a/packages/dd-trace/src/appsec/iast/iast-plugin.js b/packages/dd-trace/src/appsec/iast/iast-plugin.js index 9c728a189b0..42dab0a4af1 100644 --- a/packages/dd-trace/src/appsec/iast/iast-plugin.js +++ b/packages/dd-trace/src/appsec/iast/iast-plugin.js @@ -60,24 +60,10 @@ class IastPlugin extends Plugin { this.pluginSubs = [] } - _wrapHandler (handler) { - return (message, name) => { - try { - handler(message, name) - } catch (e) { - log.error('[ASM] Error executing IAST plugin handler', e) - } - } - } - _getTelemetryHandler (iastSub) { return () => { - try { - const iastContext = getIastContext(storage.getStore()) - iastSub.increaseExecuted(iastContext) - } catch (e) { - log.error('[ASM] Error increasing handler executed metrics', e) - } + const iastContext = getIastContext(storage.getStore()) + iastSub.increaseExecuted(iastContext) } } @@ -99,11 +85,11 @@ class IastPlugin extends Plugin { addSub (iastSub, handler) { if (typeof iastSub === 'string') { - super.addSub(iastSub, this._wrapHandler(handler)) + super.addSub(iastSub, handler) } else { iastSub = this._getAndRegisterSubscription(iastSub) if (iastSub) { - super.addSub(iastSub.channelName, this._wrapHandler(handler)) + super.addSub(iastSub.channelName, handler) if (iastTelemetry.isEnabled()) { super.addSub(iastSub.channelName, this._getTelemetryHandler(iastSub)) @@ -112,7 +98,8 @@ class IastPlugin extends Plugin { } } - enable () { + enable (iastConfig) { + this.iastConfig = iastConfig this.configure(true) } diff --git a/packages/dd-trace/src/appsec/iast/taint-tracking/index.js b/packages/dd-trace/src/appsec/iast/taint-tracking/index.js index 5c7109c4cda..b541629f3b7 100644 --- a/packages/dd-trace/src/appsec/iast/taint-tracking/index.js +++ b/packages/dd-trace/src/appsec/iast/taint-tracking/index.js @@ -18,10 +18,10 @@ module.exports = { enableTaintTracking (config, telemetryVerbosity) { enableRewriter(telemetryVerbosity) enableTaintOperations(telemetryVerbosity) - taintTrackingPlugin.enable() + taintTrackingPlugin.enable(config) - kafkaContextPlugin.enable() - kafkaConsumerPlugin.enable() + kafkaContextPlugin.enable(config) + kafkaConsumerPlugin.enable(config) setMaxTransactions(config.maxConcurrentRequests) }, diff --git a/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js b/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js index ed46cbe5f2e..9e236666619 100644 --- a/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js +++ b/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js @@ -12,7 +12,8 @@ const { HTTP_REQUEST_HEADER_NAME, HTTP_REQUEST_PARAMETER, HTTP_REQUEST_PATH_PARAM, - HTTP_REQUEST_URI + HTTP_REQUEST_URI, + SQL_ROW_VALUE } = require('./source-types') const { EXECUTED_SOURCE } = require('../telemetry/iast-metric') @@ -26,6 +27,16 @@ class TaintTrackingPlugin extends SourceIastPlugin { this._taintedURLs = new WeakMap() } + configure (config) { + super.configure(config) + + let rowsToTaint = this.iastConfig?.dbRowsToTaint + if (typeof rowsToTaint !== 'number') { + rowsToTaint = 1 + } + this._rowsToTaint = rowsToTaint + } + onConfigure () { const onRequestBody = ({ req }) => { const iastContext = getIastContext(storage.getStore()) @@ -46,8 +57,13 @@ class TaintTrackingPlugin extends SourceIastPlugin { ) this.addSub( - { channelName: 'datadog:qs:parse:finish', tag: HTTP_REQUEST_PARAMETER }, - ({ qs }) => this._taintTrackingHandler(HTTP_REQUEST_PARAMETER, qs) + { channelName: 'datadog:query:read:finish', tag: HTTP_REQUEST_PARAMETER }, + ({ query }) => this._taintTrackingHandler(HTTP_REQUEST_PARAMETER, query) + ) + + this.addSub( + { channelName: 'datadog:express:query:finish', tag: HTTP_REQUEST_PARAMETER }, + ({ query }) => this._taintTrackingHandler(HTTP_REQUEST_PARAMETER, query) ) this.addSub( @@ -68,6 +84,16 @@ class TaintTrackingPlugin extends SourceIastPlugin { ({ cookies }) => this._cookiesTaintTrackingHandler(cookies) ) + this.addSub( + { channelName: 'datadog:sequelize:query:finish', tag: SQL_ROW_VALUE }, + ({ result }) => this._taintDatabaseResult(result, 'sequelize') + ) + + this.addSub( + { channelName: 'apm:pg:query:finish', tag: SQL_ROW_VALUE }, + ({ result }) => this._taintDatabaseResult(result, 'pg') + ) + this.addSub( { channelName: 'datadog:express:process_params:start', tag: HTTP_REQUEST_PATH_PARAM }, ({ req }) => { @@ -77,6 +103,15 @@ class TaintTrackingPlugin extends SourceIastPlugin { } ) + this.addSub( + { channelName: 'datadog:router:param:start', tag: HTTP_REQUEST_PATH_PARAM }, + ({ req }) => { + if (req && req.params !== null && typeof req.params === 'object') { + this._taintTrackingHandler(HTTP_REQUEST_PATH_PARAM, req, 'params') + } + } + ) + this.addSub( { channelName: 'apm:graphql:resolve:start', tag: HTTP_REQUEST_BODY }, (data) => { @@ -170,6 +205,32 @@ class TaintTrackingPlugin extends SourceIastPlugin { this.taintHeaders(req.headers, iastContext) this.taintUrl(req, iastContext) } + + _taintDatabaseResult (result, dbOrigin, iastContext = getIastContext(storage.getStore()), name) { + if (!iastContext) return result + + if (this._rowsToTaint === 0) return result + + if (Array.isArray(result)) { + for (let i = 0; i < result.length && i < this._rowsToTaint; i++) { + const nextName = name ? `${name}.${i}` : '' + i + result[i] = this._taintDatabaseResult(result[i], dbOrigin, iastContext, nextName) + } + } else if (result && typeof result === 'object') { + if (dbOrigin === 'sequelize' && result.dataValues) { + result.dataValues = this._taintDatabaseResult(result.dataValues, dbOrigin, iastContext, name) + } else { + for (const key in result) { + const nextName = name ? `${name}.${key}` : key + result[key] = this._taintDatabaseResult(result[key], dbOrigin, iastContext, nextName) + } + } + } else if (typeof result === 'string') { + result = newTaintedString(iastContext, result, name, SQL_ROW_VALUE) + } + + return result + } } module.exports = new TaintTrackingPlugin() diff --git a/packages/dd-trace/src/appsec/iast/taint-tracking/source-types.js b/packages/dd-trace/src/appsec/iast/taint-tracking/source-types.js index f5c2ca2e8b0..f3ccf0505c3 100644 --- a/packages/dd-trace/src/appsec/iast/taint-tracking/source-types.js +++ b/packages/dd-trace/src/appsec/iast/taint-tracking/source-types.js @@ -11,5 +11,6 @@ module.exports = { HTTP_REQUEST_PATH_PARAM: 'http.request.path.parameter', HTTP_REQUEST_URI: 'http.request.uri', KAFKA_MESSAGE_KEY: 'kafka.message.key', - KAFKA_MESSAGE_VALUE: 'kafka.message.value' + KAFKA_MESSAGE_VALUE: 'kafka.message.value', + SQL_ROW_VALUE: 'sql.row.value' } diff --git a/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/evidence-redaction/sensitive-regex.js b/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/evidence-redaction/sensitive-regex.js index fe9d22f9c49..e0054b8546f 100644 --- a/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/evidence-redaction/sensitive-regex.js +++ b/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/evidence-redaction/sensitive-regex.js @@ -1,6 +1,6 @@ -// eslint-disable-next-line max-len +// eslint-disable-next-line @stylistic/js/max-len const DEFAULT_IAST_REDACTION_NAME_PATTERN = '(?:p(?:ass)?w(?:or)?d|pass(?:_?phrase)?|secret|(?:api_?|private_?|public_?|access_?|secret_?)key(?:_?id)?|token|consumer_?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?|(?:sur|last)name|user(?:name)?|address|e?mail)' -// eslint-disable-next-line max-len +// eslint-disable-next-line @stylistic/js/max-len const DEFAULT_IAST_REDACTION_VALUE_PATTERN = '(?:bearer\\s+[a-z0-9\\._\\-]+|glpat-[\\w\\-]{20}|gh[opsu]_[0-9a-zA-Z]{36}|ey[I-L][\\w=\\-]+\\.ey[I-L][\\w=\\-]+(?:\\.[\\w.+/=\\-]+)?|(?:[\\-]{5}BEGIN[a-z\\s]+PRIVATE\\sKEY[\\-]{5}[^\\-]+[\\-]{5}END[a-z\\s]+PRIVATE\\sKEY[\\-]{5}|ssh-rsa\\s*[a-z0-9/\\.+]{100,})|[\\w\\.-]+@[a-zA-Z\\d\\.-]+\\.[a-zA-Z]{2,})' module.exports = { diff --git a/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js b/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js index 959df790afd..256b47f5532 100644 --- a/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js +++ b/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js @@ -7,7 +7,7 @@ const STRINGIFY_RANGE_KEY = 'DD_' + crypto.randomBytes(20).toString('hex') const STRINGIFY_SENSITIVE_KEY = STRINGIFY_RANGE_KEY + 'SENSITIVE' const STRINGIFY_SENSITIVE_NOT_STRING_KEY = STRINGIFY_SENSITIVE_KEY + 'NOTSTRING' -// eslint-disable-next-line max-len +// eslint-disable-next-line @stylistic/js/max-len const KEYS_REGEX_WITH_SENSITIVE_RANGES = new RegExp(`(?:"(${STRINGIFY_RANGE_KEY}_\\d+_))|(?:"(${STRINGIFY_SENSITIVE_KEY}_\\d+_(\\d+)_))|("${STRINGIFY_SENSITIVE_NOT_STRING_KEY}_\\d+_([\\s0-9.a-zA-Z]*)")`, 'gm') const KEYS_REGEX_WITHOUT_SENSITIVE_RANGES = new RegExp(`"(${STRINGIFY_RANGE_KEY}_\\d+_)`, 'gm') diff --git a/packages/dd-trace/src/appsec/iast/vulnerability-reporter.js b/packages/dd-trace/src/appsec/iast/vulnerability-reporter.js index e2d1619b118..05aea14cf02 100644 --- a/packages/dd-trace/src/appsec/iast/vulnerability-reporter.js +++ b/packages/dd-trace/src/appsec/iast/vulnerability-reporter.js @@ -17,13 +17,36 @@ let resetVulnerabilityCacheTimer let deduplicationEnabled = true function addVulnerability (iastContext, vulnerability) { - if (vulnerability && vulnerability.evidence && vulnerability.type && - vulnerability.location) { - if (iastContext && iastContext.rootSpan) { + if (vulnerability?.evidence && vulnerability?.type && vulnerability?.location) { + if (deduplicationEnabled && isDuplicatedVulnerability(vulnerability)) return + + VULNERABILITY_HASHES.set(`${vulnerability.type}${vulnerability.hash}`, true) + + let span = iastContext?.rootSpan + + if (!span && tracer) { + span = tracer.startSpan('vulnerability', { + type: 'vulnerability' + }) + + vulnerability.location.spanId = span.context().toSpanId() + + span.addTags({ + [IAST_ENABLED_TAG_KEY]: 1 + }) + } + + if (!span) return + + keepTrace(span, SAMPLING_MECHANISM_APPSEC) + standalone.sample(span) + + if (iastContext?.rootSpan) { iastContext[VULNERABILITIES_KEY] = iastContext[VULNERABILITIES_KEY] || [] iastContext[VULNERABILITIES_KEY].push(vulnerability) } else { - sendVulnerabilities([vulnerability]) + sendVulnerabilities([vulnerability], span) + span.finish() } } } @@ -34,36 +57,17 @@ function isValidVulnerability (vulnerability) { vulnerability.location && vulnerability.location.spanId } -function sendVulnerabilities (vulnerabilities, rootSpan) { +function sendVulnerabilities (vulnerabilities, span) { if (vulnerabilities && vulnerabilities.length) { - let span = rootSpan - if (!span && tracer) { - span = tracer.startSpan('vulnerability', { - type: 'vulnerability' - }) - vulnerabilities.forEach((vulnerability) => { - vulnerability.location.spanId = span.context().toSpanId() - }) - span.addTags({ - [IAST_ENABLED_TAG_KEY]: 1 - }) - } - if (span && span.addTags) { - const validAndDedupVulnerabilities = deduplicateVulnerabilities(vulnerabilities).filter(isValidVulnerability) - const jsonToSend = vulnerabilitiesFormatter.toJson(validAndDedupVulnerabilities) + const validatedVulnerabilities = vulnerabilities.filter(isValidVulnerability) + const jsonToSend = vulnerabilitiesFormatter.toJson(validatedVulnerabilities) if (jsonToSend.vulnerabilities.length > 0) { const tags = {} // TODO: Store this outside of the span and set the tag in the exporter. tags[IAST_JSON_TAG_KEY] = JSON.stringify(jsonToSend) span.addTags(tags) - - keepTrace(span, SAMPLING_MECHANISM_APPSEC) - - standalone.sample(span) - - if (!rootSpan) span.finish() } } } @@ -86,17 +90,8 @@ function stopClearCacheTimer () { } } -function deduplicateVulnerabilities (vulnerabilities) { - if (!deduplicationEnabled) return vulnerabilities - const deduplicated = vulnerabilities.filter((vulnerability) => { - const key = `${vulnerability.type}${vulnerability.hash}` - if (!VULNERABILITY_HASHES.get(key)) { - VULNERABILITY_HASHES.set(key, true) - return true - } - return false - }) - return deduplicated +function isDuplicatedVulnerability (vulnerability) { + return VULNERABILITY_HASHES.get(`${vulnerability.type}${vulnerability.hash}`) } function start (config, _tracer) { diff --git a/packages/dd-trace/src/appsec/index.js b/packages/dd-trace/src/appsec/index.js index be5273f815f..db089a61dca 100644 --- a/packages/dd-trace/src/appsec/index.js +++ b/packages/dd-trace/src/appsec/index.js @@ -16,7 +16,8 @@ const { expressProcessParams, responseBody, responseWriteHead, - responseSetHeader + responseSetHeader, + routerParam } = require('./channels') const waf = require('./waf') const addresses = require('./addresses') @@ -27,7 +28,7 @@ const web = require('../plugins/util/web') const { extractIp } = require('../plugins/util/ip_extractor') const { HTTP_CLIENT_IP } = require('../../../../ext/tags') const { isBlocked, block, setTemplates, getBlockingAction } = require('./blocking') -const { passportTrackEvent } = require('./passport') +const UserTracking = require('./user_tracking') const { storage } = require('../../../datadog-core') const graphql = require('./graphql') const rasp = require('./rasp') @@ -58,23 +59,23 @@ function enable (_config) { apiSecuritySampler.configure(_config.appsec) + UserTracking.setCollectionMode(_config.appsec.eventTracking.mode, false) + bodyParser.subscribe(onRequestBodyParsed) multerParser.subscribe(onRequestBodyParsed) cookieParser.subscribe(onRequestCookieParser) incomingHttpRequestStart.subscribe(incomingHttpStartTranslator) incomingHttpRequestEnd.subscribe(incomingHttpEndTranslator) + passportVerify.subscribe(onPassportVerify) // possible optimization: only subscribe if collection mode is enabled queryParser.subscribe(onRequestQueryParsed) nextBodyParsed.subscribe(onRequestBodyParsed) nextQueryParsed.subscribe(onRequestQueryParsed) expressProcessParams.subscribe(onRequestProcessParams) + routerParam.subscribe(onRequestProcessParams) responseBody.subscribe(onResponseBody) responseWriteHead.subscribe(onResponseWriteHead) responseSetHeader.subscribe(onResponseSetHeader) - if (_config.appsec.eventTracking.enabled) { - passportVerify.subscribe(onPassportVerify) - } - isEnabled = true config = _config } catch (err) { @@ -163,8 +164,10 @@ function incomingHttpEndTranslator ({ req, res }) { persistent[addresses.HTTP_INCOMING_COOKIES] = req.cookies } - if (req.query !== null && typeof req.query === 'object') { - persistent[addresses.HTTP_INCOMING_QUERY] = req.query + // we need to keep this to support nextjs + const query = req.query + if (query !== null && typeof query === 'object') { + persistent[addresses.HTTP_INCOMING_QUERY] = query } if (apiSecuritySampler.sampleRequest(req, res, true)) { @@ -180,7 +183,7 @@ function incomingHttpEndTranslator ({ req, res }) { Reporter.finishRequest(req, res) } -function onPassportVerify ({ credentials, user }) { +function onPassportVerify ({ framework, login, user, success, abortController }) { const store = storage.getStore() const rootSpan = store?.req && web.root(store.req) @@ -189,7 +192,9 @@ function onPassportVerify ({ credentials, user }) { return } - passportTrackEvent(credentials, user, rootSpan, config.appsec.eventTracking.mode) + const results = UserTracking.trackLogin(framework, login, user, success, rootSpan) + + handleResults(results, store.req, store.req.res, rootSpan, abortController) } function onRequestQueryParsed ({ req, res, query, abortController }) { @@ -309,6 +314,7 @@ function disable () { if (nextBodyParsed.hasSubscribers) nextBodyParsed.unsubscribe(onRequestBodyParsed) if (nextQueryParsed.hasSubscribers) nextQueryParsed.unsubscribe(onRequestQueryParsed) if (expressProcessParams.hasSubscribers) expressProcessParams.unsubscribe(onRequestProcessParams) + if (routerParam.hasSubscribers) routerParam.unsubscribe(onRequestProcessParams) if (responseBody.hasSubscribers) responseBody.unsubscribe(onResponseBody) if (responseWriteHead.hasSubscribers) responseWriteHead.unsubscribe(onResponseWriteHead) if (responseSetHeader.hasSubscribers) responseSetHeader.unsubscribe(onResponseSetHeader) diff --git a/packages/dd-trace/src/appsec/passport.js b/packages/dd-trace/src/appsec/passport.js deleted file mode 100644 index 2093b7b1fdc..00000000000 --- a/packages/dd-trace/src/appsec/passport.js +++ /dev/null @@ -1,110 +0,0 @@ -'use strict' - -const log = require('../log') -const { trackEvent } = require('./sdk/track_event') -const { setUserTags } = require('./sdk/set_user') - -const UUID_PATTERN = '^[0-9A-F]{8}-[0-9A-F]{4}-[1-5][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$' -const regexUsername = new RegExp(UUID_PATTERN, 'i') - -const SDK_USER_EVENT_PATTERN = '^_dd\\.appsec\\.events\\.users\\.[\\W\\w+]+\\.sdk$' -const regexSdkEvent = new RegExp(SDK_USER_EVENT_PATTERN, 'i') - -function isSdkCalled (tags) { - let called = false - - if (tags !== null && typeof tags === 'object') { - called = Object.entries(tags).some(([key, value]) => regexSdkEvent.test(key) && value === 'true') - } - - return called -} - -// delete this function later if we know it's always credential.username -function getLogin (credentials) { - const type = credentials && credentials.type - let login - if (type === 'local' || type === 'http') { - login = credentials.username - } - - return login -} - -function parseUser (login, passportUser, mode) { - const user = { - 'usr.id': login - } - - if (!user['usr.id']) { - return user - } - - if (passportUser) { - // Guess id - if (passportUser.id) { - user['usr.id'] = passportUser.id - } else if (passportUser._id) { - user['usr.id'] = passportUser._id - } - - if (mode === 'extended') { - if (login) { - user['usr.login'] = login - } - - if (passportUser.email) { - user['usr.email'] = passportUser.email - } - - // Guess username - if (passportUser.username) { - user['usr.username'] = passportUser.username - } else if (passportUser.name) { - user['usr.username'] = passportUser.name - } - } - } - - if (mode === 'safe') { - // Remove PII in safe mode - if (!regexUsername.test(user['usr.id'])) { - user['usr.id'] = '' - } - } - - return user -} - -function passportTrackEvent (credentials, passportUser, rootSpan, mode) { - const tags = rootSpan && rootSpan.context() && rootSpan.context()._tags - - if (isSdkCalled(tags)) { - // Don't overwrite tags set by SDK callings - return - } - const user = parseUser(getLogin(credentials), passportUser, mode) - - if (user['usr.id'] === undefined) { - log.warn('No user ID found in authentication instrumentation') - return - } - - if (passportUser) { - // If a passportUser object is published then the login succeded - const userTags = {} - Object.entries(user).forEach(([k, v]) => { - const attr = k.split('.', 2)[1] - userTags[attr] = v - }) - - setUserTags(userTags, rootSpan) - trackEvent('users.login.success', null, 'passportTrackEvent', rootSpan, mode) - } else { - trackEvent('users.login.failure', user, 'passportTrackEvent', rootSpan, mode) - } -} - -module.exports = { - passportTrackEvent -} diff --git a/packages/dd-trace/src/appsec/rasp/command_injection.js b/packages/dd-trace/src/appsec/rasp/command_injection.js index 8d6d977aace..62546e2b6a6 100644 --- a/packages/dd-trace/src/appsec/rasp/command_injection.js +++ b/packages/dd-trace/src/appsec/rasp/command_injection.js @@ -25,19 +25,26 @@ function disable () { } function analyzeCommandInjection ({ file, fileArgs, shell, abortController }) { - if (!file || !shell) return + if (!file) return const store = storage.getStore() const req = store?.req if (!req) return - const commandParams = fileArgs ? [file, ...fileArgs] : file - - const persistent = { - [addresses.SHELL_COMMAND]: commandParams + const persistent = {} + const raspRule = { type: RULE_TYPES.COMMAND_INJECTION } + const params = fileArgs ? [file, ...fileArgs] : file + + if (shell) { + persistent[addresses.SHELL_COMMAND] = params + raspRule.variant = 'shell' + } else { + const commandParams = Array.isArray(params) ? params : [params] + persistent[addresses.EXEC_COMMAND] = commandParams + raspRule.variant = 'exec' } - const result = waf.run({ persistent }, req, RULE_TYPES.COMMAND_INJECTION) + const result = waf.run({ persistent }, req, raspRule) const res = store?.res handleResult(result, req, res, abortController, config) diff --git a/packages/dd-trace/src/appsec/rasp/lfi.js b/packages/dd-trace/src/appsec/rasp/lfi.js index 1190734064d..657369ad0fd 100644 --- a/packages/dd-trace/src/appsec/rasp/lfi.js +++ b/packages/dd-trace/src/appsec/rasp/lfi.js @@ -58,7 +58,9 @@ function analyzeLfi (ctx) { [FS_OPERATION_PATH]: path } - const result = waf.run({ persistent }, req, RULE_TYPES.LFI) + const raspRule = { type: RULE_TYPES.LFI } + + const result = waf.run({ persistent }, req, raspRule) handleResult(result, req, res, ctx.abortController, config) }) } diff --git a/packages/dd-trace/src/appsec/rasp/sql_injection.js b/packages/dd-trace/src/appsec/rasp/sql_injection.js index d4a165d8615..157723258f7 100644 --- a/packages/dd-trace/src/appsec/rasp/sql_injection.js +++ b/packages/dd-trace/src/appsec/rasp/sql_injection.js @@ -72,7 +72,9 @@ function analyzeSqlInjection (query, dbSystem, abortController) { [addresses.DB_SYSTEM]: dbSystem } - const result = waf.run({ persistent }, req, RULE_TYPES.SQL_INJECTION) + const raspRule = { type: RULE_TYPES.SQL_INJECTION } + + const result = waf.run({ persistent }, req, raspRule) handleResult(result, req, res, abortController, config) } diff --git a/packages/dd-trace/src/appsec/rasp/ssrf.js b/packages/dd-trace/src/appsec/rasp/ssrf.js index 38a3c150d74..7d429d74549 100644 --- a/packages/dd-trace/src/appsec/rasp/ssrf.js +++ b/packages/dd-trace/src/appsec/rasp/ssrf.js @@ -29,7 +29,9 @@ function analyzeSsrf (ctx) { [addresses.HTTP_OUTGOING_URL]: outgoingUrl } - const result = waf.run({ persistent }, req, RULE_TYPES.SSRF) + const raspRule = { type: RULE_TYPES.SSRF } + + const result = waf.run({ persistent }, req, raspRule) const res = store?.res handleResult(result, req, res, ctx.abortController, config) diff --git a/packages/dd-trace/src/appsec/remote_config/capabilities.js b/packages/dd-trace/src/appsec/remote_config/capabilities.js index bd729cc39cc..5057d38de43 100644 --- a/packages/dd-trace/src/appsec/remote_config/capabilities.js +++ b/packages/dd-trace/src/appsec/remote_config/capabilities.js @@ -22,7 +22,9 @@ module.exports = { ASM_RASP_SSRF: 1n << 23n, ASM_RASP_SHI: 1n << 24n, APM_TRACING_SAMPLE_RULES: 1n << 29n, + ASM_AUTO_USER_INSTRUM_MODE: 1n << 31n, ASM_ENDPOINT_FINGERPRINT: 1n << 32n, ASM_NETWORK_FINGERPRINT: 1n << 34n, - ASM_HEADER_FINGERPRINT: 1n << 35n + ASM_HEADER_FINGERPRINT: 1n << 35n, + ASM_RASP_CMDI: 1n << 37n } diff --git a/packages/dd-trace/src/appsec/remote_config/index.js b/packages/dd-trace/src/appsec/remote_config/index.js index 90cda5c6f61..6bebe40e142 100644 --- a/packages/dd-trace/src/appsec/remote_config/index.js +++ b/packages/dd-trace/src/appsec/remote_config/index.js @@ -4,6 +4,8 @@ const Activation = require('../activation') const RemoteConfigManager = require('./manager') const RemoteConfigCapabilities = require('./capabilities') +const { setCollectionMode } = require('../user_tracking') +const log = require('../../log') let rc @@ -23,9 +25,31 @@ function enable (config, appsec) { rc.updateCapabilities(RemoteConfigCapabilities.ASM_ACTIVATION, true) } - rc.setProductHandler('ASM_FEATURES', (action, rcConfig) => { + rc.updateCapabilities(RemoteConfigCapabilities.ASM_AUTO_USER_INSTRUM_MODE, true) + + let autoUserInstrumModeId + + rc.setProductHandler('ASM_FEATURES', (action, rcConfig, configId) => { if (!rcConfig) return + // this is put before other handlers because it can reject the config + if (typeof rcConfig.auto_user_instrum?.mode === 'string') { + if (action === 'apply' || action === 'modify') { + // check if there is already a config applied with this field + if (autoUserInstrumModeId && configId !== autoUserInstrumModeId) { + log.error('[RC] Multiple auto_user_instrum received in ASM_FEATURES. Discarding config') + // eslint-disable-next-line no-throw-literal + throw 'Multiple auto_user_instrum.mode received in ASM_FEATURES' + } + + setCollectionMode(rcConfig.auto_user_instrum.mode) + autoUserInstrumModeId = configId + } else if (configId === autoUserInstrumModeId) { + setCollectionMode(config.appsec.eventTracking.mode) + autoUserInstrumModeId = null + } + } + if (activation === Activation.ONECLICK) { enableOrDisableAppsec(action, rcConfig, config, appsec) } @@ -77,6 +101,7 @@ function enableWafUpdate (appsecConfig) { rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SSRF, true) rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_LFI, true) rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SHI, true) + rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_CMDI, true) } // TODO: delete noop handlers and kPreUpdate and replace with batched handlers @@ -109,6 +134,7 @@ function disableWafUpdate () { rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SSRF, false) rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_LFI, false) rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SHI, false) + rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_CMDI, false) rc.removeProductHandler('ASM_DATA') rc.removeProductHandler('ASM_DD') diff --git a/packages/dd-trace/src/appsec/reporter.js b/packages/dd-trace/src/appsec/reporter.js index be038279dc8..c2f9bac6cbc 100644 --- a/packages/dd-trace/src/appsec/reporter.js +++ b/packages/dd-trace/src/appsec/reporter.js @@ -101,7 +101,7 @@ function reportWafInit (wafVersion, rulesVersion, diagnosticsRules = {}) { incrementWafInitMetric(wafVersion, rulesVersion) } -function reportMetrics (metrics, raspRuleType) { +function reportMetrics (metrics, raspRule) { const store = storage.getStore() const rootSpan = store?.req && web.root(store.req) if (!rootSpan) return @@ -109,8 +109,8 @@ function reportMetrics (metrics, raspRuleType) { if (metrics.rulesVersion) { rootSpan.setTag('_dd.appsec.event_rules.version', metrics.rulesVersion) } - if (raspRuleType) { - updateRaspRequestsMetricTags(metrics, store.req, raspRuleType) + if (raspRule) { + updateRaspRequestsMetricTags(metrics, store.req, raspRule) } else { updateWafRequestsMetricTags(metrics, store.req) } @@ -148,7 +148,9 @@ function reportAttack (attackData) { newTags['_dd.appsec.json'] = '{"triggers":' + attackData + '}' } - newTags['network.client.ip'] = req.socket.remoteAddress + if (req.socket) { + newTags['network.client.ip'] = req.socket.remoteAddress + } rootSpan.addTags(newTags) } diff --git a/packages/dd-trace/src/appsec/sdk/track_event.js b/packages/dd-trace/src/appsec/sdk/track_event.js index 0c1ef9c2bd9..a04f596bbc3 100644 --- a/packages/dd-trace/src/appsec/sdk/track_event.js +++ b/packages/dd-trace/src/appsec/sdk/track_event.js @@ -7,6 +7,7 @@ const standalone = require('../standalone') const waf = require('../waf') const { SAMPLING_MECHANISM_APPSEC } = require('../../constants') const { keepTrace } = require('../../priority_sampler') +const addresses = require('../addresses') function trackUserLoginSuccessEvent (tracer, user, metadata) { // TODO: better user check here and in _setUser() ? @@ -23,7 +24,13 @@ function trackUserLoginSuccessEvent (tracer, user, metadata) { setUserTags(user, rootSpan) - trackEvent('users.login.success', metadata, 'trackUserLoginSuccessEvent', rootSpan, 'sdk') + const login = user.login ?? user.id + + metadata = { 'usr.login': login, ...metadata } + + trackEvent('users.login.success', metadata, 'trackUserLoginSuccessEvent', rootSpan) + + runWaf('users.login.success', { id: user.id, login }) } function trackUserLoginFailureEvent (tracer, userId, exists, metadata) { @@ -34,11 +41,14 @@ function trackUserLoginFailureEvent (tracer, userId, exists, metadata) { const fields = { 'usr.id': userId, + 'usr.login': userId, 'usr.exists': exists ? 'true' : 'false', ...metadata } - trackEvent('users.login.failure', fields, 'trackUserLoginFailureEvent', getRootSpan(tracer), 'sdk') + trackEvent('users.login.failure', fields, 'trackUserLoginFailureEvent', getRootSpan(tracer)) + + runWaf('users.login.failure', { login: userId }) } function trackCustomEvent (tracer, eventName, metadata) { @@ -47,27 +57,18 @@ function trackCustomEvent (tracer, eventName, metadata) { return } - trackEvent(eventName, metadata, 'trackCustomEvent', getRootSpan(tracer), 'sdk') + trackEvent(eventName, metadata, 'trackCustomEvent', getRootSpan(tracer)) } -function trackEvent (eventName, fields, sdkMethodName, rootSpan, mode) { +function trackEvent (eventName, fields, sdkMethodName, rootSpan) { if (!rootSpan) { log.warn('[ASM] Root span not available in %s', sdkMethodName) return } - keepTrace(rootSpan, SAMPLING_MECHANISM_APPSEC) - const tags = { - [`appsec.events.${eventName}.track`]: 'true' - } - - if (mode === 'sdk') { - tags[`_dd.appsec.events.${eventName}.sdk`] = 'true' - } - - if (mode === 'safe' || mode === 'extended') { - tags[`_dd.appsec.events.${eventName}.auto.mode`] = mode + [`appsec.events.${eventName}.track`]: 'true', + [`_dd.appsec.events.${eventName}.sdk`]: 'true' } if (fields) { @@ -78,16 +79,28 @@ function trackEvent (eventName, fields, sdkMethodName, rootSpan, mode) { rootSpan.addTags(tags) + keepTrace(rootSpan, SAMPLING_MECHANISM_APPSEC) standalone.sample(rootSpan) +} + +function runWaf (eventName, user) { + const persistent = { + [`server.business_logic.${eventName}`]: null + } + + if (user.id) { + persistent[addresses.USER_ID] = '' + user.id + } - if (['users.login.success', 'users.login.failure'].includes(eventName)) { - waf.run({ persistent: { [`server.business_logic.${eventName}`]: null } }) + if (user.login) { + persistent[addresses.USER_LOGIN] = '' + user.login } + + waf.run({ persistent }) } module.exports = { trackUserLoginSuccessEvent, trackUserLoginFailureEvent, - trackCustomEvent, - trackEvent + trackCustomEvent } diff --git a/packages/dd-trace/src/appsec/telemetry.js b/packages/dd-trace/src/appsec/telemetry.js index d96ca77601f..08f435b9c0e 100644 --- a/packages/dd-trace/src/appsec/telemetry.js +++ b/packages/dd-trace/src/appsec/telemetry.js @@ -79,7 +79,7 @@ function getOrCreateMetricTags (store, versionsTags) { return metricTags } -function updateRaspRequestsMetricTags (metrics, req, raspRuleType) { +function updateRaspRequestsMetricTags (metrics, req, raspRule) { if (!req) return const store = getStore(req) @@ -89,7 +89,12 @@ function updateRaspRequestsMetricTags (metrics, req, raspRuleType) { if (!enabled) return - const tags = { rule_type: raspRuleType, waf_version: metrics.wafVersion } + const tags = { rule_type: raspRule.type, waf_version: metrics.wafVersion } + + if (raspRule.variant) { + tags.rule_variant = raspRule.variant + } + appsecMetrics.count('rasp.rule.eval', tags).inc(1) if (metrics.wafTimeout) { @@ -172,6 +177,15 @@ function addRaspRequestMetrics (store, { duration, durationExt }) { store[DD_TELEMETRY_REQUEST_METRICS].raspEvalCount++ } +function incrementMissingUserLoginMetric (framework, eventType) { + if (!enabled) return + + appsecMetrics.count('instrum.user_auth.missing_user_login', { + framework, + event_type: eventType + }).inc() +} + function getRequestMetrics (req) { if (req) { const store = getStore(req) @@ -188,6 +202,7 @@ module.exports = { incrementWafInitMetric, incrementWafUpdatesMetric, incrementWafRequestsMetric, + incrementMissingUserLoginMetric, getRequestMetrics } diff --git a/packages/dd-trace/src/appsec/user_tracking.js b/packages/dd-trace/src/appsec/user_tracking.js new file mode 100644 index 00000000000..5b92f80d642 --- /dev/null +++ b/packages/dd-trace/src/appsec/user_tracking.js @@ -0,0 +1,168 @@ +'use strict' + +const crypto = require('crypto') +const log = require('../log') +const telemetry = require('./telemetry') +const addresses = require('./addresses') +const { keepTrace } = require('../priority_sampler') +const { SAMPLING_MECHANISM_APPSEC } = require('../constants') +const standalone = require('./standalone') +const waf = require('./waf') + +// the RFC doesn't include '_id', but it's common in MongoDB +const USER_ID_FIELDS = ['id', '_id', 'email', 'username', 'login', 'user'] + +let collectionMode + +function setCollectionMode (mode, overwrite = true) { + // don't overwrite if already set, only used in appsec/index.js to not overwrite RC values + if (!overwrite && collectionMode) return + + /* eslint-disable no-fallthrough */ + switch (mode) { + case 'safe': + log.warn('[ASM] Using deprecated value "safe" in config.appsec.eventTracking.mode') + case 'anon': + case 'anonymization': + collectionMode = 'anonymization' + break + + case 'extended': + log.warn('[ASM] Using deprecated value "extended" in config.appsec.eventTracking.mode') + case 'ident': + case 'identification': + collectionMode = 'identification' + break + + default: + collectionMode = 'disabled' + } + /* eslint-enable no-fallthrough */ +} + +function obfuscateIfNeeded (str) { + if (collectionMode === 'anonymization') { + // get first 16 bytes of sha256 hash in lowercase hex + return 'anon_' + crypto.createHash('sha256').update(str).digest().toString('hex', 0, 16).toLowerCase() + } else { + return str + } +} + +// TODO: should we find other ways to get the user ID ? +function getUserId (user) { + if (!user) return + + for (const field of USER_ID_FIELDS) { + let id = user[field] + + // try to find a field that can be stringified + if (id && typeof id.toString === 'function') { + id = id.toString() + + if (typeof id !== 'string' || id.startsWith('[object ')) { + // probably not a usable ID ? + continue + } + + return obfuscateIfNeeded(id) + } + } +} + +function trackLogin (framework, login, user, success, rootSpan) { + if (!collectionMode || collectionMode === 'disabled') return + + if (!rootSpan) { + log.error('[ASM] No rootSpan found in AppSec trackLogin') + return + } + + if (typeof login !== 'string') { + log.error('[ASM] Invalid login provided to AppSec trackLogin') + + telemetry.incrementMissingUserLoginMetric(framework, success ? 'login_success' : 'login_failure') + // note: + // if we start supporting using userId if login is missing, we need to only give up if both are missing, and + // implement 'appsec.instrum.user_auth.missing_user_id' telemetry too + return + } + + login = obfuscateIfNeeded(login) + const userId = getUserId(user) + + let newTags + + const persistent = { + [addresses.USER_LOGIN]: login + } + + const currentTags = rootSpan.context()._tags + const isSdkCalled = currentTags[`_dd.appsec.events.users.login.${success ? 'success' : 'failure'}.sdk`] === 'true' + + // used to not overwrite tags set by SDK + function shouldSetTag (tag) { + return !(isSdkCalled && currentTags[tag]) + } + + if (success) { + newTags = { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': collectionMode, + '_dd.appsec.usr.login': login + } + + if (shouldSetTag('appsec.events.users.login.success.usr.login')) { + newTags['appsec.events.users.login.success.usr.login'] = login + } + + if (userId) { + newTags['_dd.appsec.usr.id'] = userId + + if (shouldSetTag('usr.id')) { + newTags['usr.id'] = userId + persistent[addresses.USER_ID] = userId + } + } + + persistent[addresses.LOGIN_SUCCESS] = null + } else { + newTags = { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': collectionMode, + '_dd.appsec.usr.login': login + } + + if (shouldSetTag('appsec.events.users.login.failure.usr.login')) { + newTags['appsec.events.users.login.failure.usr.login'] = login + } + + if (userId) { + newTags['_dd.appsec.usr.id'] = userId + + if (shouldSetTag('appsec.events.users.login.failure.usr.id')) { + newTags['appsec.events.users.login.failure.usr.id'] = userId + } + } + + /* TODO: if one day we have this info + if (exists != null && shouldSetTag('appsec.events.users.login.failure.usr.exists')) { + newTags['appsec.events.users.login.failure.usr.exists'] = exists + } + */ + + persistent[addresses.LOGIN_FAILURE] = null + } + + keepTrace(rootSpan, SAMPLING_MECHANISM_APPSEC) + standalone.sample(rootSpan) + + rootSpan.addTags(newTags) + + return waf.run({ persistent }) +} + +module.exports = { + setCollectionMode, + trackLogin +} diff --git a/packages/dd-trace/src/appsec/waf/index.js b/packages/dd-trace/src/appsec/waf/index.js index 3b2bc9e2a13..a14a5313a92 100644 --- a/packages/dd-trace/src/appsec/waf/index.js +++ b/packages/dd-trace/src/appsec/waf/index.js @@ -46,7 +46,7 @@ function update (newRules) { } } -function run (data, req, raspRuleType) { +function run (data, req, raspRule) { if (!req) { const store = storage.getStore() if (!store || !store.req) { @@ -59,7 +59,7 @@ function run (data, req, raspRuleType) { const wafContext = waf.wafManager.getWAFContext(req) - return wafContext.run(data, raspRuleType) + return wafContext.run(data, raspRule) } function disposeContext (req) { diff --git a/packages/dd-trace/src/appsec/waf/waf_context_wrapper.js b/packages/dd-trace/src/appsec/waf/waf_context_wrapper.js index 6a90b8f89bb..54dbd16e1be 100644 --- a/packages/dd-trace/src/appsec/waf/waf_context_wrapper.js +++ b/packages/dd-trace/src/appsec/waf/waf_context_wrapper.js @@ -21,7 +21,7 @@ class WAFContextWrapper { this.knownAddresses = knownAddresses } - run ({ persistent, ephemeral }, raspRuleType) { + run ({ persistent, ephemeral }, raspRule) { if (this.ddwafContext.disposed) { log.warn('[ASM] Calling run on a disposed context') return @@ -87,7 +87,7 @@ class WAFContextWrapper { blockTriggered, wafVersion: this.wafVersion, wafTimeout: result.timeout - }, raspRuleType) + }, raspRule) if (ruleTriggered) { Reporter.reportAttack(JSON.stringify(result.events)) diff --git a/packages/dd-trace/src/azure_metadata.js b/packages/dd-trace/src/azure_metadata.js index 94c29c9dd16..6895f28b479 100644 --- a/packages/dd-trace/src/azure_metadata.js +++ b/packages/dd-trace/src/azure_metadata.js @@ -1,6 +1,6 @@ 'use strict' -// eslint-disable-next-line max-len +// eslint-disable-next-line @stylistic/js/max-len // Modeled after https://github.com/DataDog/libdatadog/blob/f3994857a59bb5679a65967138c5a3aec418a65f/ddcommon/src/azure_app_services.rs const os = require('os') @@ -79,7 +79,7 @@ function buildMetadata () { function getAzureAppMetadata () { // DD_AZURE_APP_SERVICES is an environment variable introduced by the .NET APM team and is set automatically for // anyone using the Datadog APM Extensions (.NET, Java, or Node) for Windows Azure App Services - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len // See: https://github.com/DataDog/datadog-aas-extension/blob/01f94b5c28b7fa7a9ab264ca28bd4e03be603900/node/src/applicationHost.xdt#L20-L21 return process.env.DD_AZURE_APP_SERVICES !== undefined ? buildMetadata() : undefined } @@ -88,9 +88,9 @@ function getAzureFunctionMetadata () { return getIsAzureFunction() ? buildMetadata() : undefined } -// eslint-disable-next-line max-len +// eslint-disable-next-line @stylistic/js/max-len // Modeled after https://github.com/DataDog/libdatadog/blob/92272e90a7919f07178f3246ef8f82295513cfed/profiling/src/exporter/mod.rs#L187 -// eslint-disable-next-line max-len +// eslint-disable-next-line @stylistic/js/max-len // and https://github.com/DataDog/libdatadog/blob/f3994857a59bb5679a65967138c5a3aec418a65f/trace-utils/src/trace_utils.rs#L533 function getAzureTagsFromMetadata (metadata) { if (metadata === undefined) { diff --git a/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js b/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js index 97323d02407..ec6e2a1fd75 100644 --- a/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js +++ b/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js @@ -73,8 +73,7 @@ class TestVisDynamicInstrumentation { // Allow the parent to exit even if the worker is still running this.worker.unref() - this.breakpointSetChannel.port2.on('message', (message) => { - const { probeId } = message + this.breakpointSetChannel.port2.on('message', ({ probeId }) => { const resolve = probeIdToResolveBreakpointSet.get(probeId) if (resolve) { resolve() @@ -82,8 +81,7 @@ class TestVisDynamicInstrumentation { } }).unref() - this.breakpointHitChannel.port2.on('message', (message) => { - const { snapshot } = message + this.breakpointHitChannel.port2.on('message', ({ snapshot }) => { const { probe: { id: probeId } } = snapshot const resolve = probeIdToResolveBreakpointHit.get(probeId) if (resolve) { @@ -91,6 +89,9 @@ class TestVisDynamicInstrumentation { probeIdToResolveBreakpointHit.delete(probeId) } }).unref() + + this.worker.on('error', (err) => log.error('ci-visibility DI worker error', err)) + this.worker.on('messageerror', (err) => log.error('ci-visibility DI worker messageerror', err)) } } diff --git a/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js b/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js index 4bef76e6343..952ba1a7cf7 100644 --- a/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js +++ b/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js @@ -1,6 +1,8 @@ 'use strict' - +const sourceMap = require('source-map') +const path = require('path') const { workerData: { breakpointSetChannel, breakpointHitChannel } } = require('worker_threads') + // TODO: move debugger/devtools_client/session to common place const session = require('../../../debugger/devtools_client/session') // TODO: move debugger/devtools_client/snapshot to common place @@ -69,14 +71,24 @@ async function addBreakpoint (snapshotId, probe) { const script = findScriptFromPartialPath(file) if (!script) throw new Error(`No loaded script found for ${file}`) - const [path, scriptId] = script + const [path, scriptId, sourceMapURL] = script log.debug(`Adding breakpoint at ${path}:${line}`) + let lineNumber = line + + if (sourceMapURL && sourceMapURL.startsWith('data:')) { + try { + lineNumber = await processScriptWithInlineSourceMap({ file, line, sourceMapURL }) + } catch (err) { + log.error(err) + } + } + const { breakpointId } = await session.post('Debugger.setBreakpoint', { location: { scriptId, - lineNumber: line - 1 + lineNumber: lineNumber - 1 } }) @@ -88,3 +100,27 @@ function start () { sessionStarted = true return session.post('Debugger.enable') // return instead of await to reduce number of promises created } + +async function processScriptWithInlineSourceMap (params) { + const { file, line, sourceMapURL } = params + + // Extract the base64-encoded source map + const base64SourceMap = sourceMapURL.split('base64,')[1] + + // Decode the base64 source map + const decodedSourceMap = Buffer.from(base64SourceMap, 'base64').toString('utf8') + + // Parse the source map + const consumer = await new sourceMap.SourceMapConsumer(decodedSourceMap) + + // Map to the generated position + const generatedPosition = consumer.generatedPositionFor({ + source: path.basename(file), // this needs to be the file, not the filepath + line, + column: 0 + }) + + consumer.destroy() + + return generatedPosition.line +} diff --git a/packages/dd-trace/src/ci-visibility/exporters/agentless/coverage-writer.js b/packages/dd-trace/src/ci-visibility/exporters/agentless/coverage-writer.js index 98eff61a6fd..a36b07201e1 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/agentless/coverage-writer.js +++ b/packages/dd-trace/src/ci-visibility/exporters/agentless/coverage-writer.js @@ -63,7 +63,7 @@ class Writer extends BaseWriter { TELEMETRY_ENDPOINT_PAYLOAD_DROPPED, { endpoint: 'code_coverage' } ) - log.error(err) + log.error('Error sending CI coverage payload', err) done() return } diff --git a/packages/dd-trace/src/ci-visibility/exporters/agentless/di-logs-writer.js b/packages/dd-trace/src/ci-visibility/exporters/agentless/di-logs-writer.js index eebc3c5e6a9..7d8c5ba47a0 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/agentless/di-logs-writer.js +++ b/packages/dd-trace/src/ci-visibility/exporters/agentless/di-logs-writer.js @@ -40,7 +40,7 @@ class DynamicInstrumentationLogsWriter extends BaseWriter { request(data, options, (err, res) => { if (err) { - log.error(err) + log.error('Error sending DI logs payload', err) done() return } diff --git a/packages/dd-trace/src/ci-visibility/exporters/agentless/index.js b/packages/dd-trace/src/ci-visibility/exporters/agentless/index.js index 5895bb573cd..a5b677ef98b 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/agentless/index.js +++ b/packages/dd-trace/src/ci-visibility/exporters/agentless/index.js @@ -38,7 +38,7 @@ class AgentlessCiVisibilityExporter extends CiVisibilityExporter { apiUrl = new URL(apiUrl) this._apiUrl = apiUrl } catch (e) { - log.error(e) + log.error('Error setting CI exporter api url', e) } } diff --git a/packages/dd-trace/src/ci-visibility/exporters/agentless/writer.js b/packages/dd-trace/src/ci-visibility/exporters/agentless/writer.js index 466c5230b22..34cad3862bc 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/agentless/writer.js +++ b/packages/dd-trace/src/ci-visibility/exporters/agentless/writer.js @@ -64,7 +64,7 @@ class Writer extends BaseWriter { TELEMETRY_ENDPOINT_PAYLOAD_DROPPED, { endpoint: 'test_cycle' } ) - log.error(err) + log.error('Error sending CI agentless payload', err) done() return } diff --git a/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js b/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js index f555603e0cb..3ad1a11e027 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +++ b/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js @@ -73,6 +73,9 @@ class CiVisibilityExporter extends AgentInfoExporter { if (this._coverageWriter) { this._coverageWriter.flush() } + if (this._logsWriter) { + this._logsWriter.flush() + } }) } @@ -193,7 +196,8 @@ class CiVisibilityExporter extends AgentInfoExporter { isEarlyFlakeDetectionEnabled, earlyFlakeDetectionNumRetries, earlyFlakeDetectionFaultyThreshold, - isFlakyTestRetriesEnabled + isFlakyTestRetriesEnabled, + isDiEnabled } = remoteConfiguration return { isCodeCoverageEnabled, @@ -204,7 +208,8 @@ class CiVisibilityExporter extends AgentInfoExporter { earlyFlakeDetectionNumRetries, earlyFlakeDetectionFaultyThreshold, isFlakyTestRetriesEnabled: isFlakyTestRetriesEnabled && this._config.isFlakyTestRetriesEnabled, - flakyTestRetriesCount: this._config.flakyTestRetriesCount + flakyTestRetriesCount: this._config.flakyTestRetriesCount, + isDiEnabled: isDiEnabled && this._config.isTestDynamicInstrumentationEnabled } } @@ -222,7 +227,7 @@ class CiVisibilityExporter extends AgentInfoExporter { repositoryUrl, (err) => { if (err) { - log.error(`Error uploading git metadata: ${err.message}`) + log.error('Error uploading git metadata: %s', err.message) } else { log.debug('Successfully uploaded git metadata') } @@ -302,13 +307,28 @@ class CiVisibilityExporter extends AgentInfoExporter { if (!this._isInitialized) { return done() } - this._writer.flush(() => { - if (this._coverageWriter) { - this._coverageWriter.flush(done) - } else { + + // TODO: safe to do them at once? Or do we want to do them one by one? + const writers = [ + this._writer, + this._coverageWriter, + this._logsWriter + ].filter(writer => writer) + + let remaining = writers.length + + if (remaining === 0) { + return done() + } + + const onFlushComplete = () => { + remaining -= 1 + if (remaining === 0) { done() } - }) + } + + writers.forEach(writer => writer.flush(onFlushComplete)) } exportUncodedCoverages () { @@ -327,7 +347,7 @@ class CiVisibilityExporter extends AgentInfoExporter { this._writer.setUrl(url) this._coverageWriter.setUrl(coverageUrl) } catch (e) { - log.error(e) + log.error('Error setting CI exporter url', e) } } diff --git a/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js b/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js index 9a32efad05e..e39770dea82 100644 --- a/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +++ b/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js @@ -92,7 +92,8 @@ function getLibraryConfiguration ({ itr_enabled: isItrEnabled, require_git: requireGit, early_flake_detection: earlyFlakeDetectionConfig, - flaky_test_retries_enabled: isFlakyTestRetriesEnabled + flaky_test_retries_enabled: isFlakyTestRetriesEnabled, + di_enabled: isDiEnabled } } } = JSON.parse(res) @@ -107,7 +108,8 @@ function getLibraryConfiguration ({ earlyFlakeDetectionConfig?.slow_test_retries?.['5s'] || DEFAULT_EARLY_FLAKE_DETECTION_NUM_RETRIES, earlyFlakeDetectionFaultyThreshold: earlyFlakeDetectionConfig?.faulty_session_threshold ?? DEFAULT_EARLY_FLAKE_DETECTION_ERROR_THRESHOLD, - isFlakyTestRetriesEnabled + isFlakyTestRetriesEnabled, + isDiEnabled: isDiEnabled && isFlakyTestRetriesEnabled } log.debug(() => `Remote settings: ${JSON.stringify(settings)}`) diff --git a/packages/dd-trace/src/config.js b/packages/dd-trace/src/config.js index 73cac449546..a16df70ee07 100644 --- a/packages/dd-trace/src/config.js +++ b/packages/dd-trace/src/config.js @@ -132,11 +132,11 @@ function checkIfBothOtelAndDdEnvVarSet () { const fromEntries = Object.fromEntries || (entries => entries.reduce((obj, [k, v]) => Object.assign(obj, { [k]: v }), {})) -// eslint-disable-next-line max-len +// eslint-disable-next-line @stylistic/js/max-len const qsRegex = '(?:p(?:ass)?w(?:or)?d|pass(?:_?phrase)?|secret|(?:api_?|private_?|public_?|access_?|secret_?)key(?:_?id)?|token|consumer_?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?)(?:(?:\\s|%20)*(?:=|%3D)[^&]+|(?:"|%22)(?:\\s|%20)*(?::|%3A)(?:\\s|%20)*(?:"|%22)(?:%2[^2]|%[^2]|[^"%])+(?:"|%22))|bearer(?:\\s|%20)+[a-z0-9\\._\\-]+|token(?::|%3A)[a-z0-9]{13}|gh[opsu]_[0-9a-zA-Z]{36}|ey[I-L](?:[\\w=-]|%3D)+\\.ey[I-L](?:[\\w=-]|%3D)+(?:\\.(?:[\\w.+\\/=-]|%3D|%2F|%2B)+)?|[\\-]{5}BEGIN(?:[a-z\\s]|%20)+PRIVATE(?:\\s|%20)KEY[\\-]{5}[^\\-]+[\\-]{5}END(?:[a-z\\s]|%20)+PRIVATE(?:\\s|%20)KEY|ssh-rsa(?:\\s|%20)*(?:[a-z0-9\\/\\.+]|%2F|%5C|%2B){100,}' -// eslint-disable-next-line max-len +// eslint-disable-next-line @stylistic/js/max-len const defaultWafObfuscatorKeyRegex = '(?i)pass|pw(?:or)?d|secret|(?:api|private|public|access)[_-]?key|token|consumer[_-]?(?:id|key|secret)|sign(?:ed|ature)|bearer|authorization|jsessionid|phpsessid|asp\\.net[_-]sessionid|sid|jwt' -// eslint-disable-next-line max-len +// eslint-disable-next-line @stylistic/js/max-len const defaultWafObfuscatorValueRegex = '(?i)(?:p(?:ass)?w(?:or)?d|pass(?:[_-]?phrase)?|secret(?:[_-]?key)?|(?:(?:api|private|public|access)[_-]?)key(?:[_-]?id)?|(?:(?:auth|access|id|refresh)[_-]?)?token|consumer[_-]?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?|jsessionid|phpsessid|asp\\.net(?:[_-]|-)sessionid|sid|jwt)(?:\\s*=[^;]|"\\s*:\\s*"[^"]+")|bearer\\s+[a-z0-9\\._\\-]+|token:[a-z0-9]{13}|gh[opsu]_[0-9a-zA-Z]{36}|ey[I-L][\\w=-]+\\.ey[I-L][\\w=-]+(?:\\.[\\w.+\\/=-]+)?|[\\-]{5}BEGIN[a-z\\s]+PRIVATE\\sKEY[\\-]{5}[^\\-]+[\\-]{5}END[a-z\\s]+PRIVATE\\sKEY|ssh-rsa\\s*[a-z0-9\\/\\.+]{100,}' const runtimeId = uuid() @@ -145,7 +145,7 @@ function maybeFile (filepath) { try { return fs.readFileSync(filepath, 'utf8') } catch (e) { - log.error(e) + log.error('Error reading file %s', filepath, e) return undefined } } @@ -378,7 +378,7 @@ class Config { } catch (e) { // Only log error if the user has set a git.properties path if (process.env.DD_GIT_PROPERTIES_FILE) { - log.error(e) + log.error('Error reading DD_GIT_PROPERTIES_FILE: %s', DD_GIT_PROPERTIES_FILE, e) } } if (gitPropertiesString) { @@ -449,8 +449,7 @@ class Config { this._setValue(defaults, 'appsec.blockedTemplateHtml', undefined) this._setValue(defaults, 'appsec.blockedTemplateJson', undefined) this._setValue(defaults, 'appsec.enabled', undefined) - this._setValue(defaults, 'appsec.eventTracking.enabled', true) - this._setValue(defaults, 'appsec.eventTracking.mode', 'safe') + this._setValue(defaults, 'appsec.eventTracking.mode', 'identification') this._setValue(defaults, 'appsec.obfuscatorKeyRegex', defaultWafObfuscatorKeyRegex) this._setValue(defaults, 'appsec.obfuscatorValueRegex', defaultWafObfuscatorValueRegex) this._setValue(defaults, 'appsec.rasp.enabled', true) @@ -467,7 +466,7 @@ class Config { this._setValue(defaults, 'ciVisibilityTestSessionName', '') this._setValue(defaults, 'clientIpEnabled', false) this._setValue(defaults, 'clientIpHeader', null) - this._setValue(defaults, 'crashtracking.enabled', false) + this._setValue(defaults, 'crashtracking.enabled', true) this._setValue(defaults, 'codeOriginForSpans.enabled', false) this._setValue(defaults, 'dbmPropagationMode', 'disabled') this._setValue(defaults, 'dogstatsd.hostname', '127.0.0.1') @@ -486,6 +485,7 @@ class Config { this._setValue(defaults, 'headerTags', []) this._setValue(defaults, 'hostname', '127.0.0.1') this._setValue(defaults, 'iast.cookieFilterPattern', '.{32,}') + this._setValue(defaults, 'iast.dbRowsToTaint', 1) this._setValue(defaults, 'iast.deduplicationEnabled', true) this._setValue(defaults, 'iast.enabled', false) this._setValue(defaults, 'iast.maxConcurrentRequests', 2) @@ -553,7 +553,7 @@ class Config { this._setValue(defaults, 'telemetry.dependencyCollection', true) this._setValue(defaults, 'telemetry.enabled', true) this._setValue(defaults, 'telemetry.heartbeatInterval', 60000) - this._setValue(defaults, 'telemetry.logCollection', false) + this._setValue(defaults, 'telemetry.logCollection', true) this._setValue(defaults, 'telemetry.metrics', true) this._setValue(defaults, 'traceEnabled', true) this._setValue(defaults, 'traceId128BitGenerationEnabled', true) @@ -566,6 +566,7 @@ class Config { this._setValue(defaults, 'url', undefined) this._setValue(defaults, 'version', pkg.version) this._setValue(defaults, 'instrumentation_config_id', undefined) + this._setValue(defaults, 'aws.dynamoDb.tablePrimaryKeys', undefined) } _applyEnvironment () { @@ -574,6 +575,7 @@ class Config { DD_AGENT_HOST, DD_API_SECURITY_ENABLED, DD_API_SECURITY_SAMPLE_DELAY, + DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE, DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING, DD_APPSEC_ENABLED, DD_APPSEC_GRAPHQL_BLOCKED_TEMPLATE_JSON, @@ -589,11 +591,13 @@ class Config { DD_APPSEC_RASP_ENABLED, DD_APPSEC_TRACE_RATE_LIMIT, DD_APPSEC_WAF_TIMEOUT, + DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS, DD_CRASHTRACKING_ENABLED, DD_CODE_ORIGIN_FOR_SPANS_ENABLED, DD_DATA_STREAMS_ENABLED, DD_DBM_PROPAGATION_MODE, DD_DOGSTATSD_HOSTNAME, + DD_DOGSTATSD_HOST, DD_DOGSTATSD_PORT, DD_DYNAMIC_INSTRUMENTATION_ENABLED, DD_ENV, @@ -604,6 +608,7 @@ class Config { DD_GRPC_SERVER_ERROR_STATUSES, JEST_WORKER_ID, DD_IAST_COOKIE_FILTER_PATTERN, + DD_IAST_DB_ROWS_TO_TAINT, DD_IAST_DEDUPLICATION_ENABLED, DD_IAST_ENABLED, DD_IAST_MAX_CONCURRENT_REQUESTS, @@ -711,11 +716,10 @@ class Config { this._setValue(env, 'appsec.blockedTemplateJson', maybeFile(DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON)) this._envUnprocessed['appsec.blockedTemplateJson'] = DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON this._setBoolean(env, 'appsec.enabled', DD_APPSEC_ENABLED) - if (DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING) { - this._setValue(env, 'appsec.eventTracking.enabled', - ['extended', 'safe'].includes(DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING.toLowerCase())) - this._setValue(env, 'appsec.eventTracking.mode', DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING.toLowerCase()) - } + this._setString(env, 'appsec.eventTracking.mode', coalesce( + DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE, + DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING // TODO: remove in next major + )) this._setString(env, 'appsec.obfuscatorKeyRegex', DD_APPSEC_OBFUSCATION_PARAMETER_KEY_REGEXP) this._setString(env, 'appsec.obfuscatorValueRegex', DD_APPSEC_OBFUSCATION_PARAMETER_VALUE_REGEXP) this._setBoolean(env, 'appsec.rasp.enabled', DD_APPSEC_RASP_ENABLED) @@ -739,7 +743,7 @@ class Config { this._setBoolean(env, 'crashtracking.enabled', DD_CRASHTRACKING_ENABLED) this._setBoolean(env, 'codeOriginForSpans.enabled', DD_CODE_ORIGIN_FOR_SPANS_ENABLED) this._setString(env, 'dbmPropagationMode', DD_DBM_PROPAGATION_MODE) - this._setString(env, 'dogstatsd.hostname', DD_DOGSTATSD_HOSTNAME) + this._setString(env, 'dogstatsd.hostname', DD_DOGSTATSD_HOST || DD_DOGSTATSD_HOSTNAME) this._setString(env, 'dogstatsd.port', DD_DOGSTATSD_PORT) this._setBoolean(env, 'dsmEnabled', DD_DATA_STREAMS_ENABLED) this._setBoolean(env, 'dynamicInstrumentationEnabled', DD_DYNAMIC_INSTRUMENTATION_ENABLED) @@ -757,6 +761,7 @@ class Config { this._setArray(env, 'headerTags', DD_TRACE_HEADER_TAGS) this._setString(env, 'hostname', coalesce(DD_AGENT_HOST, DD_TRACE_AGENT_HOSTNAME)) this._setString(env, 'iast.cookieFilterPattern', DD_IAST_COOKIE_FILTER_PATTERN) + this._setValue(env, 'iast.dbRowsToTaint', maybeInt(DD_IAST_DB_ROWS_TO_TAINT)) this._setBoolean(env, 'iast.deduplicationEnabled', DD_IAST_DEDUPLICATION_ENABLED) this._setBoolean(env, 'iast.enabled', DD_IAST_ENABLED) this._setValue(env, 'iast.maxConcurrentRequests', maybeInt(DD_IAST_MAX_CONCURRENT_REQUESTS)) @@ -876,6 +881,7 @@ class Config { this._setBoolean(env, 'tracing', DD_TRACING_ENABLED) this._setString(env, 'version', DD_VERSION || tags.version) this._setBoolean(env, 'inferredProxyServicesEnabled', DD_TRACE_INFERRED_PROXY_SERVICES_ENABLED) + this._setString(env, 'aws.dynamoDb.tablePrimaryKeys', DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS) } _applyOptions (options) { @@ -894,12 +900,7 @@ class Config { this._setValue(opts, 'appsec.blockedTemplateJson', maybeFile(options.appsec.blockedTemplateJson)) this._optsUnprocessed['appsec.blockedTemplateJson'] = options.appsec.blockedTemplateJson this._setBoolean(opts, 'appsec.enabled', options.appsec.enabled) - let eventTracking = options.appsec.eventTracking?.mode - if (eventTracking) { - eventTracking = eventTracking.toLowerCase() - this._setValue(opts, 'appsec.eventTracking.enabled', ['extended', 'safe'].includes(eventTracking)) - this._setValue(opts, 'appsec.eventTracking.mode', eventTracking) - } + this._setString(opts, 'appsec.eventTracking.mode', options.appsec.eventTracking?.mode) this._setString(opts, 'appsec.obfuscatorKeyRegex', options.appsec.obfuscatorKeyRegex) this._setString(opts, 'appsec.obfuscatorValueRegex', options.appsec.obfuscatorValueRegex) this._setBoolean(opts, 'appsec.rasp.enabled', options.appsec.rasp?.enabled) @@ -937,6 +938,7 @@ class Config { this._setArray(opts, 'headerTags', options.headerTags) this._setString(opts, 'hostname', options.hostname) this._setString(opts, 'iast.cookieFilterPattern', options.iast?.cookieFilterPattern) + this._setValue(opts, 'iast.dbRowsToTaint', maybeInt(options.iast?.dbRowsToTaint)) this._setBoolean(opts, 'iast.deduplicationEnabled', options.iast && options.iast.deduplicationEnabled) this._setBoolean(opts, 'iast.enabled', options.iast && (options.iast === true || options.iast.enabled === true)) @@ -1141,16 +1143,6 @@ class Config { calc['tracePropagationStyle.inject'] = calc['tracePropagationStyle.inject'] || defaultPropagationStyle calc['tracePropagationStyle.extract'] = calc['tracePropagationStyle.extract'] || defaultPropagationStyle } - - const iastEnabled = coalesce(this._options['iast.enabled'], this._env['iast.enabled']) - const profilingEnabled = coalesce(this._options['profiling.enabled'], this._env['profiling.enabled']) - const injectionIncludesProfiler = (this._env.injectionEnabled || []).includes('profiler') - if (iastEnabled || ['auto', 'true'].includes(profilingEnabled) || injectionIncludesProfiler) { - this._setBoolean(calc, 'telemetry.logCollection', true) - } - if (this._env.injectionEnabled?.length > 0) { - this._setBoolean(calc, 'crashtracking.enabled', true) - } } _applyRemote (options) { @@ -1288,7 +1280,7 @@ class Config { // TODO: Deeply merge configurations. // TODO: Move change tracking to telemetry. // for telemetry reporting, `name`s in `containers` need to be keys from: - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len // https://github.com/DataDog/dd-go/blob/prod/trace/apps/tracer-telemetry-intake/telemetry-payload/static/config_norm_rules.json _merge () { const containers = [this._remote, this._options, this._env, this._calculated, this._defaults] diff --git a/packages/dd-trace/src/constants.js b/packages/dd-trace/src/constants.js index 4e7faf669d4..3c93480df9f 100644 --- a/packages/dd-trace/src/constants.js +++ b/packages/dd-trace/src/constants.js @@ -47,6 +47,7 @@ module.exports = { SCHEMA_NAME: 'schema.name', GRPC_CLIENT_ERROR_STATUSES: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], GRPC_SERVER_ERROR_STATUSES: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + DYNAMODB_PTR_KIND: 'aws.dynamodb.item', S3_PTR_KIND: 'aws.s3.object', SPAN_POINTER_DIRECTION: Object.freeze({ UPSTREAM: 'u', diff --git a/packages/dd-trace/src/crashtracking/crashtracker.js b/packages/dd-trace/src/crashtracking/crashtracker.js index 72759001b1d..a2d3ec2eb52 100644 --- a/packages/dd-trace/src/crashtracking/crashtracker.js +++ b/packages/dd-trace/src/crashtracking/crashtracker.js @@ -20,7 +20,7 @@ class Crashtracker { binding.updateConfig(this._getConfig(config)) binding.updateMetadata(this._getMetadata(config)) } catch (e) { - log.error(e) + log.error('Error configuring crashtracker', e) } } @@ -36,7 +36,7 @@ class Crashtracker { this._getMetadata(config) ) } catch (e) { - log.error(e) + log.error('Error initialising crashtracker', e) } } @@ -79,6 +79,7 @@ class Crashtracker { 'language:javascript', `library_version:${pkg.version}`, 'runtime:nodejs', + `runtime_version:${process.versions.node}`, 'severity:crash' ] } diff --git a/packages/dd-trace/src/datastreams/processor.js b/packages/dd-trace/src/datastreams/processor.js index d036af805a7..d997ba098ae 100644 --- a/packages/dd-trace/src/datastreams/processor.js +++ b/packages/dd-trace/src/datastreams/processor.js @@ -1,7 +1,5 @@ const os = require('os') const pkg = require('../../../../package.json') -// Message pack int encoding is done in big endian, but data streams uses little endian -const Uint64 = require('int64-buffer').Uint64BE const { LogCollapsingLowestDenseDDSketch } = require('@datadog/sketches-js') const { DsmPathwayCodec } = require('./pathway') @@ -19,8 +17,8 @@ const HIGH_ACCURACY_DISTRIBUTION = 0.0075 class StatsPoint { constructor (hash, parentHash, edgeTags) { - this.hash = new Uint64(hash) - this.parentHash = new Uint64(parentHash) + this.hash = hash.readBigUInt64BE() + this.parentHash = parentHash.readBigUInt64BE() this.edgeTags = edgeTags this.edgeLatency = new LogCollapsingLowestDenseDDSketch(HIGH_ACCURACY_DISTRIBUTION) this.pathwayLatency = new LogCollapsingLowestDenseDDSketch(HIGH_ACCURACY_DISTRIBUTION) @@ -344,8 +342,8 @@ class DataStreamsProcessor { backlogs.push(backlog.encode()) } serializedBuckets.push({ - Start: new Uint64(timeNs), - Duration: new Uint64(this.bucketSizeNs), + Start: BigInt(timeNs), + Duration: BigInt(this.bucketSizeNs), Stats: points, Backlogs: backlogs }) diff --git a/packages/dd-trace/src/datastreams/writer.js b/packages/dd-trace/src/datastreams/writer.js index f8c9e021ecc..220b3dfecf7 100644 --- a/packages/dd-trace/src/datastreams/writer.js +++ b/packages/dd-trace/src/datastreams/writer.js @@ -2,9 +2,10 @@ const pkg = require('../../../../package.json') const log = require('../log') const request = require('../exporters/common/request') const { URL, format } = require('url') -const msgpack = require('msgpack-lite') +const { MsgpackEncoder } = require('../msgpack') const zlib = require('zlib') -const codec = msgpack.createCodec({ int64: true }) + +const msgpack = new MsgpackEncoder() function makeRequest (data, url, cb) { const options = { @@ -41,17 +42,17 @@ class DataStreamsWriter { log.debug(() => `Maximum number of active requests reached. Payload discarded: ${JSON.stringify(payload)}`) return } - const encodedPayload = msgpack.encode(payload, { codec }) + const encodedPayload = msgpack.encode(payload) zlib.gzip(encodedPayload, { level: 1 }, (err, compressedData) => { if (err) { - log.error(err) + log.error('Error zipping datastream', err) return } makeRequest(compressedData, this._url, (err, res) => { log.debug(`Response from the agent: ${res}`) if (err) { - log.error(err) + log.error('Error sending datastream', err) } }) }) diff --git a/packages/dd-trace/src/debugger/devtools_client/breakpoints.js b/packages/dd-trace/src/debugger/devtools_client/breakpoints.js new file mode 100644 index 00000000000..dd44e9bfde0 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/breakpoints.js @@ -0,0 +1,80 @@ +'use strict' + +const session = require('./session') +const { MAX_SNAPSHOTS_PER_SECOND_PER_PROBE, MAX_NON_SNAPSHOTS_PER_SECOND_PER_PROBE } = require('./defaults') +const { findScriptFromPartialPath, probes, breakpoints } = require('./state') +const log = require('../../log') + +let sessionStarted = false + +module.exports = { + addBreakpoint, + removeBreakpoint +} + +async function addBreakpoint (probe) { + if (!sessionStarted) await start() + + const file = probe.where.sourceFile + const line = Number(probe.where.lines[0]) // Tracer doesn't support multiple-line breakpoints + + // Optimize for sending data to /debugger/v1/input endpoint + probe.location = { file, lines: [String(line)] } + delete probe.where + + // Optimize for fast calculations when probe is hit + const snapshotsPerSecond = probe.sampling.snapshotsPerSecond ?? (probe.captureSnapshot + ? MAX_SNAPSHOTS_PER_SECOND_PER_PROBE + : MAX_NON_SNAPSHOTS_PER_SECOND_PER_PROBE) + probe.sampling.nsBetweenSampling = BigInt(1 / snapshotsPerSecond * 1e9) + probe.lastCaptureNs = 0n + + // TODO: Inbetween `await session.post('Debugger.enable')` and here, the scripts are parsed and cached. + // Maybe there's a race condition here or maybe we're guraenteed that `await session.post('Debugger.enable')` will + // not continue untill all scripts have been parsed? + const script = findScriptFromPartialPath(file) + if (!script) throw new Error(`No loaded script found for ${file} (probe: ${probe.id}, version: ${probe.version})`) + const [path, scriptId] = script + + log.debug( + '[debugger:devtools_client] Adding breakpoint at %s:%d (probe: %s, version: %d)', + path, line, probe.id, probe.version + ) + + const { breakpointId } = await session.post('Debugger.setBreakpoint', { + location: { + scriptId, + lineNumber: line - 1 // Beware! lineNumber is zero-indexed + } + }) + + probes.set(probe.id, breakpointId) + breakpoints.set(breakpointId, probe) +} + +async function removeBreakpoint ({ id }) { + if (!sessionStarted) { + // We should not get in this state, but abort if we do, so the code doesn't fail unexpected + throw Error(`Cannot remove probe ${id}: Debugger not started`) + } + if (!probes.has(id)) { + throw Error(`Unknown probe id: ${id}`) + } + + const breakpointId = probes.get(id) + await session.post('Debugger.removeBreakpoint', { breakpointId }) + probes.delete(id) + breakpoints.delete(breakpointId) + + if (breakpoints.size === 0) await stop() +} + +async function start () { + sessionStarted = true + return session.post('Debugger.enable') // return instead of await to reduce number of promises created +} + +async function stop () { + sessionStarted = false + return session.post('Debugger.disable') // return instead of await to reduce number of promises created +} diff --git a/packages/dd-trace/src/debugger/devtools_client/config.js b/packages/dd-trace/src/debugger/devtools_client/config.js index 838a1a76cca..7783bc84d75 100644 --- a/packages/dd-trace/src/debugger/devtools_client/config.js +++ b/packages/dd-trace/src/debugger/devtools_client/config.js @@ -15,7 +15,9 @@ const config = module.exports = { updateUrl(parentConfig) configPort.on('message', updateUrl) -configPort.on('messageerror', (err) => log.error(err)) +configPort.on('messageerror', (err) => + log.error('[debugger:devtools_client] received "messageerror" on config port', err) +) function updateUrl (updates) { config.url = updates.url || format({ diff --git a/packages/dd-trace/src/debugger/devtools_client/defaults.js b/packages/dd-trace/src/debugger/devtools_client/defaults.js new file mode 100644 index 00000000000..6acb813ab26 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/defaults.js @@ -0,0 +1,6 @@ +'use strict' + +module.exports = { + MAX_SNAPSHOTS_PER_SECOND_PER_PROBE: 1, + MAX_NON_SNAPSHOTS_PER_SECOND_PER_PROBE: 5_000 +} diff --git a/packages/dd-trace/src/debugger/devtools_client/index.js b/packages/dd-trace/src/debugger/devtools_client/index.js index db71e7028e7..9634003bf61 100644 --- a/packages/dd-trace/src/debugger/devtools_client/index.js +++ b/packages/dd-trace/src/debugger/devtools_client/index.js @@ -13,28 +13,59 @@ const { version } = require('../../../../../package.json') require('./remote_config') +// Expression to run on a call frame of the paused thread to get its active trace and span id. +const expression = ` + const context = global.require('dd-trace').scope().active()?.context(); + ({ trace_id: context?.toTraceId(), span_id: context?.toSpanId() }) +` + // There doesn't seem to be an official standard for the content of these fields, so we're just populating them with // something that should be useful to a Node.js developer. const threadId = parentThreadId === 0 ? `pid:${process.pid}` : `pid:${process.pid};tid:${parentThreadId}` const threadName = parentThreadId === 0 ? 'MainThread' : `WorkerThread:${parentThreadId}` +// WARNING: The code above the line `await session.post('Debugger.resume')` is highly optimized. Please edit with care! session.on('Debugger.paused', async ({ params }) => { const start = process.hrtime.bigint() - const timestamp = Date.now() let captureSnapshotForProbe = null let maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength - const probes = params.hitBreakpoints.map((id) => { + + // V8 doesn't allow seting more than one breakpoint at a specific location, however, it's possible to set two + // breakpoints just next to eachother that will "snap" to the same logical location, which in turn will be hit at the + // same time. E.g. index.js:1:1 and index.js:1:2. + // TODO: Investigate if it will improve performance to create a fast-path for when there's only a single breakpoint + let sampled = false + const length = params.hitBreakpoints.length + let probes = new Array(length) + for (let i = 0; i < length; i++) { + const id = params.hitBreakpoints[i] const probe = breakpoints.get(id) - if (probe.captureSnapshot) { + + if (start - probe.lastCaptureNs < probe.sampling.nsBetweenSampling) { + continue + } + + sampled = true + probe.lastCaptureNs = start + + if (probe.captureSnapshot === true) { captureSnapshotForProbe = probe maxReferenceDepth = highestOrUndefined(probe.capture.maxReferenceDepth, maxReferenceDepth) maxCollectionSize = highestOrUndefined(probe.capture.maxCollectionSize, maxCollectionSize) maxFieldCount = highestOrUndefined(probe.capture.maxFieldCount, maxFieldCount) maxLength = highestOrUndefined(probe.capture.maxLength, maxLength) } - return probe - }) + + probes[i] = probe + } + + if (sampled === false) { + return session.post('Debugger.resume') + } + + const timestamp = Date.now() + const dd = await getDD(params.callFrames[0].callFrameId) let processLocalState if (captureSnapshotForProbe !== null) { @@ -54,7 +85,13 @@ session.on('Debugger.paused', async ({ params }) => { await session.post('Debugger.resume') const diff = process.hrtime.bigint() - start // TODO: Recored as telemetry (DEBUG-2858) - log.debug(`Finished processing breakpoints - main thread paused for: ${Number(diff) / 1000000} ms`) + log.debug( + '[debugger:devtools_client] Finished processing breakpoints - main thread paused for: %d ms', + Number(diff) / 1000000 + ) + + // Due to the highly optimized algorithm above, the `probes` array might have gaps + probes = probes.filter((probe) => !!probe) const logger = { // We can safely use `location.file` from the first probe in the array, since all probes hit by `hitBreakpoints` @@ -92,8 +129,8 @@ session.on('Debugger.paused', async ({ params }) => { } // TODO: Process template (DEBUG-2628) - send(probe.template, logger, snapshot, (err) => { - if (err) log.error(err) + send(probe.template, logger, dd, snapshot, (err) => { + if (err) log.error('Debugger error', err) else ackEmitting(probe) }) } @@ -102,3 +139,21 @@ session.on('Debugger.paused', async ({ params }) => { function highestOrUndefined (num, max) { return num === undefined ? max : Math.max(num, max ?? 0) } + +async function getDD (callFrameId) { + const { result } = await session.post('Debugger.evaluateOnCallFrame', { + callFrameId, + expression, + returnByValue: true, + includeCommandLineAPI: true + }) + + if (result?.value?.trace_id === undefined) { + if (result?.subtype === 'error') { + log.error('[debugger:devtools_client] Error getting trace/span id:', result.description) + } + return + } + + return result.value +} diff --git a/packages/dd-trace/src/debugger/devtools_client/remote_config.js b/packages/dd-trace/src/debugger/devtools_client/remote_config.js index 8a7d7386e33..8e56fdd7aa0 100644 --- a/packages/dd-trace/src/debugger/devtools_client/remote_config.js +++ b/packages/dd-trace/src/debugger/devtools_client/remote_config.js @@ -1,13 +1,10 @@ 'use strict' const { workerData: { rcPort } } = require('node:worker_threads') -const { findScriptFromPartialPath, probes, breakpoints } = require('./state') -const session = require('./session') +const { addBreakpoint, removeBreakpoint } = require('./breakpoints') const { ackReceived, ackInstalled, ackError } = require('./status') const log = require('../../log') -let sessionStarted = false - // Example log line probe (simplified): // { // id: '100c9a5c-45ad-49dc-818b-c570d31e11d1', @@ -44,20 +41,13 @@ rcPort.on('message', async ({ action, conf: probe, ackId }) => { ackError(err, probe) } }) -rcPort.on('messageerror', (err) => log.error(err)) - -async function start () { - sessionStarted = true - return session.post('Debugger.enable') // return instead of await to reduce number of promises created -} - -async function stop () { - sessionStarted = false - return session.post('Debugger.disable') // return instead of await to reduce number of promises created -} +rcPort.on('messageerror', (err) => log.error('[debugger:devtools_client] received "messageerror" on RC port', err)) async function processMsg (action, probe) { - log.debug(`Received request to ${action} ${probe.type} probe (id: ${probe.id}, version: ${probe.version})`) + log.debug( + '[debugger:devtools_client] Received request to %s %s probe (id: %s, version: %d)', + action, probe.type, probe.id, probe.version + ) if (action !== 'unapply') ackReceived(probe) @@ -66,7 +56,7 @@ async function processMsg (action, probe) { } if (!probe.where.sourceFile && !probe.where.lines) { throw new Error( - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len `Unsupported probe insertion point! Only line-based probes are supported (id: ${probe.id}, version: ${probe.version})` ) } @@ -90,15 +80,17 @@ async function processMsg (action, probe) { break case 'apply': await addBreakpoint(probe) + ackInstalled(probe) break case 'modify': // TODO: Modify existing probe instead of removing it (DEBUG-2817) await removeBreakpoint(probe) await addBreakpoint(probe) + ackInstalled(probe) // TODO: Should we also send ackInstalled when modifying a probe? break default: throw new Error( - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len `Cannot process probe ${probe.id} (version: ${probe.version}) - unknown remote configuration action: ${action}` ) } @@ -107,55 +99,6 @@ async function processMsg (action, probe) { } } -async function addBreakpoint (probe) { - if (!sessionStarted) await start() - - const file = probe.where.sourceFile - const line = Number(probe.where.lines[0]) // Tracer doesn't support multiple-line breakpoints - - // Optimize for sending data to /debugger/v1/input endpoint - probe.location = { file, lines: [String(line)] } - delete probe.where - - // TODO: Inbetween `await session.post('Debugger.enable')` and here, the scripts are parsed and cached. - // Maybe there's a race condition here or maybe we're guraenteed that `await session.post('Debugger.enable')` will - // not continue untill all scripts have been parsed? - const script = findScriptFromPartialPath(file) - if (!script) throw new Error(`No loaded script found for ${file} (probe: ${probe.id}, version: ${probe.version})`) - const [path, scriptId] = script - - log.debug(`Adding breakpoint at ${path}:${line} (probe: ${probe.id}, version: ${probe.version})`) - - const { breakpointId } = await session.post('Debugger.setBreakpoint', { - location: { - scriptId, - lineNumber: line - 1 // Beware! lineNumber is zero-indexed - } - }) - - probes.set(probe.id, breakpointId) - breakpoints.set(breakpointId, probe) - - ackInstalled(probe) -} - -async function removeBreakpoint ({ id }) { - if (!sessionStarted) { - // We should not get in this state, but abort if we do, so the code doesn't fail unexpected - throw Error(`Cannot remove probe ${id}: Debugger not started`) - } - if (!probes.has(id)) { - throw Error(`Unknown probe id: ${id}`) - } - - const breakpointId = probes.get(id) - await session.post('Debugger.removeBreakpoint', { breakpointId }) - probes.delete(id) - breakpoints.delete(breakpointId) - - if (breakpoints.size === 0) await stop() -} - async function lock () { if (lock.p) await lock.p let resolve diff --git a/packages/dd-trace/src/debugger/devtools_client/send.js b/packages/dd-trace/src/debugger/devtools_client/send.js index f2ba5befd46..375afd7d47a 100644 --- a/packages/dd-trace/src/debugger/devtools_client/send.js +++ b/packages/dd-trace/src/debugger/devtools_client/send.js @@ -6,6 +6,7 @@ const { stringify } = require('querystring') const config = require('./config') const request = require('../../exporters/common/request') const { GIT_COMMIT_SHA, GIT_REPOSITORY_URL } = require('../../plugins/util/tags') +const { version } = require('../../../../../package.json') module.exports = send @@ -16,13 +17,17 @@ const hostname = getHostname() const service = config.service const ddtags = [ + ['env', process.env.DD_ENV], + ['version', process.env.DD_VERSION], + ['debugger_version', version], + ['host_name', hostname], [GIT_COMMIT_SHA, config.commitSHA], [GIT_REPOSITORY_URL, config.repositoryUrl] ].map((pair) => pair.join(':')).join(',') const path = `/debugger/v1/input?${stringify({ ddtags })}` -function send (message, logger, snapshot, cb) { +function send (message, logger, dd, snapshot, cb) { const opts = { method: 'POST', url: config.url, @@ -36,6 +41,7 @@ function send (message, logger, snapshot, cb) { service, message, logger, + dd, 'debugger.snapshot': snapshot } diff --git a/packages/dd-trace/src/debugger/devtools_client/state.js b/packages/dd-trace/src/debugger/devtools_client/state.js index c409a69f6b7..a69a37067f4 100644 --- a/packages/dd-trace/src/debugger/devtools_client/state.js +++ b/packages/dd-trace/src/debugger/devtools_client/state.js @@ -57,6 +57,6 @@ module.exports = { session.on('Debugger.scriptParsed', ({ params }) => { scriptUrls.set(params.scriptId, params.url) if (params.url.startsWith('file:')) { - scriptIds.push([params.url, params.scriptId]) + scriptIds.push([params.url, params.scriptId, params.sourceMapURL]) } }) diff --git a/packages/dd-trace/src/debugger/devtools_client/status.js b/packages/dd-trace/src/debugger/devtools_client/status.js index e4ba10d8c55..b228d7e50b7 100644 --- a/packages/dd-trace/src/debugger/devtools_client/status.js +++ b/packages/dd-trace/src/debugger/devtools_client/status.js @@ -55,7 +55,7 @@ function ackEmitting ({ id: probeId, version }) { } function ackError (err, { id: probeId, version }) { - log.error(err) + log.error('[debugger:devtools_client] ackError', err) onlyUniqueUpdates(STATUSES.ERROR, probeId, version, () => { const payload = statusPayload(probeId, version, STATUSES.ERROR) @@ -87,16 +87,16 @@ function send (payload) { } request(form, options, (err) => { - if (err) log.error(err) + if (err) log.error('[debugger:devtools_client] Error sending debugger payload', err) }) } -function statusPayload (probeId, version, status) { +function statusPayload (probeId, probeVersion, status) { return { ddsource, service, debugger: { - diagnostics: { probeId, runtimeId, version, status } + diagnostics: { probeId, runtimeId, probeVersion, status } } } } diff --git a/packages/dd-trace/src/debugger/index.js b/packages/dd-trace/src/debugger/index.js index 3638119c6f1..fee514f32f1 100644 --- a/packages/dd-trace/src/debugger/index.js +++ b/packages/dd-trace/src/debugger/index.js @@ -18,7 +18,7 @@ module.exports = { function start (config, rc) { if (worker !== null) return - log.debug('Starting Dynamic Instrumentation client...') + log.debug('[debugger] Starting Dynamic Instrumentation client...') const rcAckCallbacks = new Map() const rcChannel = new MessageChannel() @@ -33,14 +33,14 @@ function start (config, rc) { const ack = rcAckCallbacks.get(ackId) if (ack === undefined) { // This should never happen, but just in case something changes in the future, we should guard against it - log.error(`Received an unknown ackId: ${ackId}`) - if (error) log.error(error) + log.error('[debugger] Received an unknown ackId: %s', ackId) + if (error) log.error('[debugger] Error starting Dynamic Instrumentation client', error) return } ack(error) rcAckCallbacks.delete(ackId) }) - rcChannel.port2.on('messageerror', (err) => log.error(err)) + rcChannel.port2.on('messageerror', (err) => log.error('[debugger] received "messageerror" on RC port', err)) worker = new Worker( join(__dirname, 'devtools_client', 'index.js'), @@ -57,19 +57,17 @@ function start (config, rc) { } ) - worker.unref() - worker.on('online', () => { - log.debug(`Dynamic Instrumentation worker thread started successfully (thread id: ${worker.threadId})`) + log.debug('[debugger] Dynamic Instrumentation worker thread started successfully (thread id: %d)', worker.threadId) }) - worker.on('error', (err) => log.error(err)) - worker.on('messageerror', (err) => log.error(err)) + worker.on('error', (err) => log.error('[debugger] worker thread error', err)) + worker.on('messageerror', (err) => log.error('[debugger] received "messageerror" from worker', err)) worker.on('exit', (code) => { const error = new Error(`Dynamic Instrumentation worker thread exited unexpectedly with code ${code}`) - log.error(error) + log.error('[debugger] worker thread exited unexpectedly', error) // Be nice, clean up now that the worker thread encounted an issue and we can't continue rc.removeProductHandler('LIVE_DEBUGGING') @@ -80,6 +78,12 @@ function start (config, rc) { rcAckCallbacks.delete(ackId) } }) + + worker.unref() + rcChannel.port1.unref() + rcChannel.port2.unref() + configChannel.port1.unref() + configChannel.port2.unref() } function configure (config) { diff --git a/packages/dd-trace/src/dogstatsd.js b/packages/dd-trace/src/dogstatsd.js index ba84de71341..a396c9e98a4 100644 --- a/packages/dd-trace/src/dogstatsd.js +++ b/packages/dd-trace/src/dogstatsd.js @@ -71,7 +71,7 @@ class DogStatsDClient { const buffer = Buffer.concat(queue) request(buffer, this._httpOptions, (err) => { if (err) { - log.error('HTTP error from agent: ' + err.stack) + log.error('DogStatsDClient: HTTP error from agent: %s', err.message, err) if (err.status === 404) { // Inside this if-block, we have connectivity to the agent, but // we're not getting a 200 from the proxy endpoint. If it's a 404, @@ -89,7 +89,7 @@ class DogStatsDClient { this._sendUdpFromQueue(queue, this._host, this._family) } else { lookup(this._host, (err, address, family) => { - if (err) return log.error(err) + if (err) return log.error('DogStatsDClient: Host not found', err) this._sendUdpFromQueue(queue, address, family) }) } diff --git a/packages/dd-trace/src/encode/0.4.js b/packages/dd-trace/src/encode/0.4.js index 02d96cb8a26..d5c72bdb575 100644 --- a/packages/dd-trace/src/encode/0.4.js +++ b/packages/dd-trace/src/encode/0.4.js @@ -1,26 +1,20 @@ 'use strict' const { truncateSpan, normalizeSpan } = require('./tags-processors') -const Chunk = require('./chunk') +const { Chunk, MsgpackEncoder } = require('../msgpack') const log = require('../log') const { isTrue } = require('../util') const coalesce = require('koalas') const SOFT_LIMIT = 8 * 1024 * 1024 // 8MB -const float64Array = new Float64Array(1) -const uInt8Float64Array = new Uint8Array(float64Array.buffer) - -float64Array[0] = -1 - -const bigEndian = uInt8Float64Array[7] === 0 - function formatSpan (span) { return normalizeSpan(truncateSpan(span, false)) } class AgentEncoder { constructor (writer, limit = SOFT_LIMIT) { + this._msgpack = new MsgpackEncoder() this._limit = limit this._traceBytes = new Chunk() this._stringBytes = new Chunk() @@ -84,11 +78,11 @@ class AgentEncoder { bytes.reserve(1) if (span.type && span.meta_struct) { - bytes.buffer[bytes.length++] = 0x8d + bytes.buffer[bytes.length - 1] = 0x8d } else if (span.type || span.meta_struct) { - bytes.buffer[bytes.length++] = 0x8c + bytes.buffer[bytes.length - 1] = 0x8c } else { - bytes.buffer[bytes.length++] = 0x8b + bytes.buffer[bytes.length - 1] = 0x8b } if (span.type) { @@ -135,43 +129,31 @@ class AgentEncoder { this._cacheString('') } - _encodeArrayPrefix (bytes, value) { - const length = value.length - const offset = bytes.length + _encodeBuffer (bytes, buffer) { + this._msgpack.encodeBin(bytes, buffer) + } - bytes.reserve(5) - bytes.length += 5 + _encodeBool (bytes, value) { + this._msgpack.encodeBoolean(bytes, value) + } - bytes.buffer[offset] = 0xdd - bytes.buffer[offset + 1] = length >> 24 - bytes.buffer[offset + 2] = length >> 16 - bytes.buffer[offset + 3] = length >> 8 - bytes.buffer[offset + 4] = length + _encodeArrayPrefix (bytes, value) { + this._msgpack.encodeArrayPrefix(bytes, value) } _encodeMapPrefix (bytes, keysLength) { - const offset = bytes.length - - bytes.reserve(5) - bytes.length += 5 - bytes.buffer[offset] = 0xdf - bytes.buffer[offset + 1] = keysLength >> 24 - bytes.buffer[offset + 2] = keysLength >> 16 - bytes.buffer[offset + 3] = keysLength >> 8 - bytes.buffer[offset + 4] = keysLength + this._msgpack.encodeMapPrefix(bytes, keysLength) } _encodeByte (bytes, value) { - bytes.reserve(1) - - bytes.buffer[bytes.length++] = value + this._msgpack.encodeByte(bytes, value) } + // TODO: Use BigInt instead. _encodeId (bytes, id) { const offset = bytes.length bytes.reserve(9) - bytes.length += 9 id = id.toArray() @@ -186,36 +168,16 @@ class AgentEncoder { bytes.buffer[offset + 8] = id[7] } - _encodeInteger (bytes, value) { - const offset = bytes.length - - bytes.reserve(5) - bytes.length += 5 + _encodeNumber (bytes, value) { + this._msgpack.encodeNumber(bytes, value) + } - bytes.buffer[offset] = 0xce - bytes.buffer[offset + 1] = value >> 24 - bytes.buffer[offset + 2] = value >> 16 - bytes.buffer[offset + 3] = value >> 8 - bytes.buffer[offset + 4] = value + _encodeInteger (bytes, value) { + this._msgpack.encodeInteger(bytes, value) } _encodeLong (bytes, value) { - const offset = bytes.length - const hi = (value / Math.pow(2, 32)) >> 0 - const lo = value >>> 0 - - bytes.reserve(9) - bytes.length += 9 - - bytes.buffer[offset] = 0xcf - bytes.buffer[offset + 1] = hi >> 24 - bytes.buffer[offset + 2] = hi >> 16 - bytes.buffer[offset + 3] = hi >> 8 - bytes.buffer[offset + 4] = hi - bytes.buffer[offset + 5] = lo >> 24 - bytes.buffer[offset + 6] = lo >> 16 - bytes.buffer[offset + 7] = lo >> 8 - bytes.buffer[offset + 8] = lo + this._msgpack.encodeLong(bytes, value) } _encodeMap (bytes, value) { @@ -252,23 +214,7 @@ class AgentEncoder { } _encodeFloat (bytes, value) { - float64Array[0] = value - - const offset = bytes.length - bytes.reserve(9) - bytes.length += 9 - - bytes.buffer[offset] = 0xcb - - if (bigEndian) { - for (let i = 0; i <= 7; i++) { - bytes.buffer[offset + i + 1] = uInt8Float64Array[i] - } - } else { - for (let i = 7; i >= 0; i--) { - bytes.buffer[bytes.length - i - 1] = uInt8Float64Array[i] - } - } + this._msgpack.encodeFloat(bytes, value) } _encodeMetaStruct (bytes, value) { @@ -294,7 +240,6 @@ class AgentEncoder { const offset = bytes.length bytes.reserve(prefixLength) - bytes.length += prefixLength this._encodeObject(bytes, value) diff --git a/packages/dd-trace/src/encode/agentless-ci-visibility.js b/packages/dd-trace/src/encode/agentless-ci-visibility.js index dea15182323..bc5d9fc42b6 100644 --- a/packages/dd-trace/src/encode/agentless-ci-visibility.js +++ b/packages/dd-trace/src/encode/agentless-ci-visibility.js @@ -251,37 +251,6 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { } } - _encodeNumber (bytes, value) { - if (Math.floor(value) !== value) { // float 64 - return this._encodeFloat(bytes, value) - } - return this._encodeLong(bytes, value) - } - - _encodeLong (bytes, value) { - const isPositive = value >= 0 - - const hi = isPositive ? (value / Math.pow(2, 32)) >> 0 : Math.floor(value / Math.pow(2, 32)) - const lo = value >>> 0 - const flag = isPositive ? 0xcf : 0xd3 - - const offset = bytes.length - - // int 64 - bytes.reserve(9) - bytes.length += 9 - - bytes.buffer[offset] = flag - bytes.buffer[offset + 1] = hi >> 24 - bytes.buffer[offset + 2] = hi >> 16 - bytes.buffer[offset + 3] = hi >> 8 - bytes.buffer[offset + 4] = hi - bytes.buffer[offset + 5] = lo >> 24 - bytes.buffer[offset + 6] = lo >> 16 - bytes.buffer[offset + 7] = lo >> 8 - bytes.buffer[offset + 8] = lo - } - _encode (bytes, trace) { if (this._isReset) { this._encodePayloadStart(bytes) @@ -380,7 +349,6 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { // Get offset of the events list to update the length of the array when calling `makePayload` this._eventsOffset = bytes.length bytes.reserve(5) - bytes.length += 5 } reset () { diff --git a/packages/dd-trace/src/encode/coverage-ci-visibility.js b/packages/dd-trace/src/encode/coverage-ci-visibility.js index bdf4b17a3cc..5b31d83cb12 100644 --- a/packages/dd-trace/src/encode/coverage-ci-visibility.js +++ b/packages/dd-trace/src/encode/coverage-ci-visibility.js @@ -1,6 +1,6 @@ 'use strict' const { AgentEncoder } = require('./0.4') -const Chunk = require('./chunk') +const { Chunk } = require('../msgpack') const { distributionMetric, @@ -82,7 +82,6 @@ class CoverageCIVisibilityEncoder extends AgentEncoder { // Get offset of the coverages list to update the length of the array when calling `makePayload` this._coveragesOffset = bytes.length bytes.reserve(5) - bytes.length += 5 } makePayload () { diff --git a/packages/dd-trace/src/encode/span-stats.js b/packages/dd-trace/src/encode/span-stats.js index 15410cec203..43215756c7c 100644 --- a/packages/dd-trace/src/encode/span-stats.js +++ b/packages/dd-trace/src/encode/span-stats.js @@ -22,10 +22,6 @@ function truncate (value, maxLength, suffix = '') { } class SpanStatsEncoder extends AgentEncoder { - _encodeBool (bytes, value) { - this._encodeByte(bytes, value ? 0xc3 : 0xc2) - } - makePayload () { const traceSize = this._traceBytes.length const buffer = Buffer.allocUnsafe(traceSize) @@ -34,32 +30,6 @@ class SpanStatsEncoder extends AgentEncoder { return buffer } - _encodeMapPrefix (bytes, length) { - const offset = bytes.length - - bytes.reserve(1) - bytes.length += 1 - - bytes.buffer[offset] = 0x80 + length - } - - _encodeBuffer (bytes, buffer) { - const length = buffer.length - const offset = bytes.length - - bytes.reserve(5) - bytes.length += 5 - - bytes.buffer[offset] = 0xc6 - bytes.buffer[offset + 1] = length >> 24 - bytes.buffer[offset + 2] = length >> 16 - bytes.buffer[offset + 3] = length >> 8 - bytes.buffer[offset + 4] = length - - buffer.copy(bytes.buffer, offset + 5) - bytes.length += length - } - _encodeStat (bytes, stat) { this._encodeMapPrefix(bytes, 12) diff --git a/packages/dd-trace/src/exporters/agent/writer.js b/packages/dd-trace/src/exporters/agent/writer.js index 82a28647778..8fac323e614 100644 --- a/packages/dd-trace/src/exporters/agent/writer.js +++ b/packages/dd-trace/src/exporters/agent/writer.js @@ -41,17 +41,17 @@ class Writer extends BaseWriter { startupLog({ agentError: err }) if (err) { - log.error(err) + log.error('Error sending payload to the agent (status code: %s)', err.status, err) done() return } - log.debug(`Response from the agent: ${res}`) + log.debug('Response from the agent: %s', res) try { this._prioritySampler.update(JSON.parse(res).rate_by_service) } catch (e) { - log.error(e) + log.error('Error updating prioritySampler rates', e) runtimeMetrics.increment(`${METRIC_PREFIX}.errors`, true) runtimeMetrics.increment(`${METRIC_PREFIX}.errors.by.name`, `name:${e.name}`, true) diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index ab8b697eef6..2ff90236ee8 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -86,7 +86,7 @@ function request (data, options, callback) { if (isGzip) { zlib.gunzip(buffer, (err, result) => { if (err) { - log.error(`Could not gunzip response: ${err.message}`) + log.error('Could not gunzip response: %s', err.message) callback(null, '', res.statusCode) } else { callback(null, result.toString(), res.statusCode) diff --git a/packages/dd-trace/src/exporters/span-stats/writer.js b/packages/dd-trace/src/exporters/span-stats/writer.js index 3ece6d221b4..37cd6c77d5e 100644 --- a/packages/dd-trace/src/exporters/span-stats/writer.js +++ b/packages/dd-trace/src/exporters/span-stats/writer.js @@ -16,7 +16,7 @@ class Writer extends BaseWriter { _sendPayload (data, _, done) { makeRequest(data, this._url, (err, res) => { if (err) { - log.error(err) + log.error('Error sending span stats', err) done() return } diff --git a/packages/dd-trace/src/flare/index.js b/packages/dd-trace/src/flare/index.js index 70ec4ccd75e..4a5166d45e1 100644 --- a/packages/dd-trace/src/flare/index.js +++ b/packages/dd-trace/src/flare/index.js @@ -83,7 +83,7 @@ const flare = { headers: form.getHeaders() }, (err) => { if (err) { - log.error(err) + log.error('Error sending flare payload', err) } }) } diff --git a/packages/dd-trace/src/guardrails/index.js b/packages/dd-trace/src/guardrails/index.js new file mode 100644 index 00000000000..179262f154e --- /dev/null +++ b/packages/dd-trace/src/guardrails/index.js @@ -0,0 +1,64 @@ +'use strict' + +/* eslint-disable no-var */ + +var path = require('path') +var Module = require('module') +var isTrue = require('./util').isTrue +var log = require('./log') +var telemetry = require('./telemetry') +var nodeVersion = require('../../../../version') + +var NODE_MAJOR = nodeVersion.NODE_MAJOR + +function guard (fn) { + var initBailout = false + var clobberBailout = false + var forced = isTrue(process.env.DD_INJECT_FORCE) + var engines = require('../../../../package.json').engines + var minMajor = parseInt(engines.node.replace(/[^0-9]/g, '')) + var version = process.versions.node + + if (process.env.DD_INJECTION_ENABLED) { + // If we're running via single-step install, and we're in the app's + // node_modules, then we should not initialize the tracer. This prevents + // single-step-installed tracer from clobbering the manually-installed tracer. + var resolvedInApp + var entrypoint = process.argv[1] + try { + resolvedInApp = Module.createRequire(entrypoint).resolve('dd-trace') + } catch (e) { + // Ignore. If we can't resolve the module, we assume it's not in the app. + } + if (resolvedInApp) { + var ourselves = path.normalize(path.join(__dirname, '..', '..', '..', '..', 'index.js')) + if (ourselves !== resolvedInApp) { + clobberBailout = true + } + } + } + + // If the runtime doesn't match the engines field in package.json, then we + // should not initialize the tracer. + if (!clobberBailout && NODE_MAJOR < minMajor) { + initBailout = true + telemetry([ + { name: 'abort', tags: ['reason:incompatible_runtime'] }, + { name: 'abort.runtime', tags: [] } + ]) + log.info('Aborting application instrumentation due to incompatible_runtime.') + log.info('Found incompatible runtime nodejs ' + version + ', Supported runtimes: nodejs ' + engines.node + '.') + if (forced) { + log.info('DD_INJECT_FORCE enabled, allowing unsupported runtimes and continuing.') + } + } + + if (!clobberBailout && (!initBailout || forced)) { + var result = fn() + telemetry('complete', ['injection_forced:' + (forced && initBailout ? 'true' : 'false')]) + log.info('Application instrumentation bootstrapping complete') + return result + } +} + +module.exports = guard diff --git a/packages/dd-trace/src/guardrails/log.js b/packages/dd-trace/src/guardrails/log.js new file mode 100644 index 00000000000..dd74e5bdbf0 --- /dev/null +++ b/packages/dd-trace/src/guardrails/log.js @@ -0,0 +1,32 @@ +'use strict' + +/* eslint-disable no-var */ +/* eslint-disable no-console */ + +var isTrue = require('./util').isTrue + +var DD_TRACE_DEBUG = process.env.DD_TRACE_DEBUG +var DD_TRACE_LOG_LEVEL = process.env.DD_TRACE_LOG_LEVEL + +var logLevels = { + trace: 20, + debug: 20, + info: 30, + warn: 40, + error: 50, + critical: 50, + off: 100 +} + +var logLevel = isTrue(DD_TRACE_DEBUG) + ? Number(DD_TRACE_LOG_LEVEL) || logLevels.debug + : logLevels.off + +var log = { + debug: logLevel <= 20 ? console.debug.bind(console) : function () {}, + info: logLevel <= 30 ? console.info.bind(console) : function () {}, + warn: logLevel <= 40 ? console.warn.bind(console) : function () {}, + error: logLevel <= 50 ? console.error.bind(console) : function () {} +} + +module.exports = log diff --git a/packages/dd-trace/src/guardrails/telemetry.js b/packages/dd-trace/src/guardrails/telemetry.js new file mode 100644 index 00000000000..0c73e1f0bce --- /dev/null +++ b/packages/dd-trace/src/guardrails/telemetry.js @@ -0,0 +1,78 @@ +'use strict' + +/* eslint-disable no-var */ +/* eslint-disable object-shorthand */ + +var fs = require('fs') +var spawn = require('child_process').spawn +var tracerVersion = require('../../../../package.json').version +var log = require('./log') + +module.exports = sendTelemetry + +if (!process.env.DD_INJECTION_ENABLED) { + module.exports = function () {} +} + +if (!process.env.DD_TELEMETRY_FORWARDER_PATH) { + module.exports = function () {} +} + +if (!fs.existsSync(process.env.DD_TELEMETRY_FORWARDER_PATH)) { + module.exports = function () {} +} + +var metadata = { + language_name: 'nodejs', + language_version: process.versions.node, + runtime_name: 'nodejs', + runtime_version: process.versions.node, + tracer_version: tracerVersion, + pid: process.pid +} + +var seen = [] +function hasSeen (point) { + if (point.name === 'abort') { + // This one can only be sent once, regardless of tags + return seen.includes('abort') + } + if (point.name === 'abort.integration') { + // For now, this is the only other one we want to dedupe + var compiledPoint = point.name + point.tags.join('') + return seen.includes(compiledPoint) + } + return false +} + +function sendTelemetry (name, tags) { + var points = name + if (typeof name === 'string') { + points = [{ name: name, tags: tags || [] }] + } + if (['1', 'true', 'True'].indexOf(process.env.DD_INJECT_FORCE) !== -1) { + points = points.filter(function (p) { return ['error', 'complete'].includes(p.name) }) + } + points = points.filter(function (p) { return !hasSeen(p) }) + for (var i = 0; i < points.length; i++) { + points[i].name = 'library_entrypoint.' + points[i].name + } + if (points.length === 0) { + return + } + var proc = spawn(process.env.DD_TELEMETRY_FORWARDER_PATH, ['library_entrypoint'], { + stdio: 'pipe' + }) + proc.on('error', function () { + log.error('Failed to spawn telemetry forwarder') + }) + proc.on('exit', function (code) { + if (code !== 0) { + log.error('Telemetry forwarder exited with code ' + code) + } + }) + proc.stdin.on('error', function () { + log.error('Failed to write telemetry data to telemetry forwarder') + }) + proc.stdin.end(JSON.stringify({ metadata: metadata, points: points })) +} diff --git a/packages/dd-trace/src/guardrails/util.js b/packages/dd-trace/src/guardrails/util.js new file mode 100644 index 00000000000..9aa60713573 --- /dev/null +++ b/packages/dd-trace/src/guardrails/util.js @@ -0,0 +1,10 @@ +'use strict' + +/* eslint-disable object-shorthand */ + +function isTrue (str) { + str = String(str).toLowerCase() + return str === 'true' || str === '1' +} + +module.exports = { isTrue: isTrue } diff --git a/packages/dd-trace/src/lambda/runtime/ritm.js b/packages/dd-trace/src/lambda/runtime/ritm.js index 4dd27713a0b..ec50a4a80be 100644 --- a/packages/dd-trace/src/lambda/runtime/ritm.js +++ b/packages/dd-trace/src/lambda/runtime/ritm.js @@ -101,7 +101,7 @@ const registerLambdaHook = () => { try { moduleExports = hook(moduleExports) } catch (e) { - log.error(e) + log.error('Error executing lambda hook', e) } } @@ -120,7 +120,7 @@ const registerLambdaHook = () => { try { moduleExports = hook(moduleExports) } catch (e) { - log.error(e) + log.error('Error executing lambda hook for datadog-lambda-js', e) } } } diff --git a/packages/dd-trace/src/llmobs/storage.js b/packages/dd-trace/src/llmobs/storage.js index 1362aaf966e..82202c18174 100644 --- a/packages/dd-trace/src/llmobs/storage.js +++ b/packages/dd-trace/src/llmobs/storage.js @@ -1,7 +1,6 @@ 'use strict' -// TODO: remove this and use namespaced storage once available -const { AsyncLocalStorage } = require('async_hooks') -const storage = new AsyncLocalStorage() +const { storage: createStorage } = require('../../../datadog-core') +const storage = createStorage('llmobs') module.exports = { storage } diff --git a/packages/dd-trace/src/llmobs/writers/base.js b/packages/dd-trace/src/llmobs/writers/base.js index 8a6cdae9c2f..1d33bc653ad 100644 --- a/packages/dd-trace/src/llmobs/writers/base.js +++ b/packages/dd-trace/src/llmobs/writers/base.js @@ -74,11 +74,11 @@ class BaseLLMObsWriter { request(payload, options, (err, resp, code) => { if (err) { logger.error( - `Error sending ${events.length} LLMObs ${this._eventType} events to ${this._url}: ${err.message}` + 'Error sending %d LLMObs %s events to %s: %s', events.length, this._eventType, this._url, err.message, err ) } else if (code >= 300) { logger.error( - `Error sending ${events.length} LLMObs ${this._eventType} events to ${this._url}: ${code}` + 'Error sending %d LLMObs %s events to %s: %s', events.length, this._eventType, this._url, code ) } else { logger.debug(`Sent ${events.length} LLMObs ${this._eventType} events to ${this._url}`) diff --git a/packages/dd-trace/src/log/channels.js b/packages/dd-trace/src/log/channels.js index 545fef4195a..b3b10624705 100644 --- a/packages/dd-trace/src/log/channels.js +++ b/packages/dd-trace/src/log/channels.js @@ -3,7 +3,7 @@ const { channel } = require('dc-polyfill') const Level = { - trace: 20, + trace: 10, debug: 20, info: 30, warn: 40, @@ -12,6 +12,7 @@ const Level = { off: 100 } +const traceChannel = channel('datadog:log:trace') const debugChannel = channel('datadog:log:debug') const infoChannel = channel('datadog:log:info') const warnChannel = channel('datadog:log:warn') @@ -31,6 +32,9 @@ class LogChannel { } subscribe (logger) { + if (Level.trace >= this._level) { + traceChannel.subscribe(logger.trace) + } if (Level.debug >= this._level) { debugChannel.subscribe(logger.debug) } @@ -46,6 +50,9 @@ class LogChannel { } unsubscribe (logger) { + if (traceChannel.hasSubscribers) { + traceChannel.unsubscribe(logger.trace) + } if (debugChannel.hasSubscribers) { debugChannel.unsubscribe(logger.debug) } @@ -63,7 +70,7 @@ class LogChannel { module.exports = { LogChannel, - + traceChannel, debugChannel, infoChannel, warnChannel, diff --git a/packages/dd-trace/src/log/index.js b/packages/dd-trace/src/log/index.js index 3a5392340df..3fb9afff6fa 100644 --- a/packages/dd-trace/src/log/index.js +++ b/packages/dd-trace/src/log/index.js @@ -1,8 +1,9 @@ 'use strict' const coalesce = require('koalas') +const { inspect } = require('util') const { isTrue } = require('../util') -const { debugChannel, infoChannel, warnChannel, errorChannel } = require('./channels') +const { traceChannel, debugChannel, infoChannel, warnChannel, errorChannel } = require('./channels') const logWriter = require('./writer') const { Log } = require('./log') @@ -56,6 +57,25 @@ const log = { return this }, + trace (...args) { + if (traceChannel.hasSubscribers) { + const logRecord = {} + + Error.captureStackTrace(logRecord, this.trace) + + const fn = logRecord.stack.split('\n')[1].replace(/^\s+at ([^\s]+) .+/, '$1') + const params = args.map(a => { + return a && a.hasOwnProperty('toString') && typeof a.toString === 'function' + ? a.toString() + : inspect(a, { depth: 3, breakLength: Infinity, compact: true }) + }).join(', ') + const formatted = logRecord.stack.replace('Error: ', `Trace: ${fn}(${params})`) + + traceChannel.publish(Log.parse(formatted)) + } + return this + }, + debug (...args) { if (debugChannel.hasSubscribers) { debugChannel.publish(Log.parse(...args)) diff --git a/packages/dd-trace/src/log/writer.js b/packages/dd-trace/src/log/writer.js index 4724253244b..322c703b2b3 100644 --- a/packages/dd-trace/src/log/writer.js +++ b/packages/dd-trace/src/log/writer.js @@ -23,7 +23,7 @@ function withNoop (fn) { } function unsubscribeAll () { - logChannel.unsubscribe({ debug: onDebug, info: onInfo, warn: onWarn, error: onError }) + logChannel.unsubscribe({ trace: onTrace, debug: onDebug, info: onInfo, warn: onWarn, error: onError }) } function toggleSubscription (enable, level) { @@ -31,7 +31,7 @@ function toggleSubscription (enable, level) { if (enable) { logChannel = new LogChannel(level) - logChannel.subscribe({ debug: onDebug, info: onInfo, warn: onWarn, error: onError }) + logChannel.subscribe({ trace: onTrace, debug: onDebug, info: onInfo, warn: onWarn, error: onError }) } } @@ -88,6 +88,14 @@ function onDebug (log) { if (cause) withNoop(() => logger.debug(cause)) } +function onTrace (log) { + const { formatted, cause } = getErrorLog(log) + // Using logger.debug() because not all loggers have trace level, + // and console.trace() has a completely different meaning. + if (formatted) withNoop(() => logger.debug(formatted)) + if (cause) withNoop(() => logger.debug(cause)) +} + function error (...args) { onError(Log.parse(...args)) } @@ -110,4 +118,8 @@ function debug (...args) { onDebug(Log.parse(...args)) } -module.exports = { use, toggle, reset, error, warn, info, debug } +function trace (...args) { + onTrace(Log.parse(...args)) +} + +module.exports = { use, toggle, reset, error, warn, info, debug, trace } diff --git a/packages/dd-trace/src/encode/chunk.js b/packages/dd-trace/src/msgpack/chunk.js similarity index 85% rename from packages/dd-trace/src/encode/chunk.js rename to packages/dd-trace/src/msgpack/chunk.js index 8a17b45f430..02999086c55 100644 --- a/packages/dd-trace/src/encode/chunk.js +++ b/packages/dd-trace/src/msgpack/chunk.js @@ -10,6 +10,7 @@ const DEFAULT_MIN_SIZE = 2 * 1024 * 1024 // 2MB class Chunk { constructor (minSize = DEFAULT_MIN_SIZE) { this.buffer = Buffer.allocUnsafe(minSize) + this.view = new DataView(this.buffer.buffer) this.length = 0 this._minSize = minSize } @@ -20,11 +21,9 @@ class Chunk { if (length < 0x20) { // fixstr this.reserve(length + 1) - this.length += 1 this.buffer[offset] = length | 0xa0 } else if (length < 0x100000000) { // str 32 this.reserve(length + 5) - this.length += 5 this.buffer[offset] = 0xdb this.buffer[offset + 1] = length >> 24 this.buffer[offset + 2] = length >> 16 @@ -32,7 +31,7 @@ class Chunk { this.buffer[offset + 4] = length } - this.length += this.buffer.utf8Write(value, this.length, length) + this.buffer.utf8Write(value, this.length - length, length) return this.length - offset } @@ -42,22 +41,26 @@ class Chunk { } set (array) { + const length = this.length + this.reserve(array.length) - this.buffer.set(array, this.length) - this.length += array.length + this.buffer.set(array, length) } reserve (size) { if (this.length + size > this.buffer.length) { this._resize(this._minSize * Math.ceil((this.length + size) / this._minSize)) } + + this.length += size } _resize (size) { const oldBuffer = this.buffer this.buffer = Buffer.allocUnsafe(size) + this.view = new DataView(this.buffer.buffer) oldBuffer.copy(this.buffer, 0, 0, this.length) } diff --git a/packages/dd-trace/src/msgpack/encoder.js b/packages/dd-trace/src/msgpack/encoder.js new file mode 100644 index 00000000000..6fa39d82148 --- /dev/null +++ b/packages/dd-trace/src/msgpack/encoder.js @@ -0,0 +1,309 @@ +'use strict' + +const Chunk = require('./chunk') + +class MsgpackEncoder { + encode (value) { + const bytes = new Chunk() + + this.encodeValue(bytes, value) + + return bytes.buffer.subarray(0, bytes.length) + } + + encodeValue (bytes, value) { + switch (typeof value) { + case 'bigint': + this.encodeBigInt(bytes, value) + break + case 'boolean': + this.encodeBoolean(bytes, value) + break + case 'number': + this.encodeNumber(bytes, value) + break + case 'object': + if (value === null) { + this.encodeNull(bytes, value) + } else if (Array.isArray(value)) { + this.encodeArray(bytes, value) + } else if (Buffer.isBuffer(value) || ArrayBuffer.isView(value)) { + this.encodeBin(bytes, value) + } else { + this.encodeMap(bytes, value) + } + break + case 'string': + this.encodeString(bytes, value) + break + case 'symbol': + this.encodeString(bytes, value.toString()) + break + default: // function, symbol, undefined + this.encodeNull(bytes, value) + break + } + } + + encodeNull (bytes) { + const offset = bytes.length + + bytes.reserve(1) + bytes.buffer[offset] = 0xc0 + } + + encodeBoolean (bytes, value) { + const offset = bytes.length + + bytes.reserve(1) + bytes.buffer[offset] = value ? 0xc3 : 0xc2 + } + + encodeString (bytes, value) { + bytes.write(value) + } + + encodeFixArray (bytes, size = 0) { + const offset = bytes.length + + bytes.reserve(1) + bytes.buffer[offset] = 0x90 + size + } + + encodeArrayPrefix (bytes, value) { + const length = value.length + const offset = bytes.length + + bytes.reserve(5) + bytes.buffer[offset] = 0xdd + bytes.buffer[offset + 1] = length >> 24 + bytes.buffer[offset + 2] = length >> 16 + bytes.buffer[offset + 3] = length >> 8 + bytes.buffer[offset + 4] = length + } + + encodeArray (bytes, value) { + if (value.length < 16) { + this.encodeFixArray(bytes, value.length) + } else { + this.encodeArrayPrefix(bytes, value) + } + + for (const item of value) { + this.encodeValue(bytes, item) + } + } + + encodeFixMap (bytes, size = 0) { + const offset = bytes.length + + bytes.reserve(1) + bytes.buffer[offset] = 0x80 + size + } + + encodeMapPrefix (bytes, keysLength) { + const offset = bytes.length + + bytes.reserve(5) + bytes.buffer[offset] = 0xdf + bytes.buffer[offset + 1] = keysLength >> 24 + bytes.buffer[offset + 2] = keysLength >> 16 + bytes.buffer[offset + 3] = keysLength >> 8 + bytes.buffer[offset + 4] = keysLength + } + + encodeByte (bytes, value) { + bytes.reserve(1) + bytes.buffer[bytes.length - 1] = value + } + + encodeBin (bytes, value) { + const offset = bytes.length + + if (value.byteLength < 256) { + bytes.reserve(2) + bytes.buffer[offset] = 0xc4 + bytes.buffer[offset + 1] = value.byteLength + } else if (value.byteLength < 65536) { + bytes.reserve(3) + bytes.buffer[offset] = 0xc5 + bytes.buffer[offset + 1] = value.byteLength >> 8 + bytes.buffer[offset + 2] = value.byteLength + } else { + bytes.reserve(5) + bytes.buffer[offset] = 0xc6 + bytes.buffer[offset + 1] = value.byteLength >> 24 + bytes.buffer[offset + 2] = value.byteLength >> 16 + bytes.buffer[offset + 3] = value.byteLength >> 8 + bytes.buffer[offset + 4] = value.byteLength + } + + bytes.set(value) + } + + encodeInteger (bytes, value) { + const offset = bytes.length + + bytes.reserve(5) + bytes.buffer[offset] = 0xce + bytes.buffer[offset + 1] = value >> 24 + bytes.buffer[offset + 2] = value >> 16 + bytes.buffer[offset + 3] = value >> 8 + bytes.buffer[offset + 4] = value + } + + encodeShort (bytes, value) { + const offset = bytes.length + + bytes.reserve(3) + bytes.buffer[offset] = 0xcd + bytes.buffer[offset + 1] = value >> 8 + bytes.buffer[offset + 2] = value + } + + encodeLong (bytes, value) { + const offset = bytes.length + const hi = (value / Math.pow(2, 32)) >> 0 + const lo = value >>> 0 + + bytes.reserve(9) + bytes.buffer[offset] = 0xcf + bytes.buffer[offset + 1] = hi >> 24 + bytes.buffer[offset + 2] = hi >> 16 + bytes.buffer[offset + 3] = hi >> 8 + bytes.buffer[offset + 4] = hi + bytes.buffer[offset + 5] = lo >> 24 + bytes.buffer[offset + 6] = lo >> 16 + bytes.buffer[offset + 7] = lo >> 8 + bytes.buffer[offset + 8] = lo + } + + encodeNumber (bytes, value) { + if (Number.isNaN(value)) { + value = 0 + } + if (Number.isInteger(value)) { + if (value >= 0) { + this.encodeUnsigned(bytes, value) + } else { + this.encodeSigned(bytes, value) + } + } else { + this.encodeFloat(bytes, value) + } + } + + encodeSigned (bytes, value) { + const offset = bytes.length + + if (value >= -0x20) { + bytes.reserve(1) + bytes.buffer[offset] = value + } else if (value >= -0x80) { + bytes.reserve(2) + bytes.buffer[offset] = 0xd0 + bytes.buffer[offset + 1] = value + } else if (value >= -0x8000) { + bytes.reserve(3) + bytes.buffer[offset] = 0xd1 + bytes.buffer[offset + 1] = value >> 8 + bytes.buffer[offset + 2] = value + } else if (value >= -0x80000000) { + bytes.reserve(5) + bytes.buffer[offset] = 0xd2 + bytes.buffer[offset + 1] = value >> 24 + bytes.buffer[offset + 2] = value >> 16 + bytes.buffer[offset + 3] = value >> 8 + bytes.buffer[offset + 4] = value + } else { + const hi = Math.floor(value / Math.pow(2, 32)) + const lo = value >>> 0 + + bytes.reserve(9) + bytes.buffer[offset] = 0xd3 + bytes.buffer[offset + 1] = hi >> 24 + bytes.buffer[offset + 2] = hi >> 16 + bytes.buffer[offset + 3] = hi >> 8 + bytes.buffer[offset + 4] = hi + bytes.buffer[offset + 5] = lo >> 24 + bytes.buffer[offset + 6] = lo >> 16 + bytes.buffer[offset + 7] = lo >> 8 + bytes.buffer[offset + 8] = lo + } + } + + encodeUnsigned (bytes, value) { + const offset = bytes.length + + if (value <= 0x7f) { + bytes.reserve(1) + bytes.buffer[offset] = value + } else if (value <= 0xff) { + bytes.reserve(2) + bytes.buffer[offset] = 0xcc + bytes.buffer[offset + 1] = value + } else if (value <= 0xffff) { + bytes.reserve(3) + bytes.buffer[offset] = 0xcd + bytes.buffer[offset + 1] = value >> 8 + bytes.buffer[offset + 2] = value + } else if (value <= 0xffffffff) { + bytes.reserve(5) + bytes.buffer[offset] = 0xce + bytes.buffer[offset + 1] = value >> 24 + bytes.buffer[offset + 2] = value >> 16 + bytes.buffer[offset + 3] = value >> 8 + bytes.buffer[offset + 4] = value + } else { + const hi = (value / Math.pow(2, 32)) >> 0 + const lo = value >>> 0 + + bytes.reserve(9) + bytes.buffer[offset] = 0xcf + bytes.buffer[offset + 1] = hi >> 24 + bytes.buffer[offset + 2] = hi >> 16 + bytes.buffer[offset + 3] = hi >> 8 + bytes.buffer[offset + 4] = hi + bytes.buffer[offset + 5] = lo >> 24 + bytes.buffer[offset + 6] = lo >> 16 + bytes.buffer[offset + 7] = lo >> 8 + bytes.buffer[offset + 8] = lo + } + } + + // TODO: Support BigInt larger than 64bit. + encodeBigInt (bytes, value) { + const offset = bytes.length + + bytes.reserve(9) + + if (value >= 0n) { + bytes.buffer[offset] = 0xcf + bytes.view.setBigUint64(offset + 1, value) + } else { + bytes.buffer[offset] = 0xd3 + bytes.view.setBigInt64(offset + 1, value) + } + } + + encodeMap (bytes, value) { + const keys = Object.keys(value) + + this.encodeMapPrefix(bytes, keys.length) + + for (const key of keys) { + this.encodeValue(bytes, key) + this.encodeValue(bytes, value[key]) + } + } + + encodeFloat (bytes, value) { + const offset = bytes.length + + bytes.reserve(9) + bytes.buffer[offset] = 0xcb + bytes.view.setFloat64(offset + 1, value) + } +} + +module.exports = { MsgpackEncoder } diff --git a/packages/dd-trace/src/msgpack/index.js b/packages/dd-trace/src/msgpack/index.js new file mode 100644 index 00000000000..03228d27044 --- /dev/null +++ b/packages/dd-trace/src/msgpack/index.js @@ -0,0 +1,6 @@ +'use strict' + +const Chunk = require('./chunk') +const { MsgpackEncoder } = require('./encoder') + +module.exports = { Chunk, MsgpackEncoder } diff --git a/packages/dd-trace/src/opentelemetry/context_manager.js b/packages/dd-trace/src/opentelemetry/context_manager.js index fba84eef9f4..430626bbd7e 100644 --- a/packages/dd-trace/src/opentelemetry/context_manager.js +++ b/packages/dd-trace/src/opentelemetry/context_manager.js @@ -1,6 +1,6 @@ 'use strict' -const { AsyncLocalStorage } = require('async_hooks') +const { storage } = require('../../../datadog-core') const { trace, ROOT_CONTEXT } = require('@opentelemetry/api') const DataDogSpanContext = require('../opentracing/span_context') @@ -9,7 +9,7 @@ const tracer = require('../../') class ContextManager { constructor () { - this._store = new AsyncLocalStorage() + this._store = storage('opentelemetry') } active () { diff --git a/packages/dd-trace/src/opentracing/propagation/text_map.js b/packages/dd-trace/src/opentracing/propagation/text_map.js index b117ae0ae5e..82bc9f2b30f 100644 --- a/packages/dd-trace/src/opentracing/propagation/text_map.js +++ b/packages/dd-trace/src/opentracing/propagation/text_map.js @@ -300,14 +300,17 @@ class TextMapPropagator { case 'tracecontext': extractedContext = this._extractTraceparentContext(carrier) break - case 'b3' && this - ._config - .tracePropagationStyle - .otelPropagators: // TODO: should match "b3 single header" in next major case 'b3 single header': // TODO: delete in major after singular "b3" extractedContext = this._extractB3SingleContext(carrier) break case 'b3': + if (this._config.tracePropagationStyle.otelPropagators) { + // TODO: should match "b3 single header" in next major + extractedContext = this._extractB3SingleContext(carrier) + } else { + extractedContext = this._extractB3MultiContext(carrier) + } + break case 'b3multi': extractedContext = this._extractB3MultiContext(carrier) break @@ -496,7 +499,7 @@ class TextMapPropagator { } _extractGenericContext (carrier, traceKey, spanKey, radix) { - if (carrier[traceKey] && carrier[spanKey]) { + if (carrier && carrier[traceKey] && carrier[spanKey]) { if (invalidSegment.test(carrier[traceKey])) return null return new DatadogSpanContext({ diff --git a/packages/dd-trace/src/opentracing/span.js b/packages/dd-trace/src/opentracing/span.js index e855e504e17..23f885bbabd 100644 --- a/packages/dd-trace/src/opentracing/span.js +++ b/packages/dd-trace/src/opentracing/span.js @@ -107,7 +107,7 @@ class DatadogSpan { toString () { const spanContext = this.context() - const resourceName = spanContext._tags['resource.name'] + const resourceName = spanContext._tags['resource.name'] || '' const resource = resourceName.length > 100 ? `${resourceName.substring(0, 97)}...` : resourceName @@ -214,7 +214,7 @@ class DatadogSpan { if (DD_TRACE_EXPERIMENTAL_STATE_TRACKING === 'true') { if (!this._spanContext._tags['service.name']) { - log.error(`Finishing invalid span: ${this}`) + log.error('Finishing invalid span: %s', this) } } diff --git a/packages/dd-trace/src/opentracing/tracer.js b/packages/dd-trace/src/opentracing/tracer.js index 2d854442cc3..4ae30ca93ac 100644 --- a/packages/dd-trace/src/opentracing/tracer.js +++ b/packages/dd-trace/src/opentracing/tracer.js @@ -91,7 +91,7 @@ class DatadogTracer { } this._propagators[format].inject(context, carrier) } catch (e) { - log.error(e) + log.error('Error injecting trace', e) runtimeMetrics.increment('datadog.tracer.node.inject.errors', true) } } @@ -100,7 +100,7 @@ class DatadogTracer { try { return this._propagators[format].extract(carrier) } catch (e) { - log.error(e) + log.error('Error extracting trace', e) runtimeMetrics.increment('datadog.tracer.node.extract.errors', true) return null } diff --git a/packages/dd-trace/src/plugin_manager.js b/packages/dd-trace/src/plugin_manager.js index e9daea9b60b..74cc656048b 100644 --- a/packages/dd-trace/src/plugin_manager.js +++ b/packages/dd-trace/src/plugin_manager.js @@ -138,7 +138,8 @@ module.exports = class PluginManager { clientIpEnabled, memcachedCommandEnabled, ciVisibilityTestSessionName, - ciVisAgentlessLogSubmissionEnabled + ciVisAgentlessLogSubmissionEnabled, + isTestDynamicInstrumentationEnabled } = this._tracerConfig const sharedConfig = { @@ -149,7 +150,8 @@ module.exports = class PluginManager { url, headers: headerTags || [], ciVisibilityTestSessionName, - ciVisAgentlessLogSubmissionEnabled + ciVisAgentlessLogSubmissionEnabled, + isTestDynamicInstrumentationEnabled } if (logInjection !== undefined) { diff --git a/packages/dd-trace/src/plugins/ci_plugin.js b/packages/dd-trace/src/plugins/ci_plugin.js index d4c9f32bc68..a2f8948bf49 100644 --- a/packages/dd-trace/src/plugins/ci_plugin.js +++ b/packages/dd-trace/src/plugins/ci_plugin.js @@ -21,7 +21,9 @@ const { ITR_CORRELATION_ID, TEST_SOURCE_FILE, TEST_LEVEL_EVENT_TYPES, - TEST_SUITE + TEST_SUITE, + getFileAndLineNumberFromError, + getTestSuitePath } = require('./util/test') const Plugin = require('./plugin') const { COMPONENT } = require('../constants') @@ -47,7 +49,7 @@ module.exports = class CiPlugin extends Plugin { } this.tracer._exporter.getLibraryConfiguration(this.testConfiguration, (err, libraryConfig) => { if (err) { - log.error(`Library configuration could not be fetched. ${err.message}`) + log.error('Library configuration could not be fetched. %s', err.message) } else { this.libraryConfig = libraryConfig } @@ -61,7 +63,7 @@ module.exports = class CiPlugin extends Plugin { } this.tracer._exporter.getSkippableSuites(this.testConfiguration, (err, skippableSuites, itrCorrelationId) => { if (err) { - log.error(`Skippable suites could not be fetched. ${err.message}`) + log.error('Skippable suites could not be fetched. %s', err.message) } else { this.itrCorrelationId = itrCorrelationId } @@ -150,7 +152,7 @@ module.exports = class CiPlugin extends Plugin { } this.tracer._exporter.getKnownTests(this.testConfiguration, (err, knownTests) => { if (err) { - log.error(`Known tests could not be fetched. ${err.message}`) + log.error('Known tests could not be fetched. %s', err.message) this.libraryConfig.isEarlyFlakeDetectionEnabled = false } onDone({ err, knownTests }) @@ -180,6 +182,12 @@ module.exports = class CiPlugin extends Plugin { configure (config) { super.configure(config) + + if (config.isTestDynamicInstrumentationEnabled) { + const testVisibilityDynamicInstrumentation = require('../ci-visibility/dynamic-instrumentation') + this.di = testVisibilityDynamicInstrumentation + } + this.testEnvironmentMetadata = getTestEnvironmentMetadata(this.constructor.id, this.config) const { @@ -283,4 +291,39 @@ module.exports = class CiPlugin extends Plugin { return testSpan } + + // TODO: If the test finishes and the probe is not hit, we should remove the breakpoint + addDiProbe (err, probe) { + const [file, line] = getFileAndLineNumberFromError(err) + + const relativePath = getTestSuitePath(file, this.repositoryRoot) + + const [ + snapshotId, + setProbePromise, + hitProbePromise + ] = this.di.addLineProbe({ file: relativePath, line }) + + if (probe) { // not all frameworks may sync with the set probe promise + probe.setProbePromise = setProbePromise + } + + hitProbePromise.then(({ snapshot }) => { + // TODO: handle race conditions for this.retriedTestIds + const { traceId, spanId } = this.retriedTestIds + this.tracer._exporter.exportDiLogs(this.testEnvironmentMetadata, { + debugger: { snapshot }, + dd: { + trace_id: traceId, + span_id: spanId + } + }) + }) + + return { + snapshotId, + file: relativePath, + line + } + } } diff --git a/packages/dd-trace/src/plugins/plugin.js b/packages/dd-trace/src/plugins/plugin.js index 78a49b62b14..e8d9c911a69 100644 --- a/packages/dd-trace/src/plugins/plugin.js +++ b/packages/dd-trace/src/plugins/plugin.js @@ -79,7 +79,7 @@ module.exports = class Plugin { return handler.apply(this, arguments) } catch (e) { logger.error('Error in plugin handler:', e) - logger.info('Disabling plugin:', plugin.id) + logger.info('Disabling plugin: %s', plugin.id) plugin.configure(false) } } diff --git a/packages/dd-trace/src/plugins/tracing.js b/packages/dd-trace/src/plugins/tracing.js index 6f11b9bde6a..e384b8cb7a7 100644 --- a/packages/dd-trace/src/plugins/tracing.js +++ b/packages/dd-trace/src/plugins/tracing.js @@ -94,7 +94,7 @@ class TracingPlugin extends Plugin { } addError (error, span = this.activeSpan) { - if (!span._spanContext._tags.error) { + if (span && !span._spanContext._tags.error) { // Errors may be wrapped in a context. error = (error && error.error) || error span.setTag('error', error || 1) diff --git a/packages/dd-trace/src/plugins/util/git.js b/packages/dd-trace/src/plugins/util/git.js index 06b9521817f..47707a48679 100644 --- a/packages/dd-trace/src/plugins/util/git.js +++ b/packages/dd-trace/src/plugins/util/git.js @@ -61,7 +61,7 @@ function sanitizedExec ( exitCode: err.status || err.errno }) } - log.error(err) + log.error('Git plugin error executing command', err) return '' } finally { storage.enterWith(store) @@ -144,7 +144,7 @@ function unshallowRepository () { ], { stdio: 'pipe' }) } catch (err) { // If the local HEAD is a commit that has not been pushed to the remote, the above command will fail. - log.error(err) + log.error('Git plugin error executing git command', err) incrementCountMetric( TELEMETRY_GIT_COMMAND_ERRORS, { command: 'unshallow', errorType: err.code, exitCode: err.status || err.errno } @@ -157,7 +157,7 @@ function unshallowRepository () { ], { stdio: 'pipe' }) } catch (err) { // If the CI is working on a detached HEAD or branch tracking hasn’t been set up, the above command will fail. - log.error(err) + log.error('Git plugin error executing fallback git command', err) incrementCountMetric( TELEMETRY_GIT_COMMAND_ERRORS, { command: 'unshallow', errorType: err.code, exitCode: err.status || err.errno } @@ -196,7 +196,7 @@ function getLatestCommits () { distributionMetric(TELEMETRY_GIT_COMMAND_MS, { command: 'get_local_commits' }, Date.now() - startTime) return result } catch (err) { - log.error(`Get latest commits failed: ${err.message}`) + log.error('Get latest commits failed: %s', err.message) incrementCountMetric( TELEMETRY_GIT_COMMAND_ERRORS, { command: 'get_local_commits', errorType: err.status } @@ -229,7 +229,7 @@ function getCommitsRevList (commitsToExclude, commitsToInclude) { .split('\n') .filter(commit => commit) } catch (err) { - log.error(`Get commits to upload failed: ${err.message}`) + log.error('Get commits to upload failed: %s', err.message) incrementCountMetric( TELEMETRY_GIT_COMMAND_ERRORS, { command: 'get_objects', errorType: err.code, exitCode: err.status || err.errno } // err.status might be null @@ -272,7 +272,7 @@ function generatePackFilesForCommits (commitsToUpload) { try { result = execGitPackObjects(temporaryPath) } catch (err) { - log.error(err) + log.error('Git plugin error executing git pack-objects command', err) incrementCountMetric( TELEMETRY_GIT_COMMAND_ERRORS, { command: 'pack_objects', exitCode: err.status || err.errno, errorType: err.code } @@ -292,7 +292,7 @@ function generatePackFilesForCommits (commitsToUpload) { try { result = execGitPackObjects(cwdPath) } catch (err) { - log.error(err) + log.error('Git plugin error executing fallback git pack-objects command', err) incrementCountMetric( TELEMETRY_GIT_COMMAND_ERRORS, { command: 'pack_objects', exitCode: err.status || err.errno, errorType: err.code } diff --git a/packages/dd-trace/src/plugins/util/test.js b/packages/dd-trace/src/plugins/util/test.js index 6c0dde70cfb..633b1f14361 100644 --- a/packages/dd-trace/src/plugins/util/test.js +++ b/packages/dd-trace/src/plugins/util/test.js @@ -106,6 +106,13 @@ const TEST_LEVEL_EVENT_TYPES = [ 'test_session_end' ] +// Dynamic instrumentation - Test optimization integration tags +const DI_ERROR_DEBUG_INFO_CAPTURED = 'error.debug_info_captured' +// TODO: for the moment we'll only use a single snapshot id, so `0` is hardcoded +const DI_DEBUG_ERROR_SNAPSHOT_ID = '_dd.debug.error.0.snapshot_id' +const DI_DEBUG_ERROR_FILE = '_dd.debug.error.0.file' +const DI_DEBUG_ERROR_LINE = '_dd.debug.error.0.line' + module.exports = { TEST_CODE_OWNERS, TEST_SESSION_NAME, @@ -181,7 +188,12 @@ module.exports = { TEST_BROWSER_VERSION, getTestSessionName, TEST_LEVEL_EVENT_TYPES, - getNumFromKnownTests + getNumFromKnownTests, + getFileAndLineNumberFromError, + DI_ERROR_DEBUG_INFO_CAPTURED, + DI_DEBUG_ERROR_SNAPSHOT_ID, + DI_DEBUG_ERROR_FILE, + DI_DEBUG_ERROR_LINE } // Returns pkg manager and its version, separated by '-', e.g. npm-8.15.0 or yarn-1.22.19 @@ -206,13 +218,13 @@ function removeInvalidMetadata (metadata) { return Object.keys(metadata).reduce((filteredTags, tag) => { if (tag === GIT_REPOSITORY_URL) { if (!validateGitRepositoryUrl(metadata[GIT_REPOSITORY_URL])) { - log.error(`Repository URL is not a valid repository URL: ${metadata[GIT_REPOSITORY_URL]}.`) + log.error('Repository URL is not a valid repository URL: %s.', metadata[GIT_REPOSITORY_URL]) return filteredTags } } if (tag === GIT_COMMIT_SHA) { if (!validateGitCommitSha(metadata[GIT_COMMIT_SHA])) { - log.error(`Git commit SHA must be a full-length git SHA: ${metadata[GIT_COMMIT_SHA]}.`) + log.error('Git commit SHA must be a full-length git SHA: %s.', metadata[GIT_COMMIT_SHA]) return filteredTags } } @@ -637,3 +649,24 @@ function getNumFromKnownTests (knownTests) { return totalNumTests } + +function getFileAndLineNumberFromError (error) { + // Split the stack trace into individual lines + const stackLines = error.stack.split('\n') + + // The top frame is usually the second line + const topFrame = stackLines[1] + + // Regular expression to match the file path, line number, and column number + const regex = /\s*at\s+(?:.*\()?(.+):(\d+):(\d+)\)?/ + const match = topFrame.match(regex) + + if (match) { + const filePath = match[1] + const lineNumber = Number(match[2]) + const columnNumber = Number(match[3]) + + return [filePath, lineNumber, columnNumber] + } + return [] +} diff --git a/packages/dd-trace/src/plugins/util/web.js b/packages/dd-trace/src/plugins/util/web.js index 5bfb1d6fad4..2d92c74ea91 100644 --- a/packages/dd-trace/src/plugins/util/web.js +++ b/packages/dd-trace/src/plugins/util/web.js @@ -546,7 +546,7 @@ function getHeadersToRecord (config) { .map(h => h.split(':')) .map(([key, tag]) => [key.toLowerCase(), tag]) } catch (err) { - log.error(err) + log.error('Web plugin error getting headers', err) } } else if (config.hasOwnProperty('headers')) { log.error('Expected `headers` to be an array of strings.') @@ -595,7 +595,7 @@ function getQsObfuscator (config) { try { return new RegExp(obfuscator, 'gi') } catch (err) { - log.error(err) + log.error('Web plugin error getting qs obfuscator', err) } } diff --git a/packages/dd-trace/src/priority_sampler.js b/packages/dd-trace/src/priority_sampler.js index f9968a41194..3a89f71f664 100644 --- a/packages/dd-trace/src/priority_sampler.js +++ b/packages/dd-trace/src/priority_sampler.js @@ -1,5 +1,6 @@ 'use strict' +const log = require('./log') const RateLimiter = require('./rate_limiter') const Sampler = require('./sampler') const { setSamplingRules } = require('./startup-log') @@ -44,16 +45,19 @@ class PrioritySampler { this.update({}) } - configure (env, { sampleRate, provenance = undefined, rateLimit = 100, rules = [] } = {}) { + configure (env, opts = {}) { + const { sampleRate, provenance = undefined, rateLimit = 100, rules = [] } = opts this._env = env this._rules = this._normalizeRules(rules, sampleRate, rateLimit, provenance) this._limiter = new RateLimiter(rateLimit) + log.trace(env, opts) setSamplingRules(this._rules) } isSampled (span) { const priority = this._getPriorityFromAuto(span) + log.trace(span) return priority === USER_KEEP || priority === AUTO_KEEP } @@ -67,6 +71,8 @@ class PrioritySampler { if (context._sampling.priority !== undefined) return if (!root) return // noop span + log.trace(span, auto) + const tag = this._getPriorityFromTags(context._tags, context) if (this.validate(tag)) { @@ -94,6 +100,8 @@ class PrioritySampler { samplers[DEFAULT_KEY] = samplers[DEFAULT_KEY] || defaultSampler this._samplers = samplers + + log.trace(rates) } validate (samplingPriority) { @@ -117,6 +125,8 @@ class PrioritySampler { context._sampling.mechanism = mechanism const root = context._trace.started[0] + + log.trace(span, samplingPriority, mechanism) this._addDecisionMaker(root) } diff --git a/packages/dd-trace/src/profiling/config.js b/packages/dd-trace/src/profiling/config.js index 3c360d65f7a..4e7863dce3a 100644 --- a/packages/dd-trace/src/profiling/config.js +++ b/packages/dd-trace/src/profiling/config.js @@ -21,6 +21,7 @@ class Config { const { DD_AGENT_HOST, DD_ENV, + DD_INTERNAL_PROFILING_TIMELINE_SAMPLING_ENABLED, // used for testing DD_PROFILING_CODEHOTSPOTS_ENABLED, DD_PROFILING_CPU_ENABLED, DD_PROFILING_DEBUG_SOURCE_MAPS, @@ -175,6 +176,8 @@ class Config { DD_PROFILING_EXPERIMENTAL_TIMELINE_ENABLED, samplingContextsAvailable)) logExperimentalVarDeprecation('TIMELINE_ENABLED') checkOptionWithSamplingContextAllowed(this.timelineEnabled, 'Timeline view') + this.timelineSamplingEnabled = isTrue(coalesce(options.timelineSamplingEnabled, + DD_INTERNAL_PROFILING_TIMELINE_SAMPLING_ENABLED, true)) this.codeHotspotsEnabled = isTrue(coalesce(options.codeHotspotsEnabled, DD_PROFILING_CODEHOTSPOTS_ENABLED, diff --git a/packages/dd-trace/src/profiling/exporters/agent.js b/packages/dd-trace/src/profiling/exporters/agent.js index 485636ee240..6ad63486a87 100644 --- a/packages/dd-trace/src/profiling/exporters/agent.js +++ b/packages/dd-trace/src/profiling/exporters/agent.js @@ -3,13 +3,13 @@ const retry = require('retry') const { request: httpRequest } = require('http') const { request: httpsRequest } = require('https') +const { EventSerializer } = require('./event_serializer') // TODO: avoid using dd-trace internals. Make this a separate module? const docker = require('../../exporters/common/docker') const FormData = require('../../exporters/common/form-data') const { storage } = require('../../../../datadog-core') const version = require('../../../../../package.json').version -const os = require('os') const { urlToHttpOptions } = require('url') const perf = require('perf_hooks').performance @@ -89,8 +89,10 @@ function computeRetries (uploadTimeout) { return [tries, Math.floor(uploadTimeout)] } -class AgentExporter { - constructor ({ url, logger, uploadTimeout, env, host, service, version, libraryInjected, activation } = {}) { +class AgentExporter extends EventSerializer { + constructor (config = {}) { + super(config) + const { url, logger, uploadTimeout } = config this._url = url this._logger = logger @@ -98,74 +100,13 @@ class AgentExporter { this._backoffTime = backoffTime this._backoffTries = backoffTries - this._env = env - this._host = host - this._service = service - this._appVersion = version - this._libraryInjected = !!libraryInjected - this._activation = activation || 'unknown' } - export ({ profiles, start, end, tags }) { + export (exportSpec) { + const { profiles } = exportSpec const fields = [] - function typeToFile (type) { - return `${type}.pprof` - } - - const event = JSON.stringify({ - attachments: Object.keys(profiles).map(typeToFile), - start: start.toISOString(), - end: end.toISOString(), - family: 'node', - version: '4', - tags_profiler: [ - 'language:javascript', - 'runtime:nodejs', - `runtime_arch:${process.arch}`, - `runtime_os:${process.platform}`, - `runtime_version:${process.version}`, - `process_id:${process.pid}`, - `profiler_version:${version}`, - 'format:pprof', - ...Object.entries(tags).map(([key, value]) => `${key}:${value}`) - ].join(','), - info: { - application: { - env: this._env, - service: this._service, - start_time: new Date(perf.nodeTiming.nodeStart + perf.timeOrigin).toISOString(), - version: this._appVersion - }, - platform: { - hostname: this._host, - kernel_name: os.type(), - kernel_release: os.release(), - kernel_version: os.version() - }, - profiler: { - activation: this._activation, - ssi: { - mechanism: this._libraryInjected ? 'injected_agent' : 'none' - }, - version - }, - runtime: { - // Using `nodejs` for consistency with the existing `runtime` tag. - // Note that the event `family` property uses `node`, as that's what's - // proscribed by the Intake API, but that's an internal enum and is - // not customer visible. - engine: 'nodejs', - // strip off leading 'v'. This makes the format consistent with other - // runtimes (e.g. Ruby) but not with the existing `runtime_version` tag. - // We'll keep it like this as we want cross-engine consistency. We - // also aren't changing the format of the existing tag as we don't want - // to break it. - version: process.version.substring(1) - } - } - }) - + const event = this.getEventJSON(exportSpec) fields.push(['event', event, { filename: 'event.json', contentType: 'application/json' @@ -181,7 +122,7 @@ class AgentExporter { return `Adding ${type} profile to agent export: ` + bytes }) - const filename = typeToFile(type) + const filename = this.typeToFile(type) fields.push([filename, buffer, { filename, contentType: 'application/octet-stream' diff --git a/packages/dd-trace/src/profiling/exporters/event_serializer.js b/packages/dd-trace/src/profiling/exporters/event_serializer.js new file mode 100644 index 00000000000..1bd16ea21bc --- /dev/null +++ b/packages/dd-trace/src/profiling/exporters/event_serializer.js @@ -0,0 +1,76 @@ +const os = require('os') +const perf = require('perf_hooks').performance +const version = require('../../../../../package.json').version + +class EventSerializer { + constructor ({ env, host, service, version, libraryInjected, activation } = {}) { + this._env = env + this._host = host + this._service = service + this._appVersion = version + this._libraryInjected = !!libraryInjected + this._activation = activation || 'unknown' + } + + typeToFile (type) { + return `${type}.pprof` + } + + getEventJSON ({ profiles, start, end, tags = {}, endpointCounts }) { + return JSON.stringify({ + attachments: Object.keys(profiles).map(t => this.typeToFile(t)), + start: start.toISOString(), + end: end.toISOString(), + family: 'node', + version: '4', + tags_profiler: [ + 'language:javascript', + 'runtime:nodejs', + `runtime_arch:${process.arch}`, + `runtime_os:${process.platform}`, + `runtime_version:${process.version}`, + `process_id:${process.pid}`, + `profiler_version:${version}`, + 'format:pprof', + ...Object.entries(tags).map(([key, value]) => `${key}:${value}`) + ].join(','), + endpoint_counts: endpointCounts, + info: { + application: { + env: this._env, + service: this._service, + start_time: new Date(perf.nodeTiming.nodeStart + perf.timeOrigin).toISOString(), + version: this._appVersion + }, + platform: { + hostname: this._host, + kernel_name: os.type(), + kernel_release: os.release(), + kernel_version: os.version() + }, + profiler: { + activation: this._activation, + ssi: { + mechanism: this._libraryInjected ? 'injected_agent' : 'none' + }, + version + }, + runtime: { + // Using `nodejs` for consistency with the existing `runtime` tag. + // Note that the event `family` property uses `node`, as that's what's + // proscribed by the Intake API, but that's an internal enum and is + // not customer visible. + engine: 'nodejs', + // strip off leading 'v'. This makes the format consistent with other + // runtimes (e.g. Ruby) but not with the existing `runtime_version` tag. + // We'll keep it like this as we want cross-engine consistency. We + // also aren't changing the format of the existing tag as we don't want + // to break it. + version: process.version.substring(1) + } + } + }) + } +} + +module.exports = { EventSerializer } diff --git a/packages/dd-trace/src/profiling/exporters/file.js b/packages/dd-trace/src/profiling/exporters/file.js index 724eac4656b..a7b87b2025d 100644 --- a/packages/dd-trace/src/profiling/exporters/file.js +++ b/packages/dd-trace/src/profiling/exporters/file.js @@ -4,6 +4,7 @@ const fs = require('fs') const { promisify } = require('util') const { threadId } = require('worker_threads') const writeFile = promisify(fs.writeFile) +const { EventSerializer } = require('./event_serializer') function formatDateTime (t) { const pad = (n) => String(n).padStart(2, '0') @@ -11,18 +12,21 @@ function formatDateTime (t) { `T${pad(t.getUTCHours())}${pad(t.getUTCMinutes())}${pad(t.getUTCSeconds())}Z` } -class FileExporter { - constructor ({ pprofPrefix } = {}) { +class FileExporter extends EventSerializer { + constructor (config = {}) { + super(config) + const { pprofPrefix } = config this._pprofPrefix = pprofPrefix || '' } - export ({ profiles, end }) { + export (exportSpec) { + const { profiles, end } = exportSpec const types = Object.keys(profiles) const dateStr = formatDateTime(end) const tasks = types.map(type => { return writeFile(`${this._pprofPrefix}${type}_worker_${threadId}_${dateStr}.pprof`, profiles[type]) }) - + tasks.push(writeFile(`event_worker_${threadId}_${dateStr}.json`, this.getEventJSON(exportSpec))) return Promise.all(tasks) } } diff --git a/packages/dd-trace/src/profiling/profiler.js b/packages/dd-trace/src/profiling/profiler.js index 2233de59f84..d02912dde42 100644 --- a/packages/dd-trace/src/profiling/profiler.js +++ b/packages/dd-trace/src/profiling/profiler.js @@ -4,9 +4,11 @@ const { EventEmitter } = require('events') const { Config } = require('./config') const { snapshotKinds } = require('./constants') const { threadNamePrefix } = require('./profilers/shared') +const { isWebServerSpan, endpointNameFromTags, getStartedSpans } = require('./webspan-utils') const dc = require('dc-polyfill') const profileSubmittedChannel = dc.channel('datadog:profiling:profile-submitted') +const spanFinishedChannel = dc.channel('dd-trace:span:finish') function maybeSourceMap (sourceMap, SourceMapper, debug) { if (!sourceMap) return @@ -21,6 +23,20 @@ function logError (logger, err) { } } +function findWebSpan (startedSpans, spanId) { + for (let i = startedSpans.length; --i >= 0;) { + const ispan = startedSpans[i] + const context = ispan.context() + if (context._spanId === spanId) { + if (isWebServerSpan(context._tags)) { + return true + } + spanId = context._parentId + } + } + return false +} + class Profiler extends EventEmitter { constructor () { super() @@ -30,6 +46,7 @@ class Profiler extends EventEmitter { this._timer = undefined this._lastStart = undefined this._timeoutInterval = undefined + this.endpointCounts = new Map() } start (options) { @@ -82,6 +99,11 @@ class Profiler extends EventEmitter { this._logger.debug(`Started ${profiler.type} profiler in ${threadNamePrefix} thread`) } + if (config.endpointCollectionEnabled) { + this._spanFinishListener = this._onSpanFinish.bind(this) + spanFinishedChannel.subscribe(this._spanFinishListener) + } + this._capture(this._timeoutInterval, start) return true } catch (e) { @@ -117,6 +139,11 @@ class Profiler extends EventEmitter { this._enabled = false + if (this._spanFinishListener !== undefined) { + spanFinishedChannel.unsubscribe(this._spanFinishListener) + this._spanFinishListener = undefined + } + for (const profiler of this._config.profilers) { profiler.stop() this._logger.debug(`Stopped ${profiler.type} profiler in ${threadNamePrefix} thread`) @@ -137,6 +164,26 @@ class Profiler extends EventEmitter { } } + _onSpanFinish (span) { + const context = span.context() + const tags = context._tags + if (!isWebServerSpan(tags)) return + + const endpointName = endpointNameFromTags(tags) + if (!endpointName) return + + // Make sure this is the outermost web span, just in case so we don't overcount + if (findWebSpan(getStartedSpans(context), context._parentId)) return + + let counter = this.endpointCounts.get(endpointName) + if (counter === undefined) { + counter = { count: 1 } + this.endpointCounts.set(endpointName, counter) + } else { + counter.count++ + } + } + async _collect (snapshotKind, restart = true) { if (!this._enabled) return @@ -194,18 +241,23 @@ class Profiler extends EventEmitter { _submit (profiles, start, end, snapshotKind) { const { tags } = this._config - const tasks = [] - tags.snapshot = snapshotKind - for (const exporter of this._config.exporters) { - const task = exporter.export({ profiles, start, end, tags }) - .catch(err => { - if (this._logger) { - this._logger.warn(err) - } - }) - tasks.push(task) + // Flatten endpoint counts + const endpointCounts = {} + for (const [endpoint, { count }] of this.endpointCounts) { + endpointCounts[endpoint] = count } + this.endpointCounts.clear() + + tags.snapshot = snapshotKind + const exportSpec = { profiles, start, end, tags, endpointCounts } + const tasks = this._config.exporters.map(exporter => + exporter.export(exportSpec).catch(err => { + if (this._logger) { + this._logger.warn(err) + } + }) + ) return Promise.all(tasks) } diff --git a/packages/dd-trace/src/profiling/profilers/event_plugins/event.js b/packages/dd-trace/src/profiling/profilers/event_plugins/event.js index f47a3468f78..eace600a9aa 100644 --- a/packages/dd-trace/src/profiling/profilers/event_plugins/event.js +++ b/packages/dd-trace/src/profiling/profilers/event_plugins/event.js @@ -1,14 +1,15 @@ -const { AsyncLocalStorage } = require('async_hooks') +const { storage } = require('../../../../../datadog-core') const TracingPlugin = require('../../../plugins/tracing') const { performance } = require('perf_hooks') // We are leveraging the TracingPlugin class for its functionality to bind // start/error/finish methods to the appropriate diagnostic channels. class EventPlugin extends TracingPlugin { - constructor (eventHandler) { + constructor (eventHandler, eventFilter) { super() this.eventHandler = eventHandler - this.store = new AsyncLocalStorage() + this.eventFilter = eventFilter + this.store = storage('profiling') this.entryType = this.constructor.entryType } @@ -20,29 +21,42 @@ class EventPlugin extends TracingPlugin { } error () { - this.store.getStore().error = true + const store = this.store.getStore() + if (store) { + store.error = true + } } finish () { - const { startEvent, startTime, error } = this.store.getStore() - if (error) { - return // don't emit perf events for failed operations - } - const duration = performance.now() - startTime + const store = this.store.getStore() + if (!store) return - const context = this.activeSpan?.context() - const _ddSpanId = context?.toSpanId() - const _ddRootSpanId = context?._trace.started[0]?.context().toSpanId() || _ddSpanId + const { startEvent, startTime, error } = store + if (error || this.ignoreEvent(startEvent)) { + return // don't emit perf events for failed operations or ignored events + } + const duration = performance.now() - startTime const event = { entryType: this.entryType, startTime, - duration, - _ddSpanId, - _ddRootSpanId + duration } + + if (!this.eventFilter(event)) { + return + } + + const context = this.activeSpan?.context() + event._ddSpanId = context?.toSpanId() + event._ddRootSpanId = context?._trace.started[0]?.context().toSpanId() || event._ddSpanId + this.eventHandler(this.extendEvent(event, startEvent)) } + + ignoreEvent () { + return false + } } module.exports = EventPlugin diff --git a/packages/dd-trace/src/profiling/profilers/event_plugins/fs.js b/packages/dd-trace/src/profiling/profilers/event_plugins/fs.js new file mode 100644 index 00000000000..34eb7b52353 --- /dev/null +++ b/packages/dd-trace/src/profiling/profilers/event_plugins/fs.js @@ -0,0 +1,49 @@ +const EventPlugin = require('./event') + +// Values taken from parameter names in datadog-instrumentations/src/fs.js. +// Known param names that are disallowed because they can be strings and have arbitrary sizes: +// 'data' +// Known param names that are disallowed because they are never a string or number: +// 'buffer', 'buffers', 'listener' +const allowedParams = new Set([ + 'atime', 'dest', + 'existingPath', 'fd', 'file', + 'flag', 'gid', 'len', + 'length', 'mode', 'mtime', + 'newPath', 'offset', 'oldPath', + 'operation', 'options', 'path', + 'position', 'prefix', 'src', + 'target', 'type', 'uid' +]) + +class FilesystemPlugin extends EventPlugin { + static get id () { + return 'fs' + } + + static get operation () { + return 'operation' + } + + static get entryType () { + return 'fs' + } + + ignoreEvent (event) { + // Don't care about sync events, they show up in the event loop samples anyway + return event.operation?.endsWith('Sync') + } + + extendEvent (event, detail) { + const d = { ...detail } + Object.entries(d).forEach(([k, v]) => { + if (!(allowedParams.has(k) && (typeof v === 'string' || typeof v === 'number'))) { + delete d[k] + } + }) + event.detail = d + + return event + } +} +module.exports = FilesystemPlugin diff --git a/packages/dd-trace/src/profiling/profilers/events.js b/packages/dd-trace/src/profiling/profilers/events.js index f8f43b06a9a..8ff1748ceda 100644 --- a/packages/dd-trace/src/profiling/profilers/events.js +++ b/packages/dd-trace/src/profiling/profilers/events.js @@ -133,11 +133,32 @@ class NetDecorator { } } +class FilesystemDecorator { + constructor (stringTable) { + this.stringTable = stringTable + } + + decorateSample (sampleInput, item) { + const labels = sampleInput.label + const stringTable = this.stringTable + Object.entries(item.detail).forEach(([k, v]) => { + switch (typeof v) { + case 'string': + labels.push(labelFromStrStr(stringTable, k, v)) + break + case 'number': + labels.push(new Label({ key: stringTable.dedup(k), num: v })) + } + }) + } +} + // Keys correspond to PerformanceEntry.entryType, values are constructor // functions for type-specific decorators. const decoratorTypes = { - gc: GCDecorator, + fs: FilesystemDecorator, dns: DNSDecorator, + gc: GCDecorator, net: NetDecorator } @@ -254,10 +275,10 @@ class NodeApiEventSource { } class DatadogInstrumentationEventSource { - constructor (eventHandler) { - this.plugins = ['dns_lookup', 'dns_lookupservice', 'dns_resolve', 'dns_reverse', 'net'].map(m => { + constructor (eventHandler, eventFilter) { + this.plugins = ['dns_lookup', 'dns_lookupservice', 'dns_resolve', 'dns_reverse', 'fs', 'net'].map(m => { const Plugin = require(`./event_plugins/${m}`) - return new Plugin(eventHandler) + return new Plugin(eventHandler, eventFilter) }) this.started = false @@ -292,29 +313,68 @@ class CompositeEventSource { } } +function createPossionProcessSamplingFilter (samplingIntervalMillis) { + let nextSamplingInstant = performance.now() + let currentSamplingInstant = 0 + setNextSamplingInstant() + + return event => { + const endTime = event.startTime + event.duration + while (endTime >= nextSamplingInstant) { + setNextSamplingInstant() + } + // An event is sampled if it started before, and ended on or after a sampling instant. The above + // while loop will ensure that the ending invariant is always true for the current sampling + // instant so we don't have to test for it below. Across calls, the invariant also holds as long + // as the events arrive in endTime order. This is true for events coming from + // DatadogInstrumentationEventSource; they will be ordered by endTime by virtue of this method + // being invoked synchronously with the plugins' finish() handler which evaluates + // performance.now(). OTOH, events coming from NodeAPIEventSource (GC in typical setup) might be + // somewhat delayed as they are queued by Node, so they can arrive out of order with regard to + // events coming from the non-queued source. By omitting the endTime check, we will pass through + // some short events that started and ended before the current sampling instant. OTOH, if we + // were to check for this.currentSamplingInstant <= endTime, we would discard some long events + // that also ended before the current sampling instant. We'd rather err on the side of including + // some short events than excluding some long events. + return event.startTime < currentSamplingInstant + } + + function setNextSamplingInstant () { + currentSamplingInstant = nextSamplingInstant + nextSamplingInstant -= Math.log(1 - Math.random()) * samplingIntervalMillis + } +} + /** * This class generates pprof files with timeline events. It combines an event - * source with an event serializer. + * source with a sampling event filter and an event serializer. */ class EventsProfiler { constructor (options = {}) { this.type = 'events' this.eventSerializer = new EventSerializer() - const eventHandler = event => { - this.eventSerializer.addEvent(event) + const eventHandler = event => this.eventSerializer.addEvent(event) + const eventFilter = options.timelineSamplingEnabled + // options.samplingInterval comes in microseconds, we need millis + ? createPossionProcessSamplingFilter((options.samplingInterval ?? 1e6 / 99) / 1000) + : _ => true + const filteringEventHandler = event => { + if (eventFilter(event)) { + eventHandler(event) + } } if (options.codeHotspotsEnabled) { // Use Datadog instrumentation to collect events with span IDs. Still use // Node API for GC events. this.eventSource = new CompositeEventSource([ - new DatadogInstrumentationEventSource(eventHandler), - new NodeApiEventSource(eventHandler, ['gc']) + new DatadogInstrumentationEventSource(eventHandler, eventFilter), + new NodeApiEventSource(filteringEventHandler, ['gc']) ]) } else { // Use Node API instrumentation to collect events without span IDs - this.eventSource = new NodeApiEventSource(eventHandler) + this.eventSource = new NodeApiEventSource(filteringEventHandler) } } diff --git a/packages/dd-trace/src/profiling/profilers/wall.js b/packages/dd-trace/src/profiling/profilers/wall.js index dc3c0ba61ba..bcc7959074f 100644 --- a/packages/dd-trace/src/profiling/profilers/wall.js +++ b/packages/dd-trace/src/profiling/profilers/wall.js @@ -3,8 +3,6 @@ const { storage } = require('../../../../datadog-core') const dc = require('dc-polyfill') -const { HTTP_METHOD, HTTP_ROUTE, RESOURCE_NAME, SPAN_TYPE } = require('../../../../../ext/tags') -const { WEB } = require('../../../../../ext/types') const runtimeMetrics = require('../../runtime_metrics') const telemetryMetrics = require('../../telemetry/metrics') const { @@ -15,6 +13,8 @@ const { getThreadLabels } = require('./shared') +const { isWebServerSpan, endpointNameFromTags, getStartedSpans } = require('../webspan-utils') + const beforeCh = dc.channel('dd-trace:storage:before') const enterCh = dc.channel('dd-trace:storage:enter') const spanFinishCh = dc.channel('dd-trace:span:finish') @@ -29,21 +29,6 @@ function getActiveSpan () { return store && store.span } -function getStartedSpans (context) { - return context._trace.started -} - -function isWebServerSpan (tags) { - return tags[SPAN_TYPE] === WEB -} - -function endpointNameFromTags (tags) { - return tags[RESOURCE_NAME] || [ - tags[HTTP_METHOD], - tags[HTTP_ROUTE] - ].filter(v => v).join(' ') -} - let channelsActivated = false function ensureChannelsActivated () { if (channelsActivated) return diff --git a/packages/dd-trace/src/profiling/webspan-utils.js b/packages/dd-trace/src/profiling/webspan-utils.js new file mode 100644 index 00000000000..d002dcd2705 --- /dev/null +++ b/packages/dd-trace/src/profiling/webspan-utils.js @@ -0,0 +1,23 @@ +const { HTTP_METHOD, HTTP_ROUTE, RESOURCE_NAME, SPAN_TYPE } = require('../../../../ext/tags') +const { WEB } = require('../../../../ext/types') + +function isWebServerSpan (tags) { + return tags[SPAN_TYPE] === WEB +} + +function endpointNameFromTags (tags) { + return tags[RESOURCE_NAME] || [ + tags[HTTP_METHOD], + tags[HTTP_ROUTE] + ].filter(v => v).join(' ') +} + +function getStartedSpans (context) { + return context._trace.started +} + +module.exports = { + isWebServerSpan, + endpointNameFromTags, + getStartedSpans +} diff --git a/packages/dd-trace/src/proxy.js b/packages/dd-trace/src/proxy.js index 5c113399601..fd814c9d6e3 100644 --- a/packages/dd-trace/src/proxy.js +++ b/packages/dd-trace/src/proxy.js @@ -181,8 +181,13 @@ class Tracer extends NoopProxy { ) } } + + if (config.isTestDynamicInstrumentationEnabled) { + const testVisibilityDynamicInstrumentation = require('./ci-visibility/dynamic-instrumentation') + testVisibilityDynamicInstrumentation.start() + } } catch (e) { - log.error(e) + log.error('Error initialising tracer', e) } return this @@ -193,7 +198,7 @@ class Tracer extends NoopProxy { try { return require('./profiler').start(config) } catch (e) { - log.error(e) + log.error('Error starting profiler', e) } } diff --git a/packages/dd-trace/src/runtime_metrics.js b/packages/dd-trace/src/runtime_metrics.js index b2711879a05..f16b227ca18 100644 --- a/packages/dd-trace/src/runtime_metrics.js +++ b/packages/dd-trace/src/runtime_metrics.js @@ -7,11 +7,19 @@ const os = require('os') const { DogStatsDClient } = require('./dogstatsd') const log = require('./log') const Histogram = require('./histogram') -const { performance } = require('perf_hooks') +const { performance, PerformanceObserver } = require('perf_hooks') +const { NODE_MAJOR, NODE_MINOR } = require('../../../version') const INTERVAL = 10 * 1000 +// Node >=16 has PerformanceObserver with `gc` type, but <16.7 had a critical bug. +// See: https://github.com/nodejs/node/issues/39548 +const hasGCObserver = NODE_MAJOR >= 18 || (NODE_MAJOR === 16 && NODE_MINOR >= 7) +const hasGCProfiler = NODE_MAJOR >= 20 || (NODE_MAJOR === 18 && NODE_MINOR >= 15) + let nativeMetrics = null +let gcObserver = null +let gcProfiler = null let interval let client @@ -24,15 +32,20 @@ let elu reset() -module.exports = { +const runtimeMetrics = module.exports = { start (config) { const clientConfig = DogStatsDClient.generateClientConfig(config) try { nativeMetrics = require('@datadog/native-metrics') - nativeMetrics.start() + + if (hasGCObserver) { + nativeMetrics.start('loop') // Only add event loop watcher and not GC. + } else { + nativeMetrics.start() + } } catch (e) { - log.error(e) + log.error('Error starting native metrics', e) nativeMetrics = null } @@ -40,6 +53,9 @@ module.exports = { time = process.hrtime() + startGCObserver() + startGCProfiler() + if (nativeMetrics) { interval = setInterval(() => { captureCommonMetrics() @@ -138,6 +154,10 @@ function reset () { counters = {} histograms = {} nativeMetrics = null + gcObserver && gcObserver.disconnect() + gcObserver = null + gcProfiler && gcProfiler.stop() + gcProfiler = null } function captureCpuUsage () { @@ -202,6 +222,29 @@ function captureHeapSpace () { client.gauge('runtime.node.heap.physical_size.by.space', stats[i].physical_space_size, tags) } } +function captureGCMetrics () { + if (!gcProfiler) return + + const profile = gcProfiler.stop() + const pauseAll = new Histogram() + const pause = {} + + for (const stat of profile.statistics) { + const type = stat.gcType.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase() + + pause[type] = pause[type] || new Histogram() + pause[type].record(stat.cost) + pauseAll.record(stat.cost) + } + + histogram('runtime.node.gc.pause', pauseAll) + + for (const type in pause) { + histogram('runtime.node.gc.pause.by.type', pause[type], [`gc_type:${type}`]) + } + + gcProfiler.start() +} function captureGauges () { Object.keys(gauges).forEach(name => { @@ -256,6 +299,7 @@ function captureCommonMetrics () { captureCounters() captureHistograms() captureELU() + captureGCMetrics() } function captureNativeMetrics () { @@ -297,6 +341,11 @@ function captureNativeMetrics () { function histogram (name, stats, tags) { tags = [].concat(tags) + // Stats can contain garbage data when a value was never recorded. + if (stats.count === 0) { + stats = { max: 0, min: 0, sum: 0, avg: 0, median: 0, p95: 0, count: 0 } + } + client.gauge(`${name}.min`, stats.min, tags) client.gauge(`${name}.max`, stats.max, tags) client.increment(`${name}.sum`, stats.sum, tags) @@ -306,3 +355,57 @@ function histogram (name, stats, tags) { client.gauge(`${name}.median`, stats.median, tags) client.gauge(`${name}.95percentile`, stats.p95, tags) } + +function startGCObserver () { + if (gcObserver || hasGCProfiler || !hasGCObserver) return + + gcObserver = new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + const type = gcType(entry.detail?.kind || entry.kind) + + runtimeMetrics.histogram('runtime.node.gc.pause.by.type', entry.duration, `gc_type:${type}`) + runtimeMetrics.histogram('runtime.node.gc.pause', entry.duration) + } + }) + + gcObserver.observe({ type: 'gc' }) +} + +function startGCProfiler () { + if (gcProfiler || !hasGCProfiler) return + + gcProfiler = new v8.GCProfiler() + gcProfiler.start() +} + +function gcType (kind) { + if (NODE_MAJOR >= 22) { + switch (kind) { + case 1: return 'scavenge' + case 2: return 'minor_mark_sweep' + case 4: return 'mark_sweep_compact' // Deprecated, might be removed soon. + case 8: return 'incremental_marking' + case 16: return 'process_weak_callbacks' + case 31: return 'all' + } + } else if (NODE_MAJOR >= 18) { + switch (kind) { + case 1: return 'scavenge' + case 2: return 'minor_mark_compact' + case 4: return 'mark_sweep_compact' + case 8: return 'incremental_marking' + case 16: return 'process_weak_callbacks' + case 31: return 'all' + } + } else { + switch (kind) { + case 1: return 'scavenge' + case 2: return 'mark_sweep_compact' + case 4: return 'incremental_marking' + case 8: return 'process_weak_callbacks' + case 15: return 'all' + } + } + + return 'unknown' +} diff --git a/packages/dd-trace/src/serverless.js b/packages/dd-trace/src/serverless.js index d352cae899e..415df38fc2c 100644 --- a/packages/dd-trace/src/serverless.js +++ b/packages/dd-trace/src/serverless.js @@ -23,7 +23,7 @@ function maybeStartServerlessMiniAgent (config) { try { require('child_process').spawn(rustBinaryPath, { stdio: 'inherit' }) } catch (err) { - log.error(`Error spawning mini agent process: ${err}`) + log.error('Error spawning mini agent process: %s', err.message) } } diff --git a/packages/dd-trace/src/span_processor.js b/packages/dd-trace/src/span_processor.js index deb92c02f34..46cf51b162b 100644 --- a/packages/dd-trace/src/span_processor.js +++ b/packages/dd-trace/src/span_processor.js @@ -87,22 +87,22 @@ class SpanProcessor { const id = context.toSpanId() if (finished.has(span)) { - log.error(`Span was already finished in the same trace: ${span}`) + log.error('Span was already finished in the same trace: %s', span) } else { finished.add(span) if (finishedIds.has(id)) { - log.error(`Another span with the same ID was already finished in the same trace: ${span}`) + log.error('Another span with the same ID was already finished in the same trace: %s', span) } else { finishedIds.add(id) } if (context._trace !== trace) { - log.error(`A span was finished in the wrong trace: ${span}.`) + log.error('A span was finished in the wrong trace: %s', span) } if (finishedSpans.has(span)) { - log.error(`Span was already finished in a different trace: ${span}`) + log.error('Span was already finished in a different trace: %s', span) } else { finishedSpans.add(span) } @@ -114,35 +114,35 @@ class SpanProcessor { const id = context.toSpanId() if (started.has(span)) { - log.error(`Span was already started in the same trace: ${span}`) + log.error('Span was already started in the same trace: %s', span) } else { started.add(span) if (startedIds.has(id)) { - log.error(`Another span with the same ID was already started in the same trace: ${span}`) + log.error('Another span with the same ID was already started in the same trace: %s', span) } else { startedIds.add(id) } if (context._trace !== trace) { - log.error(`A span was started in the wrong trace: ${span}.`) + log.error('A span was started in the wrong trace: %s', span) } if (startedSpans.has(span)) { - log.error(`Span was already started in a different trace: ${span}`) + log.error('Span was already started in a different trace: %s', span) } else { startedSpans.add(span) } } if (!finished.has(span)) { - log.error(`Span started in one trace but was finished in another trace: ${span}`) + log.error('Span started in one trace but was finished in another trace: %s', span) } } for (const span of trace.finished) { if (!started.has(span)) { - log.error(`Span finished in one trace but was started in another trace: ${span}`) + log.error('Span finished in one trace but was started in another trace: %s', span) } } } diff --git a/packages/dd-trace/src/tagger.js b/packages/dd-trace/src/tagger.js index 41c8616a086..bbd8a187940 100644 --- a/packages/dd-trace/src/tagger.js +++ b/packages/dd-trace/src/tagger.js @@ -44,7 +44,7 @@ function add (carrier, keyValuePairs, parseOtelTags = false) { Object.assign(carrier, keyValuePairs) } } catch (e) { - log.error(e) + log.error('Error adding tags', e) } } diff --git a/packages/dd-trace/src/telemetry/index.js b/packages/dd-trace/src/telemetry/index.js index 5df7d6fcae3..eb1fe376c67 100644 --- a/packages/dd-trace/src/telemetry/index.js +++ b/packages/dd-trace/src/telemetry/index.js @@ -137,6 +137,7 @@ function appClosing () { sendData(config, application, host, reqType, payload) // We flush before shutting down. metricsManager.send(config, application, host) + telemetryLogger.send(config, application, host) } function onBeforeExit () { diff --git a/packages/dd-trace/src/telemetry/init-telemetry.js b/packages/dd-trace/src/telemetry/init-telemetry.js deleted file mode 100644 index a126ecc6238..00000000000 --- a/packages/dd-trace/src/telemetry/init-telemetry.js +++ /dev/null @@ -1,75 +0,0 @@ -'use strict' - -const fs = require('fs') -const { spawn } = require('child_process') -const tracerVersion = require('../../../../package.json').version -const log = require('../log') - -module.exports = sendTelemetry - -if (!process.env.DD_INJECTION_ENABLED) { - module.exports = () => {} -} - -if (!process.env.DD_TELEMETRY_FORWARDER_PATH) { - module.exports = () => {} -} - -if (!fs.existsSync(process.env.DD_TELEMETRY_FORWARDER_PATH)) { - module.exports = () => {} -} - -const metadata = { - language_name: 'nodejs', - language_version: process.versions.node, - runtime_name: 'nodejs', - runtime_version: process.versions.node, - tracer_version: tracerVersion, - pid: process.pid -} - -const seen = [] -function hasSeen (point) { - if (point.name === 'abort') { - // This one can only be sent once, regardless of tags - return seen.includes('abort') - } - if (point.name === 'abort.integration') { - // For now, this is the only other one we want to dedupe - const compiledPoint = point.name + point.tags.join('') - return seen.includes(compiledPoint) - } - return false -} - -function sendTelemetry (name, tags = []) { - let points = name - if (typeof name === 'string') { - points = [{ name, tags }] - } - if (['1', 'true', 'True'].includes(process.env.DD_INJECT_FORCE)) { - points = points.filter(p => ['error', 'complete'].includes(p.name)) - } - points = points.filter(p => !hasSeen(p)) - points.forEach(p => { - p.name = `library_entrypoint.${p.name}` - }) - if (points.length === 0) { - return - } - const proc = spawn(process.env.DD_TELEMETRY_FORWARDER_PATH, ['library_entrypoint'], { - stdio: 'pipe' - }) - proc.on('error', () => { - log.error('Failed to spawn telemetry forwarder') - }) - proc.on('exit', (code) => { - if (code !== 0) { - log.error(`Telemetry forwarder exited with code ${code}`) - } - }) - proc.stdin.on('error', () => { - log.error('Failed to write telemetry data to telemetry forwarder') - }) - proc.stdin.end(JSON.stringify({ metadata, points })) -} diff --git a/packages/dd-trace/src/telemetry/logs/index.js b/packages/dd-trace/src/telemetry/logs/index.js index c535acb9cdd..199b5fb7943 100644 --- a/packages/dd-trace/src/telemetry/logs/index.js +++ b/packages/dd-trace/src/telemetry/logs/index.js @@ -40,6 +40,7 @@ function onErrorLog (msg) { const telLog = { level: 'ERROR', + count: 1, // existing log.error(err) without message will be reported as 'Generic Error' message: message ?? 'Generic Error' @@ -47,8 +48,7 @@ function onErrorLog (msg) { if (cause) { telLog.stack_trace = cause.stack - const errorType = cause.name ?? 'Error' - telLog.message = `${errorType}: ${telLog.message}` + telLog.errorType = cause.constructor.name } onLog(telLog) diff --git a/packages/dd-trace/src/telemetry/logs/log-collector.js b/packages/dd-trace/src/telemetry/logs/log-collector.js index 9103fd1c47d..a2ee9d06f4a 100644 --- a/packages/dd-trace/src/telemetry/logs/log-collector.js +++ b/packages/dd-trace/src/telemetry/logs/log-collector.js @@ -3,7 +3,7 @@ const log = require('../../log') const { calculateDDBasePath } = require('../../util') -const logs = new Map() +const logs = new Map() // hash -> log // NOTE: Is this a reasonable number? let maxEntries = 10000 @@ -47,8 +47,14 @@ function sanitize (logEntry) { .filter((line, index) => (isDDCode && index < firstIndex) || line.includes(ddBasePath)) .map(line => line.replace(ddBasePath, '')) + if (!isDDCode && logEntry.errorType && stackLines.length) { + stackLines = [`${logEntry.errorType}: redacted`, ...stackLines] + } + + delete logEntry.errorType + logEntry.stack_trace = stackLines.join(EOL) - if (logEntry.stack_trace === '' && !logEntry.message) { + if (logEntry.stack_trace === '' && (!logEntry.message || logEntry.message === 'Generic Error')) { // If entire stack was removed and there is no message we'd rather not log it at all. return null } @@ -75,6 +81,8 @@ const logCollector = { if (!logs.has(hash)) { logs.set(hash, logEntry) return true + } else { + logs.get(hash).count++ } } catch (e) { log.error('Unable to add log to logCollector: %s', e.message) diff --git a/packages/dd-trace/src/telemetry/send-data.js b/packages/dd-trace/src/telemetry/send-data.js index 813fa427812..81406910c27 100644 --- a/packages/dd-trace/src/telemetry/send-data.js +++ b/packages/dd-trace/src/telemetry/send-data.js @@ -57,7 +57,7 @@ function sendData (config, application, host, reqType, payload = {}, cb = () => try { url = url || new URL(getAgentlessTelemetryEndpoint(config.site)) } catch (err) { - log.error(err) + log.error('Telemetry endpoint url is invalid', err) // No point to do the request if the URL is invalid return cb(err, { payload, reqType }) } @@ -100,7 +100,7 @@ function sendData (config, application, host, reqType, payload = {}, cb = () => path: '/api/v2/apmtelemetry' } if (backendUrl) { - request(data, backendOptions, (error) => { log.error(error) }) + request(data, backendOptions, (error) => { log.error('Error sending telemetry data', error) }) } else { log.error('Invalid Telemetry URL') } diff --git a/packages/dd-trace/src/util.js b/packages/dd-trace/src/util.js index e4aa29c076c..de3618fcd27 100644 --- a/packages/dd-trace/src/util.js +++ b/packages/dd-trace/src/util.js @@ -1,6 +1,5 @@ 'use strict' -const crypto = require('crypto') const path = require('path') function isTrue (str) { @@ -25,6 +24,8 @@ function isError (value) { // Matches a glob pattern to a given subject string function globMatch (pattern, subject) { + if (typeof pattern === 'string') pattern = pattern.toLowerCase() + if (typeof subject === 'string') subject = subject.toLowerCase() let px = 0 // [p]attern inde[x] let sx = 0 // [s]ubject inde[x] let nextPx = 0 @@ -64,6 +65,8 @@ function globMatch (pattern, subject) { return true } +// TODO: this adds stack traces relative to packages/ +// shouldn't paths be relative to the root of dd-trace? function calculateDDBasePath (dirname) { const dirSteps = dirname.split(path.sep) const packagesIndex = dirSteps.lastIndexOf('packages') @@ -74,25 +77,11 @@ function hasOwn (object, prop) { return Object.prototype.hasOwnProperty.call(object, prop) } -/** - * Generates a unique hash from an array of strings by joining them with | before hashing. - * Used to uniquely identify AWS requests for span pointers. - * @param {string[]} components - Array of strings to hash - * @returns {string} A 32-character hash uniquely identifying the components - */ -function generatePointerHash (components) { - // If passing S3's ETag as a component, make sure any quotes have already been removed! - const dataToHash = components.join('|') - const hash = crypto.createHash('sha256').update(dataToHash).digest('hex') - return hash.substring(0, 32) -} - module.exports = { isTrue, isFalse, isError, globMatch, calculateDDBasePath, - hasOwn, - generatePointerHash + hasOwn } diff --git a/packages/dd-trace/test/appsec/attacker-fingerprinting.express.plugin.spec.js b/packages/dd-trace/test/appsec/attacker-fingerprinting.express.plugin.spec.js index bc7c918965c..112d634cca9 100644 --- a/packages/dd-trace/test/appsec/attacker-fingerprinting.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/attacker-fingerprinting.express.plugin.spec.js @@ -8,72 +8,74 @@ const agent = require('../plugins/agent') const appsec = require('../../src/appsec') const Config = require('../../src/config') -describe('Attacker fingerprinting', () => { - let port, server +withVersions('express', 'express', expressVersion => { + describe('Attacker fingerprinting', () => { + let port, server - before(() => { - return agent.load(['express', 'http'], { client: false }) - }) + before(() => { + return agent.load(['express', 'http'], { client: false }) + }) - before((done) => { - const express = require('../../../../versions/express').get() - const bodyParser = require('../../../../versions/body-parser').get() + before((done) => { + const express = require(`../../../../versions/express@${expressVersion}`).get() + const bodyParser = require('../../../../versions/body-parser').get() - const app = express() - app.use(bodyParser.json()) + const app = express() + app.use(bodyParser.json()) - app.post('/', (req, res) => { - res.end('DONE') - }) + app.post('/', (req, res) => { + res.end('DONE') + }) - server = app.listen(port, () => { - port = server.address().port - done() + server = app.listen(port, () => { + port = server.address().port + done() + }) }) - }) - after(() => { - server.close() - return agent.close({ ritmReset: false }) - }) + after(() => { + server.close() + return agent.close({ ritmReset: false }) + }) - beforeEach(() => { - appsec.enable(new Config( - { - appsec: { - enabled: true, - rules: path.join(__dirname, 'attacker-fingerprinting-rules.json') + beforeEach(() => { + appsec.enable(new Config( + { + appsec: { + enabled: true, + rules: path.join(__dirname, 'attacker-fingerprinting-rules.json') + } } - } - )) - }) + )) + }) - afterEach(() => { - appsec.disable() - }) + afterEach(() => { + appsec.disable() + }) - it('should report http fingerprints', async () => { - await axios.post( - `http://localhost:${port}/?key=testattack`, - { - bodyParam: 'bodyValue' - }, - { - headers: { - headerName: 'headerValue', - 'x-real-ip': '255.255.255.255' + it('should report http fingerprints', async () => { + await axios.post( + `http://localhost:${port}/?key=testattack`, + { + bodyParam: 'bodyValue' + }, + { + headers: { + headerName: 'headerValue', + 'x-real-ip': '255.255.255.255' + } } - } - ) + ) - await agent.use((traces) => { - const span = traces[0][0] - assert.property(span.meta, '_dd.appsec.fp.http.header') - assert.equal(span.meta['_dd.appsec.fp.http.header'], 'hdr-0110000110-6431a3e6-5-55682ec1') - assert.property(span.meta, '_dd.appsec.fp.http.network') - assert.equal(span.meta['_dd.appsec.fp.http.network'], 'net-1-0100000000') - assert.property(span.meta, '_dd.appsec.fp.http.endpoint') - assert.equal(span.meta['_dd.appsec.fp.http.endpoint'], 'http-post-8a5edab2-2c70e12b-be31090f') + await agent.use((traces) => { + const span = traces[0][0] + assert.property(span.meta, '_dd.appsec.fp.http.header') + assert.equal(span.meta['_dd.appsec.fp.http.header'], 'hdr-0110000110-6431a3e6-5-55682ec1') + assert.property(span.meta, '_dd.appsec.fp.http.network') + assert.equal(span.meta['_dd.appsec.fp.http.network'], 'net-1-0100000000') + assert.property(span.meta, '_dd.appsec.fp.http.endpoint') + assert.equal(span.meta['_dd.appsec.fp.http.endpoint'], 'http-post-8a5edab2-2c70e12b-be31090f') + }) }) }) }) diff --git a/packages/dd-trace/test/appsec/iast/analyzers/code-injection-analyzer.express.plugin.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/code-injection-analyzer.express.plugin.spec.js index 4177dc78aba..64e15b9161b 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/code-injection-analyzer.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/code-injection-analyzer.express.plugin.spec.js @@ -6,6 +6,10 @@ const path = require('path') const os = require('os') const fs = require('fs') const { clearCache } = require('../../../../src/appsec/iast/vulnerability-reporter') +const { newTaintedString } = require('../../../../src/appsec/iast/taint-tracking/operations') +const { SQL_ROW_VALUE } = require('../../../../src/appsec/iast/taint-tracking/source-types') +const { storage } = require('../../../../../datadog-core') +const iastContextFunctions = require('../../../../src/appsec/iast/iast-context') describe('Code injection vulnerability', () => { withVersions('express', 'express', '>4.18.0', version => { @@ -29,7 +33,6 @@ describe('Code injection vulnerability', () => { (testThatRequestHasVulnerability, testThatRequestHasNoVulnerability) => { testThatRequestHasVulnerability({ fn: (req, res) => { - // eslint-disable-next-line no-eval res.send(require(evalFunctionsPath).runEval(req.query.script, 'test-result')) }, vulnerability: 'CODE_INJECTION', @@ -42,6 +45,19 @@ describe('Code injection vulnerability', () => { } }) + testThatRequestHasVulnerability({ + fn: (req, res) => { + const source = '1 + 2' + const store = storage.getStore() + const iastContext = iastContextFunctions.getIastContext(store) + const str = newTaintedString(iastContext, source, 'param', SQL_ROW_VALUE) + + res.send(require(evalFunctionsPath).runEval(str, 'test-result')) + }, + vulnerability: 'CODE_INJECTION', + testDescription: 'Should detect CODE_INJECTION vulnerability with DB source' + }) + testThatRequestHasNoVulnerability({ fn: (req, res) => { res.send('' + require(evalFunctionsPath).runFakeEval(req.query.script)) diff --git a/packages/dd-trace/test/appsec/iast/analyzers/hardcoded-password-analyzer.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/hardcoded-password-analyzer.spec.js index fdc51ce0153..e20c83ef33d 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/hardcoded-password-analyzer.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/hardcoded-password-analyzer.spec.js @@ -1,4 +1,4 @@ -/* eslint-disable max-len */ +/* eslint-disable @stylistic/js/max-len */ 'use strict' const path = require('path') diff --git a/packages/dd-trace/test/appsec/iast/analyzers/ldap-injection-analyzer.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/ldap-injection-analyzer.spec.js index 59413db0a4f..c8af2de6846 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/ldap-injection-analyzer.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/ldap-injection-analyzer.spec.js @@ -1,14 +1,27 @@ 'use strict' const proxyquire = require('proxyquire') +const { HTTP_REQUEST_PARAMETER } = require('../../../../src/appsec/iast/taint-tracking/source-types') describe('ldap-injection-analyzer', () => { const NOT_TAINTED_QUERY = 'no vulnerable query' const TAINTED_QUERY = 'vulnerable query' const TaintTrackingMock = { - isTainted: (iastContext, string) => { + getRanges: (iastContext, string) => { return string === TAINTED_QUERY + ? [ + { + start: 0, + end: string.length, + iinfo: { + parameterName: 'param', + parameterValue: string, + type: HTTP_REQUEST_PARAMETER + } + } + ] + : [] } } diff --git a/packages/dd-trace/test/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.express-mongo-sanitize.plugin.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.express-mongo-sanitize.plugin.spec.js index e05537ce04b..f1042142100 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.express-mongo-sanitize.plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.express-mongo-sanitize.plugin.spec.js @@ -9,7 +9,8 @@ const { prepareTestServerForIastInExpress } = require('../utils') const agent = require('../../../plugins/agent') describe('nosql injection detection in mongodb - whole feature', () => { - withVersions('express', 'express', '>4.18.0', expressVersion => { + // https://github.com/fiznool/express-mongo-sanitize/issues/200 + withVersions('mongodb', 'express', '>4.18.0 <5.0.0', expressVersion => { withVersions('mongodb', 'mongodb', mongodbVersion => { const mongodb = require(`../../../../../../versions/mongodb@${mongodbVersion}`) diff --git a/packages/dd-trace/test/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.mongoose.plugin.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.mongoose.plugin.spec.js index f09264225a9..75337c63b3f 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.mongoose.plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.mongoose.plugin.spec.js @@ -10,7 +10,7 @@ const fs = require('fs') const { NODE_MAJOR } = require('../../../../../../version') describe('nosql injection detection in mongodb - whole feature', () => { - withVersions('express', 'express', '>4.18.0', expressVersion => { + withVersions('mongoose', 'express', expressVersion => { withVersions('mongoose', 'mongoose', '>4.0.0', mongooseVersion => { const specificMongooseVersion = require(`../../../../../../versions/mongoose@${mongooseVersion}`).version() if (NODE_MAJOR === 14 && semver.satisfies(specificMongooseVersion, '>=8')) return @@ -27,11 +27,16 @@ describe('nosql injection detection in mongodb - whole feature', () => { const dbName = id().toString() mongoose = require(`../../../../../../versions/mongoose@${mongooseVersion}`).get() - mongoose.connect(`mongodb://localhost:27017/${dbName}`, { + await mongoose.connect(`mongodb://localhost:27017/${dbName}`, { useNewUrlParser: true, useUnifiedTopology: true }) + if (mongoose.models.Test) { + delete mongoose.models?.Test + delete mongoose.modelSchemas?.Test + } + Test = mongoose.model('Test', { name: String }) const src = path.join(__dirname, 'resources', vulnerableMethodFilename) @@ -46,7 +51,12 @@ describe('nosql injection detection in mongodb - whole feature', () => { }) after(() => { - fs.unlinkSync(tmpFilePath) + try { + fs.unlinkSync(tmpFilePath) + } catch (e) { + // ignore the error + } + return mongoose.disconnect() }) diff --git a/packages/dd-trace/test/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.mquery.plugin.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.mquery.plugin.spec.js index 7cf71f7a86e..a91b428211c 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.mquery.plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.mquery.plugin.spec.js @@ -9,7 +9,8 @@ const semver = require('semver') const fs = require('fs') describe('nosql injection detection with mquery', () => { - withVersions('express', 'express', '>4.18.0', expressVersion => { + // https://github.com/fiznool/express-mongo-sanitize/issues/200 + withVersions('mongodb', 'express', '>4.18.0 <5.0.0', expressVersion => { withVersions('mongodb', 'mongodb', mongodbVersion => { const mongodb = require(`../../../../../../versions/mongodb@${mongodbVersion}`) @@ -316,7 +317,7 @@ describe('nosql injection detection with mquery', () => { withVersions('express-mongo-sanitize', 'express-mongo-sanitize', expressMongoSanitizeVersion => { prepareTestServerForIastInExpress('Test with sanitization middleware', expressVersion, (expressApp) => { const mongoSanitize = - require(`../../../../../../versions/express-mongo-sanitize@${expressMongoSanitizeVersion}`).get() + require(`../../../../../../versions/express-mongo-sanitize@${expressMongoSanitizeVersion}`).get() expressApp.use(mongoSanitize()) }, (testThatRequestHasVulnerability, testThatRequestHasNoVulnerability) => { testThatRequestHasNoVulnerability({ diff --git a/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js index 6c39799f916..3fe86dacd8d 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js @@ -12,6 +12,7 @@ const { newTaintedString } = require('../../../../src/appsec/iast/taint-tracking const { prepareTestServerForIast } = require('../utils') const fs = require('fs') +const { HTTP_REQUEST_PARAMETER } = require('../../../../src/appsec/iast/taint-tracking/source-types') const iastContext = { rootSpan: { @@ -25,26 +26,23 @@ const iastContext = { } } -const TaintTrackingMock = { - isTainted: sinon.stub() +const getRanges = (ctx, val) => { + return [ + { + start: 0, + end: val.length, + iinfo: { + parameterName: 'param', + parameterValue: val, + type: HTTP_REQUEST_PARAMETER + } + } + ] } -const getIastContext = sinon.stub() -const hasQuota = sinon.stub() -const addVulnerability = sinon.stub() - -const ProxyAnalyzer = proxyquire('../../../../src/appsec/iast/analyzers/vulnerability-analyzer', { - '../iast-context': { getIastContext }, - '../overhead-controller': { hasQuota }, - '../vulnerability-reporter': { addVulnerability } -}) - -const InjectionAnalyzer = proxyquire('../../../../src/appsec/iast/analyzers/injection-analyzer', { - './vulnerability-analyzer': ProxyAnalyzer, - '../taint-tracking/operations': TaintTrackingMock -}) - describe('path-traversal-analyzer', () => { + let TaintTrackingMock, getIastContext, hasQuota, addVulnerability, ProxyAnalyzer, InjectionAnalyzer + before(() => { pathTraversalAnalyzer.enable() }) @@ -53,6 +51,28 @@ describe('path-traversal-analyzer', () => { pathTraversalAnalyzer.disable() }) + beforeEach(() => { + TaintTrackingMock = { + isTainted: sinon.stub(), + getRanges: sinon.stub() + } + + getIastContext = sinon.stub() + hasQuota = sinon.stub() + addVulnerability = sinon.stub() + + ProxyAnalyzer = proxyquire('../../../../src/appsec/iast/analyzers/vulnerability-analyzer', { + '../iast-context': { getIastContext }, + '../overhead-controller': { hasQuota }, + '../vulnerability-reporter': { addVulnerability } + }) + + InjectionAnalyzer = proxyquire('../../../../src/appsec/iast/analyzers/injection-analyzer', { + './vulnerability-analyzer': ProxyAnalyzer, + '../taint-tracking/operations': TaintTrackingMock + }) + }) + it('Analyzer should be subscribed to proper channel', () => { expect(pathTraversalAnalyzer._subscriptions).to.have.lengthOf(1) expect(pathTraversalAnalyzer._subscriptions[0]._channel.name).to.equals('apm:fs:operation:start') @@ -72,26 +92,25 @@ describe('path-traversal-analyzer', () => { }) it('if context exists but value is not a string it should not call isTainted', () => { - const isTainted = sinon.stub() + const getRanges = sinon.stub() const iastContext = {} const proxyPathAnalyzer = proxyquire('../../../../src/appsec/iast/analyzers/path-traversal-analyzer', { - '../taint-tracking': { isTainted } + '../taint-tracking': { getRanges } }) proxyPathAnalyzer._isVulnerable(undefined, iastContext) - expect(isTainted).not.to.have.been.called + expect(getRanges).not.to.have.been.called }) it('if context and value are valid it should call isTainted', () => { - // const isTainted = sinon.stub() const iastContext = {} const proxyPathAnalyzer = proxyquire('../../../../src/appsec/iast/analyzers/path-traversal-analyzer', { './injection-analyzer': InjectionAnalyzer }) - TaintTrackingMock.isTainted.returns(false) + TaintTrackingMock.getRanges.returns([]) const result = proxyPathAnalyzer._isVulnerable('test', iastContext) expect(result).to.be.false - expect(TaintTrackingMock.isTainted).to.have.been.calledOnce + expect(TaintTrackingMock.getRanges).to.have.been.calledOnce }) it('Should report proper vulnerability type', () => { @@ -102,7 +121,7 @@ describe('path-traversal-analyzer', () => { getIastContext.returns(iastContext) hasQuota.returns(true) - TaintTrackingMock.isTainted.returns(true) + TaintTrackingMock.getRanges.callsFake(getRanges) proxyPathAnalyzer.analyze(['test']) expect(addVulnerability).to.have.been.calledOnce @@ -116,9 +135,8 @@ describe('path-traversal-analyzer', () => { '../iast-context': { getIastContext: () => iastContext } }) - addVulnerability.reset() getIastContext.returns(iastContext) - TaintTrackingMock.isTainted.returns(true) + TaintTrackingMock.getRanges.callsFake(getRanges) hasQuota.returns(true) proxyPathAnalyzer.analyze(['taintedArg1', 'taintedArg2']) @@ -132,11 +150,10 @@ describe('path-traversal-analyzer', () => { '../iast-context': { getIastContext: () => iastContext } }) - addVulnerability.reset() - TaintTrackingMock.isTainted.reset() getIastContext.returns(iastContext) - TaintTrackingMock.isTainted.onFirstCall().returns(false) - TaintTrackingMock.isTainted.onSecondCall().returns(true) + + TaintTrackingMock.getRanges.onFirstCall().returns([]) + TaintTrackingMock.getRanges.onSecondCall().callsFake(getRanges) hasQuota.returns(true) proxyPathAnalyzer.analyze(['arg1', 'taintedArg2']) @@ -155,10 +172,8 @@ describe('path-traversal-analyzer', () => { return { path: mockPath, line: 3 } } - addVulnerability.reset() - TaintTrackingMock.isTainted.reset() getIastContext.returns(iastContext) - TaintTrackingMock.isTainted.returns(true) + TaintTrackingMock.getRanges.callsFake(getRanges) hasQuota.returns(true) proxyPathAnalyzer.analyze(['arg1']) diff --git a/packages/dd-trace/test/appsec/iast/analyzers/sql-injection-analyzer.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/sql-injection-analyzer.spec.js index de662075cf3..8c4d26103d3 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/sql-injection-analyzer.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/sql-injection-analyzer.spec.js @@ -4,14 +4,27 @@ const proxyquire = require('proxyquire') const log = require('../../../../src/log') const dc = require('dc-polyfill') +const { HTTP_REQUEST_PARAMETER } = require('../../../../src/appsec/iast/taint-tracking/source-types') describe('sql-injection-analyzer', () => { const NOT_TAINTED_QUERY = 'no vulnerable query' const TAINTED_QUERY = 'vulnerable query' const TaintTrackingMock = { - isTainted: (iastContext, string) => { + getRanges: (iastContext, string) => { return string === TAINTED_QUERY + ? [ + { + start: 0, + end: string.length, + iinfo: { + parameterName: 'param', + parameterValue: string, + type: HTTP_REQUEST_PARAMETER + } + } + ] + : [] } } diff --git a/packages/dd-trace/test/appsec/iast/analyzers/template-injection-analyzer.handlebars.plugin.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/template-injection-analyzer.handlebars.plugin.spec.js index 4152f4ab6e9..b3398543a04 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/template-injection-analyzer.handlebars.plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/template-injection-analyzer.handlebars.plugin.spec.js @@ -4,6 +4,7 @@ const { prepareTestServerForIast } = require('../utils') const { storage } = require('../../../../../datadog-core') const iastContextFunctions = require('../../../../src/appsec/iast/iast-context') const { newTaintedString } = require('../../../../src/appsec/iast/taint-tracking/operations') +const { SQL_ROW_VALUE } = require('../../../../src/appsec/iast/taint-tracking/source-types') describe('template-injection-analyzer with handlebars', () => { withVersions('handlebars', 'handlebars', version => { @@ -27,6 +28,14 @@ describe('template-injection-analyzer with handlebars', () => { lib.compile(template) }, 'TEMPLATE_INJECTION') + testThatRequestHasVulnerability(() => { + const store = storage.getStore() + const iastContext = iastContextFunctions.getIastContext(store) + const template = newTaintedString(iastContext, source, 'param', SQL_ROW_VALUE) + lib.compile(template) + }, 'TEMPLATE_INJECTION', undefined, undefined, undefined, + 'Should detect TEMPLATE_INJECTION vulnerability with DB source') + testThatRequestHasNoVulnerability(() => { lib.compile(source) }, 'TEMPLATE_INJECTION') @@ -48,6 +57,14 @@ describe('template-injection-analyzer with handlebars', () => { lib.precompile(template) }, 'TEMPLATE_INJECTION') + testThatRequestHasVulnerability(() => { + const store = storage.getStore() + const iastContext = iastContextFunctions.getIastContext(store) + const template = newTaintedString(iastContext, source, 'param', SQL_ROW_VALUE) + lib.precompile(template) + }, 'TEMPLATE_INJECTION', undefined, undefined, undefined, + 'Should detect TEMPLATE_INJECTION vulnerability with DB source') + testThatRequestHasNoVulnerability(() => { lib.precompile(source) }, 'TEMPLATE_INJECTION') @@ -70,6 +87,15 @@ describe('template-injection-analyzer with handlebars', () => { lib.registerPartial('vulnerablePartial', partial) }, 'TEMPLATE_INJECTION') + testThatRequestHasVulnerability(() => { + const store = storage.getStore() + const iastContext = iastContextFunctions.getIastContext(store) + const partial = newTaintedString(iastContext, source, 'param', SQL_ROW_VALUE) + + lib.registerPartial('vulnerablePartial', partial) + }, 'TEMPLATE_INJECTION', undefined, undefined, undefined, + 'Should detect TEMPLATE_INJECTION vulnerability with DB source') + testThatRequestHasNoVulnerability(() => { lib.registerPartial('vulnerablePartial', source) }, 'TEMPLATE_INJECTION') diff --git a/packages/dd-trace/test/appsec/iast/analyzers/template-injection-analyzer.pug.plugin.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/template-injection-analyzer.pug.plugin.spec.js index 412da3a62f0..574f256fd53 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/template-injection-analyzer.pug.plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/template-injection-analyzer.pug.plugin.spec.js @@ -4,6 +4,7 @@ const { prepareTestServerForIast } = require('../utils') const { storage } = require('../../../../../datadog-core') const iastContextFunctions = require('../../../../src/appsec/iast/iast-context') const { newTaintedString } = require('../../../../src/appsec/iast/taint-tracking/operations') +const { SQL_ROW_VALUE } = require('../../../../src/appsec/iast/taint-tracking/source-types') describe('template-injection-analyzer with pug', () => { withVersions('pug', 'pug', version => { @@ -27,6 +28,14 @@ describe('template-injection-analyzer with pug', () => { lib.compile(template) }, 'TEMPLATE_INJECTION') + testThatRequestHasVulnerability(() => { + const store = storage.getStore() + const iastContext = iastContextFunctions.getIastContext(store) + const template = newTaintedString(iastContext, source, 'param', SQL_ROW_VALUE) + lib.compile(template) + }, 'TEMPLATE_INJECTION', undefined, undefined, undefined, + 'Should detect TEMPLATE_INJECTION vulnerability with DB source') + testThatRequestHasNoVulnerability(() => { const template = lib.compile(source) template() @@ -49,6 +58,14 @@ describe('template-injection-analyzer with pug', () => { lib.compileClient(template) }, 'TEMPLATE_INJECTION') + testThatRequestHasVulnerability(() => { + const store = storage.getStore() + const iastContext = iastContextFunctions.getIastContext(store) + const template = newTaintedString(iastContext, source, 'param', SQL_ROW_VALUE) + lib.compileClient(template) + }, 'TEMPLATE_INJECTION', undefined, undefined, undefined, + 'Should detect TEMPLATE_INJECTION vulnerability with DB source') + testThatRequestHasNoVulnerability(() => { lib.compileClient(source) }, 'TEMPLATE_INJECTION') @@ -70,6 +87,14 @@ describe('template-injection-analyzer with pug', () => { lib.compileClientWithDependenciesTracked(template, {}) }, 'TEMPLATE_INJECTION') + testThatRequestHasVulnerability(() => { + const store = storage.getStore() + const iastContext = iastContextFunctions.getIastContext(store) + const template = newTaintedString(iastContext, source, 'param', SQL_ROW_VALUE) + lib.compileClientWithDependenciesTracked(template, {}) + }, 'TEMPLATE_INJECTION', undefined, undefined, undefined, + 'Should detect TEMPLATE_INJECTION vulnerability with DB source') + testThatRequestHasNoVulnerability(() => { lib.compileClient(source) }, 'TEMPLATE_INJECTION') @@ -91,6 +116,14 @@ describe('template-injection-analyzer with pug', () => { lib.render(str) }, 'TEMPLATE_INJECTION') + testThatRequestHasVulnerability(() => { + const store = storage.getStore() + const iastContext = iastContextFunctions.getIastContext(store) + const str = newTaintedString(iastContext, source, 'param', SQL_ROW_VALUE) + lib.render(str) + }, 'TEMPLATE_INJECTION', undefined, undefined, undefined, + 'Should detect TEMPLATE_INJECTION vulnerability with DB source') + testThatRequestHasNoVulnerability(() => { lib.render(source) }, 'TEMPLATE_INJECTION') diff --git a/packages/dd-trace/test/appsec/iast/analyzers/vulnerability-analyzer.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/vulnerability-analyzer.spec.js index 332e0c29e35..b47fb95b81b 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/vulnerability-analyzer.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/vulnerability-analyzer.spec.js @@ -133,27 +133,6 @@ describe('vulnerability-analyzer', () => { ) }) - it('should wrap subscription handler and catch thrown Errors', () => { - const vulnerabilityAnalyzer = new VulnerabilityAnalyzer(ANALYZER_TYPE) - const handler = sinon.spy(() => { - throw new Error('handler Error') - }) - const wrapped = vulnerabilityAnalyzer._wrapHandler(handler) - - const iastContext = { - name: 'test' - } - iastContextHandler.getIastContext.returns(iastContext) - - expect(typeof wrapped).to.be.equal('function') - const message = {} - const name = 'test' - expect(() => wrapped(message, name)).to.not.throw() - const args = handler.firstCall.args - expect(args[0]).to.be.equal(message) - expect(args[1]).to.be.equal(name) - }) - it('should catch thrown Errors inside subscription handlers', () => { const vulnerabilityAnalyzer = new VulnerabilityAnalyzer(ANALYZER_TYPE) vulnerabilityAnalyzer.addSub({ channelName: 'dd-trace:test:error:sub' }, () => { diff --git a/packages/dd-trace/test/appsec/iast/iast-plugin.spec.js b/packages/dd-trace/test/appsec/iast/iast-plugin.spec.js index caa4e91bf8b..21696d3b70f 100644 --- a/packages/dd-trace/test/appsec/iast/iast-plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/iast-plugin.spec.js @@ -4,6 +4,7 @@ const { expect } = require('chai') const { channel } = require('dc-polyfill') const proxyquire = require('proxyquire') const { getExecutedMetric, getInstrumentedMetric, TagKey } = require('../../../src/appsec/iast/telemetry/iast-metric') +const { IastPlugin } = require('../../../src/appsec/iast/iast-plugin') const VULNERABILITY_TYPE = TagKey.VULNERABILITY_TYPE const SOURCE_TYPE = TagKey.SOURCE_TYPE @@ -71,33 +72,23 @@ describe('IAST Plugin', () => { }) describe('addSub', () => { - it('should call Plugin.addSub with channelName and wrapped handler', () => { + it('should call Plugin.addSub with channelName and handler', () => { iastPlugin.addSub('test', handler) expect(addSubMock).to.be.calledOnce const args = addSubMock.getCall(0).args expect(args[0]).equal('test') - - const wrapped = args[1] - expect(wrapped).to.be.a('function') - expect(wrapped).to.not.be.equal(handler) - expect(wrapped()).to.not.throw - expect(logError).to.be.calledOnce + expect(args[1]).to.equal(handler) }) - it('should call Plugin.addSub with channelName and wrapped handler after registering iastPluginSub', () => { + it('should call Plugin.addSub with channelName and handler after registering iastPluginSub', () => { const iastPluginSub = { channelName: 'test' } iastPlugin.addSub(iastPluginSub, handler) expect(addSubMock).to.be.calledOnce const args = addSubMock.getCall(0).args expect(args[0]).equal('test') - - const wrapped = args[1] - expect(wrapped).to.be.a('function') - expect(wrapped).to.not.be.equal(handler) - expect(wrapped()).to.not.throw - expect(logError).to.be.calledOnce + expect(args[1]).to.equal(handler) }) it('should infer moduleName from channelName after registering iastPluginSub', () => { @@ -117,20 +108,15 @@ describe('IAST Plugin', () => { }) it('should not call _getTelemetryHandler', () => { - const wrapHandler = sinon.stub() - iastPlugin._wrapHandler = wrapHandler const getTelemetryHandler = sinon.stub() iastPlugin._getTelemetryHandler = getTelemetryHandler iastPlugin.addSub({ channelName, tagKey: VULNERABILITY_TYPE }, handler) - expect(wrapHandler).to.be.calledOnceWith(handler) expect(getTelemetryHandler).to.be.not.called - wrapHandler.reset() getTelemetryHandler.reset() iastPlugin.addSub({ channelName, tagKey: SOURCE_TYPE, tag: 'test-tag' }, handler) - expect(wrapHandler).to.be.calledOnceWith(handler) expect(getTelemetryHandler).to.be.not.called }) }) @@ -235,20 +221,15 @@ describe('IAST Plugin', () => { describe('addSub', () => { it('should call _getTelemetryHandler with correct metrics', () => { - const wrapHandler = sinon.stub() - iastPlugin._wrapHandler = wrapHandler const getTelemetryHandler = sinon.stub() iastPlugin._getTelemetryHandler = getTelemetryHandler iastPlugin.addSub({ channelName, tagKey: VULNERABILITY_TYPE }, handler) - expect(wrapHandler).to.be.calledOnceWith(handler) expect(getTelemetryHandler).to.be.calledOnceWith(iastPlugin.pluginSubs[0]) - wrapHandler.reset() getTelemetryHandler.reset() iastPlugin.addSub({ channelName, tagKey: SOURCE_TYPE, tag: 'test-tag' }, handler) - expect(wrapHandler).to.be.calledOnceWith(handler) expect(getTelemetryHandler).to.be.calledOnceWith(iastPlugin.pluginSubs[1]) }) @@ -399,4 +380,50 @@ describe('IAST Plugin', () => { }) }) }) + + describe('Add sub to iast plugin', () => { + class BadPlugin extends IastPlugin { + static get id () { return 'badPlugin' } + + constructor () { + super() + this.addSub('appsec:badPlugin:start', this.start) + } + + start () { + throw new Error('this is one bad plugin') + } + } + class GoodPlugin extends IastPlugin { + static get id () { return 'goodPlugin' } + + constructor () { + super() + this.addSub('appsec:goodPlugin:start', this.start) + } + + start () {} + } + + const badPlugin = new BadPlugin() + const goodPlugin = new GoodPlugin() + + it('should disable bad plugin', () => { + badPlugin.configure({ enabled: true }) + expect(badPlugin._enabled).to.be.true + + channel('appsec:badPlugin:start').publish({ foo: 'bar' }) + + expect(badPlugin._enabled).to.be.false + }) + + it('should not disable good plugin', () => { + goodPlugin.configure({ enabled: true }) + expect(goodPlugin._enabled).to.be.true + + channel('appsec:goodPlugin:start').publish({ foo: 'bar' }) + + expect(goodPlugin._enabled).to.be.true + }) + }) }) diff --git a/packages/dd-trace/test/appsec/iast/overhead-controller.spec.js b/packages/dd-trace/test/appsec/iast/overhead-controller.spec.js index 7bde02537d9..c5003be25ad 100644 --- a/packages/dd-trace/test/appsec/iast/overhead-controller.spec.js +++ b/packages/dd-trace/test/appsec/iast/overhead-controller.spec.js @@ -331,7 +331,8 @@ describe('Overhead controller', () => { iast: { enabled: true, requestSampling: 100, - maxConcurrentRequests: 2 + maxConcurrentRequests: 2, + deduplicationEnabled: false } } }) @@ -365,7 +366,6 @@ describe('Overhead controller', () => { } else if (url === SECOND_REQUEST) { setImmediate(() => { requestResolvers[FIRST_REQUEST]() - vulnerabilityReporter.clearCache() }) } }) @@ -373,7 +373,6 @@ describe('Overhead controller', () => { if (url === FIRST_REQUEST) { setImmediate(() => { requestResolvers[SECOND_REQUEST]() - vulnerabilityReporter.clearCache() }) } }) @@ -388,7 +387,8 @@ describe('Overhead controller', () => { iast: { enabled: true, requestSampling: 100, - maxConcurrentRequests: 2 + maxConcurrentRequests: 2, + deduplicationEnabled: false } } }) @@ -435,7 +435,6 @@ describe('Overhead controller', () => { requestResolvers[FIRST_REQUEST]() } else if (url === FIFTH_REQUEST) { requestResolvers[SECOND_REQUEST]() - vulnerabilityReporter.clearCache() } }) testRequestEventEmitter.on(TEST_REQUEST_FINISHED, (url) => { @@ -444,7 +443,6 @@ describe('Overhead controller', () => { axios.get(`http://localhost:${serverConfig.port}${FIFTH_REQUEST}`).then().catch(done) } else if (url === SECOND_REQUEST) { setImmediate(() => { - vulnerabilityReporter.clearCache() requestResolvers[THIRD_REQUEST]() requestResolvers[FOURTH_REQUEST]() requestResolvers[FIFTH_REQUEST]() diff --git a/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.spec.js b/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.spec.js index 1a21b0a5b08..af575ce9652 100644 --- a/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.spec.js @@ -8,14 +8,18 @@ const { HTTP_REQUEST_COOKIE_VALUE, HTTP_REQUEST_HEADER_VALUE, HTTP_REQUEST_PATH_PARAM, - HTTP_REQUEST_URI + HTTP_REQUEST_URI, + SQL_ROW_VALUE } = require('../../../../src/appsec/iast/taint-tracking/source-types') +const Config = require('../../../../src/config') const middlewareNextChannel = dc.channel('apm:express:middleware:next') -const queryParseFinishChannel = dc.channel('datadog:qs:parse:finish') +const queryReadFinishChannel = dc.channel('datadog:query:read:finish') const bodyParserFinishChannel = dc.channel('datadog:body-parser:read:finish') const cookieParseFinishCh = dc.channel('datadog:cookie:parse:finish') const processParamsStartCh = dc.channel('datadog:express:process_params:start') +const routerParamStartCh = dc.channel('datadog:router:param:start') +const sequelizeFinish = dc.channel('datadog:sequelize:query:finish') describe('IAST Taint tracking plugin', () => { let taintTrackingPlugin @@ -33,7 +37,8 @@ describe('IAST Taint tracking plugin', () => { './operations': sinon.spy(taintTrackingOperations), '../../../../../datadog-core': datadogCore }) - taintTrackingPlugin.enable() + const config = new Config() + taintTrackingPlugin.enable(config.iast) }) afterEach(() => { @@ -42,16 +47,20 @@ describe('IAST Taint tracking plugin', () => { }) it('Should subscribe to body parser, qs, cookie and process_params channel', () => { - expect(taintTrackingPlugin._subscriptions).to.have.lengthOf(9) + expect(taintTrackingPlugin._subscriptions).to.have.lengthOf(13) expect(taintTrackingPlugin._subscriptions[0]._channel.name).to.equals('datadog:body-parser:read:finish') expect(taintTrackingPlugin._subscriptions[1]._channel.name).to.equals('datadog:multer:read:finish') - expect(taintTrackingPlugin._subscriptions[2]._channel.name).to.equals('datadog:qs:parse:finish') - expect(taintTrackingPlugin._subscriptions[3]._channel.name).to.equals('apm:express:middleware:next') - expect(taintTrackingPlugin._subscriptions[4]._channel.name).to.equals('datadog:cookie:parse:finish') - expect(taintTrackingPlugin._subscriptions[5]._channel.name).to.equals('datadog:express:process_params:start') - expect(taintTrackingPlugin._subscriptions[6]._channel.name).to.equals('apm:graphql:resolve:start') - expect(taintTrackingPlugin._subscriptions[7]._channel.name).to.equals('datadog:url:parse:finish') - expect(taintTrackingPlugin._subscriptions[8]._channel.name).to.equals('datadog:url:getter:finish') + expect(taintTrackingPlugin._subscriptions[2]._channel.name).to.equals('datadog:query:read:finish') + expect(taintTrackingPlugin._subscriptions[3]._channel.name).to.equals('datadog:express:query:finish') + expect(taintTrackingPlugin._subscriptions[4]._channel.name).to.equals('apm:express:middleware:next') + expect(taintTrackingPlugin._subscriptions[5]._channel.name).to.equals('datadog:cookie:parse:finish') + expect(taintTrackingPlugin._subscriptions[6]._channel.name).to.equals('datadog:sequelize:query:finish') + expect(taintTrackingPlugin._subscriptions[7]._channel.name).to.equals('apm:pg:query:finish') + expect(taintTrackingPlugin._subscriptions[8]._channel.name).to.equals('datadog:express:process_params:start') + expect(taintTrackingPlugin._subscriptions[9]._channel.name).to.equals('datadog:router:param:start') + expect(taintTrackingPlugin._subscriptions[10]._channel.name).to.equals('apm:graphql:resolve:start') + expect(taintTrackingPlugin._subscriptions[11]._channel.name).to.equals('datadog:url:parse:finish') + expect(taintTrackingPlugin._subscriptions[12]._channel.name).to.equals('datadog:url:getter:finish') }) describe('taint sources', () => { @@ -136,7 +145,7 @@ describe('IAST Taint tracking plugin', () => { } } - queryParseFinishChannel.publish({ qs: req.query }) + queryReadFinishChannel.publish({ query: req.query }) expect(taintTrackingOperations.taintObject).to.be.calledOnceWith( iastContext, @@ -209,7 +218,7 @@ describe('IAST Taint tracking plugin', () => { ) }) - it('Should taint request params when process params event is published', () => { + it('Should taint request params when process params event is published with processParamsStartCh', () => { const req = { params: { parameter1: 'tainted1' @@ -224,6 +233,21 @@ describe('IAST Taint tracking plugin', () => { ) }) + it('Should taint request params when process params event is published with routerParamStartCh', () => { + const req = { + params: { + parameter1: 'tainted1' + } + } + + routerParamStartCh.publish({ req }) + expect(taintTrackingOperations.taintObject).to.be.calledOnceWith( + iastContext, + req.params, + HTTP_REQUEST_PATH_PARAM + ) + }) + it('Should not taint request params when process params event is published with non params request', () => { const req = {} @@ -253,5 +277,259 @@ describe('IAST Taint tracking plugin', () => { HTTP_REQUEST_URI ) }) + + describe('taint database sources', () => { + it('Should not taint if config is set to 0', () => { + taintTrackingPlugin.disable() + const config = new Config() + config.dbRowsToTaint = 0 + taintTrackingPlugin.enable(config) + + const result = [ + { + id: 1, + name: 'string value 1' + }, + { + id: 2, + name: 'string value 2' + }] + sequelizeFinish.publish({ result }) + + expect(taintTrackingOperations.newTaintedString).to.not.have.been.called + }) + + describe('with default config', () => { + it('Should taint first database row coming from sequelize', () => { + const result = [ + { + id: 1, + name: 'string value 1' + }, + { + id: 2, + name: 'string value 2' + }] + sequelizeFinish.publish({ result }) + + expect(taintTrackingOperations.newTaintedString).to.be.calledOnceWith( + iastContext, + 'string value 1', + '0.name', + SQL_ROW_VALUE + ) + }) + + it('Should taint whole object', () => { + const result = { id: 1, description: 'value' } + sequelizeFinish.publish({ result }) + + expect(taintTrackingOperations.newTaintedString).to.be.calledOnceWith( + iastContext, + 'value', + 'description', + SQL_ROW_VALUE + ) + }) + + it('Should taint first row in nested objects', () => { + const result = [ + { + id: 1, + description: 'value', + children: [ + { + id: 11, + name: 'child1' + }, + { + id: 12, + name: 'child2' + } + ] + }, + { + id: 2, + description: 'value', + children: [ + { + id: 21, + name: 'child3' + }, + { + id: 22, + name: 'child4' + } + ] + } + ] + sequelizeFinish.publish({ result }) + + expect(taintTrackingOperations.newTaintedString).to.be.calledTwice + expect(taintTrackingOperations.newTaintedString).to.be.calledWith( + iastContext, + 'value', + '0.description', + SQL_ROW_VALUE + ) + expect(taintTrackingOperations.newTaintedString).to.be.calledWith( + iastContext, + 'child1', + '0.children.0.name', + SQL_ROW_VALUE + ) + }) + }) + + describe('with config set to 2', () => { + beforeEach(() => { + taintTrackingPlugin.disable() + const config = new Config() + config.dbRowsToTaint = 2 + taintTrackingPlugin.enable(config) + }) + + it('Should taint first database row coming from sequelize', () => { + const result = [ + { + id: 1, + name: 'string value 1' + }, + { + id: 2, + name: 'string value 2' + }, + { + id: 3, + name: 'string value 2' + }] + sequelizeFinish.publish({ result }) + + expect(taintTrackingOperations.newTaintedString).to.be.calledTwice + expect(taintTrackingOperations.newTaintedString).to.be.calledWith( + iastContext, + 'string value 1', + '0.name', + SQL_ROW_VALUE + ) + expect(taintTrackingOperations.newTaintedString).to.be.calledWith( + iastContext, + 'string value 2', + '1.name', + SQL_ROW_VALUE + ) + }) + + it('Should taint whole object', () => { + const result = { id: 1, description: 'value' } + sequelizeFinish.publish({ result }) + + expect(taintTrackingOperations.newTaintedString).to.be.calledOnceWith( + iastContext, + 'value', + 'description', + SQL_ROW_VALUE + ) + }) + + it('Should taint first row in nested objects', () => { + const result = [ + { + id: 1, + description: 'value', + children: [ + { + id: 11, + name: 'child1' + }, + { + id: 12, + name: 'child2' + }, + { + id: 13, + name: 'child3' + } + ] + }, + { + id: 2, + description: 'value2', + children: [ + { + id: 21, + name: 'child4' + }, + { + id: 22, + name: 'child5' + }, + { + id: 23, + name: 'child6' + } + ] + }, + { + id: 3, + description: 'value3', + children: [ + { + id: 31, + name: 'child7' + }, + { + id: 32, + name: 'child8' + }, + { + id: 33, + name: 'child9' + } + ] + } + ] + sequelizeFinish.publish({ result }) + + expect(taintTrackingOperations.newTaintedString).to.callCount(6) + expect(taintTrackingOperations.newTaintedString).to.be.calledWith( + iastContext, + 'value', + '0.description', + SQL_ROW_VALUE + ) + expect(taintTrackingOperations.newTaintedString).to.be.calledWith( + iastContext, + 'child1', + '0.children.0.name', + SQL_ROW_VALUE + ) + expect(taintTrackingOperations.newTaintedString).to.be.calledWith( + iastContext, + 'child2', + '0.children.1.name', + SQL_ROW_VALUE + ) + expect(taintTrackingOperations.newTaintedString).to.be.calledWith( + iastContext, + 'value2', + '1.description', + SQL_ROW_VALUE + ) + expect(taintTrackingOperations.newTaintedString).to.be.calledWith( + iastContext, + 'child4', + '1.children.0.name', + SQL_ROW_VALUE + ) + expect(taintTrackingOperations.newTaintedString).to.be.calledWith( + iastContext, + 'child5', + '1.children.1.name', + SQL_ROW_VALUE + ) + }) + }) + }) }) }) diff --git a/packages/dd-trace/test/appsec/iast/taint-tracking/sources/sql_row.pg.plugin.spec.js b/packages/dd-trace/test/appsec/iast/taint-tracking/sources/sql_row.pg.plugin.spec.js new file mode 100644 index 00000000000..69e73b0ccb0 --- /dev/null +++ b/packages/dd-trace/test/appsec/iast/taint-tracking/sources/sql_row.pg.plugin.spec.js @@ -0,0 +1,113 @@ +'use strict' + +const { prepareTestServerForIast } = require('../../utils') + +const connectionData = { + host: '127.0.0.1', + user: 'postgres', + password: 'postgres', + database: 'postgres', + application_name: 'test' +} + +describe('db sources with pg', () => { + let pg + withVersions('pg', 'pg', '>=8.0.3', version => { + let client + beforeEach(async () => { + pg = require(`../../../../../../../versions/pg@${version}`).get() + client = new pg.Client(connectionData) + await client.connect() + + await client.query(`CREATE TABLE IF NOT EXISTS examples ( + id INT, + name VARCHAR(50), + query VARCHAR(100), + command VARCHAR(50))`) + + await client.query(`INSERT INTO examples (id, name, query, command) + VALUES (1, 'Item1', 'SELECT 1', 'ls'), + (2, 'Item2', 'SELECT 1', 'ls'), + (3, 'Item3', 'SELECT 1', 'ls')`) + }) + + afterEach(async () => { + await client.query('DROP TABLE examples') + client.end() + }) + + prepareTestServerForIast('sequelize', (testThatRequestHasVulnerability, testThatRequestHasNoVulnerability) => { + describe('using pg.Client', () => { + testThatRequestHasVulnerability(async (req, res) => { + const result = await client.query('SELECT * FROM examples') + + const firstItem = result.rows[0] + + await client.query(firstItem.query) + + res.end() + }, 'SQL_INJECTION', { occurrences: 1 }, null, null, + 'Should have SQL_INJECTION using the first row of the result') + + testThatRequestHasNoVulnerability(async (req, res) => { + const result = await client.query('SELECT * FROM examples') + + const secondItem = result.rows[1] + + await client.query(secondItem.query) + + res.end() + }, 'SQL_INJECTION', null, 'Should not taint the second row of a query with default configuration') + + testThatRequestHasNoVulnerability(async (req, res) => { + const result = await client.query('SELECT * from examples') + const firstItem = result.rows[0] + + const childProcess = require('child_process') + childProcess.execSync(firstItem.command) + + res.end('OK') + }, 'COMMAND_INJECTION', null, 'Should not detect COMMAND_INJECTION with database source') + }) + + describe('using pg.Pool', () => { + let pool + + beforeEach(() => { + pool = new pg.Pool(connectionData) + }) + + testThatRequestHasVulnerability(async (req, res) => { + const result = await pool.query('SELECT * FROM examples') + + const firstItem = result.rows[0] + + await client.query(firstItem.query) + + res.end() + }, 'SQL_INJECTION', { occurrences: 1 }, null, null, + 'Should have SQL_INJECTION using the first row of the result') + + testThatRequestHasNoVulnerability(async (req, res) => { + const result = await pool.query('SELECT * FROM examples') + + const secondItem = result.rows[1] + + await client.query(secondItem.query) + + res.end() + }, 'SQL_INJECTION', null, 'Should not taint the second row of a query with default configuration') + + testThatRequestHasNoVulnerability(async (req, res) => { + const result = await pool.query('SELECT * from examples') + const firstItem = result.rows[0] + + const childProcess = require('child_process') + childProcess.execSync(firstItem.command) + + res.end('OK') + }, 'COMMAND_INJECTION', null, 'Should not detect COMMAND_INJECTION with database source') + }) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/iast/taint-tracking/sources/sql_row.sequelize.plugin.spec.js b/packages/dd-trace/test/appsec/iast/taint-tracking/sources/sql_row.sequelize.plugin.spec.js new file mode 100644 index 00000000000..0e1e84888c7 --- /dev/null +++ b/packages/dd-trace/test/appsec/iast/taint-tracking/sources/sql_row.sequelize.plugin.spec.js @@ -0,0 +1,106 @@ +'use strict' + +const { prepareTestServerForIast } = require('../../utils') + +describe('db sources with sequelize', () => { + withVersions('sequelize', 'sequelize', sequelizeVersion => { + prepareTestServerForIast('sequelize', (testThatRequestHasVulnerability, testThatRequestHasNoVulnerability) => { + let Sequelize, sequelize + + beforeEach(async () => { + Sequelize = require(`../../../../../../../versions/sequelize@${sequelizeVersion}`).get() + sequelize = new Sequelize('database', 'username', 'password', { + dialect: 'sqlite', + logging: false + }) + await sequelize.query(`CREATE TABLE examples ( + id INT, + name VARCHAR(50), + query VARCHAR(100), + command VARCHAR(50), + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP )`) + + await sequelize.query(`INSERT INTO examples (id, name, query, command) + VALUES (1, 'Item1', 'SELECT 1', 'ls'), + (2, 'Item2', 'SELECT 1', 'ls'), + (3, 'Item3', 'SELECT 1', 'ls')`) + }) + + afterEach(() => { + return sequelize.close() + }) + + describe('using query method', () => { + testThatRequestHasVulnerability(async (req, res) => { + const result = await sequelize.query('SELECT * from examples') + + await sequelize.query(result[0][0].query) + + res.end('OK') + }, 'SQL_INJECTION', { occurrences: 1 }, null, null, + 'Should have SQL_INJECTION using the first row of the result') + + testThatRequestHasNoVulnerability(async (req, res) => { + const result = await sequelize.query('SELECT * from examples') + + await sequelize.query(result[0][1].query) + + res.end('OK') + }, 'SQL_INJECTION', null, 'Should not taint the second row of a query with default configuration') + + testThatRequestHasNoVulnerability(async (req, res) => { + const result = await sequelize.query('SELECT * from examples') + + const childProcess = require('child_process') + childProcess.execSync(result[0][0].command) + + res.end('OK') + }, 'COMMAND_INJECTION', null, 'Should not detect COMMAND_INJECTION with database source') + }) + + describe('using Model', () => { + // let Model + let Example + + beforeEach(() => { + Example = sequelize.define('example', { + id: { + type: Sequelize.DataTypes.INTEGER, + primaryKey: true + }, + name: Sequelize.DataTypes.STRING, + query: Sequelize.DataTypes.STRING, + command: Sequelize.DataTypes.STRING + }) + }) + + testThatRequestHasVulnerability(async (req, res) => { + const examples = await Example.findAll() + + await sequelize.query(examples[0].query) + + res.end('OK') + }, 'SQL_INJECTION', { occurrences: 1 }, null, null, + 'Should have SQL_INJECTION using the first row of the result') + + testThatRequestHasNoVulnerability(async (req, res) => { + const examples = await Example.findAll() + + await sequelize.query(examples[1].query) + + res.end('OK') + }, 'SQL_INJECTION', null, 'Should not taint the second row of a query with default configuration') + + testThatRequestHasNoVulnerability(async (req, res) => { + const examples = await Example.findAll() + + const childProcess = require('child_process') + childProcess.execSync(examples[0].command) + + res.end('OK') + }, 'COMMAND_INJECTION', null, 'Should not detect COMMAND_INJECTION with database source') + }) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/iast/taint-tracking/sources/taint-tracking.express.plugin.spec.js b/packages/dd-trace/test/appsec/iast/taint-tracking/sources/taint-tracking.express.plugin.spec.js index 7465f6b2408..8fc32f1c03a 100644 --- a/packages/dd-trace/test/appsec/iast/taint-tracking/sources/taint-tracking.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/taint-tracking/sources/taint-tracking.express.plugin.spec.js @@ -46,9 +46,10 @@ describe('URI sourcing with express', () => { iast.disable() }) - it('should taint uri', done => { + it('should taint uri', (done) => { const app = express() - app.get('/path/*', (req, res) => { + const pathPattern = semver.intersects(version, '>=5.0.0') ? '/path/*splat' : '/path/*' + app.get(pathPattern, (req, res) => { const store = storage.getStore() const iastContext = iastContextFunctions.getIastContext(store) const isPathTainted = isTainted(iastContext, req.url) @@ -76,11 +77,11 @@ describe('Path params sourcing with express', () => { let appListener withVersions('express', 'express', version => { - const checkParamIsTaintedAndNext = (req, res, next, param) => { + const checkParamIsTaintedAndNext = (req, res, next, param, name) => { const store = storage.getStore() const iastContext = iastContextFunctions.getIastContext(store) - const pathParamValue = param + const pathParamValue = name ? req.params[name] : req.params const isParameterTainted = isTainted(iastContext, pathParamValue) expect(isParameterTainted).to.be.true const taintedParameterValueRanges = getRanges(iastContext, pathParamValue) @@ -188,8 +189,7 @@ describe('Path params sourcing with express', () => { res.status(200).send() }) - app.param('parameter1', checkParamIsTaintedAndNext) - app.param('parameter2', checkParamIsTaintedAndNext) + app.param(['parameter1', 'parameter2'], checkParamIsTaintedAndNext) appListener = app.listen(0, 'localhost', () => { const port = appListener.address().port @@ -202,6 +202,9 @@ describe('Path params sourcing with express', () => { }) it('should taint path param on router.params callback with custom implementation', function (done) { + if (!semver.satisfies(expressVersion, '4')) { + this.skip() + } const app = express() app.use('/:parameter1/:parameter2', (req, res) => { diff --git a/packages/dd-trace/test/appsec/iast/utils.js b/packages/dd-trace/test/appsec/iast/utils.js index 6e427bcb629..01274dd954e 100644 --- a/packages/dd-trace/test/appsec/iast/utils.js +++ b/packages/dd-trace/test/appsec/iast/utils.js @@ -256,8 +256,8 @@ function prepareTestServerForIast (description, tests, iastConfig) { }) } - function testThatRequestHasNoVulnerability (fn, vulnerability, makeRequest) { - it(`should not have ${vulnerability} vulnerability`, function (done) { + function testThatRequestHasNoVulnerability (fn, vulnerability, makeRequest, description) { + it(description || `should not have ${vulnerability} vulnerability`, function (done) { app = fn checkNoVulnerabilityInRequest(vulnerability, config, done, makeRequest) }) diff --git a/packages/dd-trace/test/appsec/iast/vulnerability-reporter.spec.js b/packages/dd-trace/test/appsec/iast/vulnerability-reporter.spec.js index 1f4516218af..2ebe646a2d8 100644 --- a/packages/dd-trace/test/appsec/iast/vulnerability-reporter.spec.js +++ b/packages/dd-trace/test/appsec/iast/vulnerability-reporter.spec.js @@ -52,14 +52,14 @@ describe('vulnerability-reporter', () => { expect(iastContext.vulnerabilities).to.be.an('array') }) - it('should add multiple vulnerabilities', () => { + it('should deduplicate same vulnerabilities', () => { addVulnerability(iastContext, vulnerabilityAnalyzer._createVulnerability('INSECURE_HASHING', { value: 'sha1' }, -555)) addVulnerability(iastContext, vulnerabilityAnalyzer._createVulnerability('INSECURE_HASHING', { value: 'sha1' }, 888)) addVulnerability(iastContext, vulnerabilityAnalyzer._createVulnerability('INSECURE_HASHING', { value: 'sha1' }, 123)) - expect(iastContext.vulnerabilities).to.have.length(3) + expect(iastContext.vulnerabilities).to.have.length(1) }) it('should add in the context evidence properties', () => { @@ -260,7 +260,12 @@ describe('vulnerability-reporter', () => { '[{"value":"SELECT id FROM u WHERE email = \'"},{"value":"joe@mail.com","source":1},{"value":"\';"}]},' + '"location":{"spanId":888,"path":"filename.js","line":99}}]}' }) - expect(prioritySampler.setPriority).to.have.been.calledOnceWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + + expect(prioritySampler.setPriority).to.have.been.calledTwice + expect(prioritySampler.setPriority.firstCall) + .to.have.been.calledWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(prioritySampler.setPriority.secondCall) + .to.have.been.calledWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) }) it('should send multiple vulnerabilities with same tainted source', () => { @@ -313,7 +318,12 @@ describe('vulnerability-reporter', () => { '[{"value":"UPDATE u SET name=\'"},{"value":"joe","source":0},{"value":"\' WHERE id=1;"}]},' + '"location":{"spanId":888,"path":"filename.js","line":99}}]}' }) - expect(prioritySampler.setPriority).to.have.been.calledOnceWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + + expect(prioritySampler.setPriority).to.have.been.calledTwice + expect(prioritySampler.setPriority.firstCall) + .to.have.been.calledWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(prioritySampler.setPriority.secondCall) + .to.have.been.calledWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) }) it('should send once with multiple vulnerabilities', () => { @@ -334,7 +344,13 @@ describe('vulnerability-reporter', () => { '{"type":"INSECURE_HASHING","hash":1755238473,"evidence":{"value":"md5"},' + '"location":{"spanId":-5,"path":"/path/to/file3.js","line":3}}]}' }) - expect(prioritySampler.setPriority).to.have.been.calledOnceWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(prioritySampler.setPriority).to.have.been.calledThrice + expect(prioritySampler.setPriority.firstCall) + .to.have.been.calledWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(prioritySampler.setPriority.secondCall) + .to.have.been.calledWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(prioritySampler.setPriority.thirdCall) + .to.have.been.calledWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) }) it('should send once vulnerability with one vulnerability', () => { @@ -366,23 +382,6 @@ describe('vulnerability-reporter', () => { expect(prioritySampler.setPriority).to.have.been.calledOnceWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) }) - it('should not send duplicated vulnerabilities in multiple sends', () => { - const iastContext = { rootSpan: span } - addVulnerability(iastContext, - vulnerabilityAnalyzer._createVulnerability('INSECURE_HASHING', { value: 'sha1' }, 888, - { path: 'filename.js', line: 88 })) - addVulnerability(iastContext, - vulnerabilityAnalyzer._createVulnerability('INSECURE_HASHING', { value: 'sha1' }, 888, - { path: 'filename.js', line: 88 })) - sendVulnerabilities(iastContext.vulnerabilities, span) - sendVulnerabilities(iastContext.vulnerabilities, span) - expect(span.addTags).to.have.been.calledOnceWithExactly({ - '_dd.iast.json': '{"sources":[],"vulnerabilities":[{"type":"INSECURE_HASHING","hash":3410512691,' + - '"evidence":{"value":"sha1"},"location":{"spanId":888,"path":"filename.js","line":88}}]}' - }) - expect(prioritySampler.setPriority).to.have.been.calledOnceWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) - }) - it('should not deduplicate vulnerabilities if not enabled', () => { start({ iast: { @@ -401,7 +400,11 @@ describe('vulnerability-reporter', () => { '{"type":"INSECURE_HASHING","hash":3410512691,"evidence":{"value":"sha1"},"location":' + '{"spanId":888,"path":"filename.js","line":88}}]}' }) - expect(prioritySampler.setPriority).to.have.been.calledOnceWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(prioritySampler.setPriority).to.have.been.calledTwice + expect(prioritySampler.setPriority.firstCall) + .to.have.been.calledWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(prioritySampler.setPriority.secondCall) + .to.have.been.calledWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) }) it('should add _dd.p.appsec trace tag with standalone enabled', () => { diff --git a/packages/dd-trace/test/appsec/index.express.plugin.spec.js b/packages/dd-trace/test/appsec/index.express.plugin.spec.js index bb674015f78..1c6a8aeb86d 100644 --- a/packages/dd-trace/test/appsec/index.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/index.express.plugin.spec.js @@ -19,7 +19,7 @@ withVersions('express', 'express', version => { }) before((done) => { - const express = require('../../../../versions/express').get() + const express = require(`../../../../versions/express@${version}`).get() const app = express() @@ -44,11 +44,7 @@ withVersions('express', 'express', version => { paramCallbackSpy = sinon.spy(paramCallback) - app.param(() => { - return paramCallbackSpy - }) - - app.param('callbackedParameter') + app.param('callbackedParameter', paramCallbackSpy) getPort().then((port) => { server = app.listen(port, () => { @@ -191,7 +187,7 @@ withVersions('express', 'express', version => { }) before((done) => { - const express = require('../../../../versions/express').get() + const express = require(`../../../../versions/express@${version}`).get() const app = express() @@ -256,7 +252,7 @@ withVersions('express', 'express', version => { }) before((done) => { - const express = require('../../../../versions/express').get() + const express = require(`../../../../versions/express@${version}`).get() const bodyParser = require('../../../../versions/body-parser').get() const app = express() diff --git a/packages/dd-trace/test/appsec/index.spec.js b/packages/dd-trace/test/appsec/index.spec.js index 26a1c709cd9..7ca54e9241b 100644 --- a/packages/dd-trace/test/appsec/index.spec.js +++ b/packages/dd-trace/test/appsec/index.spec.js @@ -15,6 +15,7 @@ const { nextBodyParsed, nextQueryParsed, expressProcessParams, + routerParam, responseBody, responseWriteHead, responseSetHeader @@ -43,7 +44,7 @@ describe('AppSec Index', function () { let AppSec let web let blocking - let passport + let UserTracking let log let appsecTelemetry let graphql @@ -64,8 +65,7 @@ describe('AppSec Index', function () { blockedTemplateHtml: blockedTemplate.html, blockedTemplateJson: blockedTemplate.json, eventTracking: { - enabled: true, - mode: 'safe' + mode: 'anon' }, apiSecurity: { enabled: false, @@ -89,8 +89,9 @@ describe('AppSec Index', function () { setTemplates: sinon.stub() } - passport = { - passportTrackEvent: sinon.stub() + UserTracking = { + setCollectionMode: sinon.stub(), + trackLogin: sinon.stub() } log = { @@ -123,7 +124,7 @@ describe('AppSec Index', function () { '../log': log, '../plugins/util/web': web, './blocking': blocking, - './passport': passport, + './user_tracking': UserTracking, './telemetry': appsecTelemetry, './graphql': graphql, './api_security_sampler': apiSecuritySampler, @@ -151,6 +152,7 @@ describe('AppSec Index', function () { expect(blocking.setTemplates).to.have.been.calledOnceWithExactly(config) expect(RuleManager.loadRules).to.have.been.calledOnceWithExactly(config.appsec) expect(Reporter.setRateLimit).to.have.been.calledOnceWithExactly(42) + expect(UserTracking.setCollectionMode).to.have.been.calledOnceWithExactly('anon', false) expect(incomingHttpRequestStart.subscribe) .to.have.been.calledOnceWithExactly(AppSec.incomingHttpStartTranslator) expect(incomingHttpRequestEnd.subscribe).to.have.been.calledOnceWithExactly(AppSec.incomingHttpEndTranslator) @@ -178,6 +180,7 @@ describe('AppSec Index', function () { expect(nextBodyParsed.hasSubscribers).to.be.false expect(nextQueryParsed.hasSubscribers).to.be.false expect(expressProcessParams.hasSubscribers).to.be.false + expect(routerParam.hasSubscribers).to.be.false expect(responseWriteHead.hasSubscribers).to.be.false expect(responseSetHeader.hasSubscribers).to.be.false @@ -190,17 +193,18 @@ describe('AppSec Index', function () { expect(nextBodyParsed.hasSubscribers).to.be.true expect(nextQueryParsed.hasSubscribers).to.be.true expect(expressProcessParams.hasSubscribers).to.be.true + expect(routerParam.hasSubscribers).to.be.true expect(responseWriteHead.hasSubscribers).to.be.true expect(responseSetHeader.hasSubscribers).to.be.true }) - it('should not subscribe to passportVerify if eventTracking is disabled', () => { - config.appsec.eventTracking.enabled = false + it('should still subscribe to passportVerify if eventTracking is disabled', () => { + config.appsec.eventTracking.mode = 'disabled' AppSec.disable() AppSec.enable(config) - expect(passportVerify.hasSubscribers).to.be.false + expect(passportVerify.hasSubscribers).to.be.true }) it('should call appsec telemetry enable', () => { @@ -271,6 +275,7 @@ describe('AppSec Index', function () { expect(nextBodyParsed.hasSubscribers).to.be.false expect(nextQueryParsed.hasSubscribers).to.be.false expect(expressProcessParams.hasSubscribers).to.be.false + expect(routerParam.hasSubscribers).to.be.false expect(responseWriteHead.hasSubscribers).to.be.false expect(responseSetHeader.hasSubscribers).to.be.false }) @@ -361,7 +366,7 @@ describe('AppSec Index', function () { const res = { getHeaders: () => ({ 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 }), statusCode: 201 } @@ -399,7 +404,7 @@ describe('AppSec Index', function () { const res = { getHeaders: () => ({ 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 }), statusCode: 201 } @@ -445,7 +450,7 @@ describe('AppSec Index', function () { const res = { getHeaders: () => ({ 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 }), statusCode: 201 } @@ -511,7 +516,7 @@ describe('AppSec Index', function () { const res = { getHeaders: () => ({ 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 }), statusCode: 201 } @@ -557,7 +562,7 @@ describe('AppSec Index', function () { const res = { getHeaders: () => ({ 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 }), statusCode: 201 } @@ -645,6 +650,17 @@ describe('AppSec Index', function () { abortController = { abort: sinon.stub() } + res = { + getHeaders: () => ({ + 'content-type': 'application/json', + 'content-length': 42 + }), + writeHead: sinon.stub(), + end: sinon.stub(), + getHeaderNames: sinon.stub().returns([]) + } + res.writeHead.returns(res) + req = { url: '/path', headers: { @@ -655,18 +671,9 @@ describe('AppSec Index', function () { socket: { remoteAddress: '127.0.0.1', remotePort: 8080 - } - } - res = { - getHeaders: () => ({ - 'content-type': 'application/json', - 'content-lenght': 42 - }), - writeHead: sinon.stub(), - end: sinon.stub(), - getHeaderNames: sinon.stub().returns([]) + }, + res } - res.writeHead.returns(res) AppSec.enable(config) AppSec.incomingHttpStartTranslator({ req, res }) @@ -803,31 +810,84 @@ describe('AppSec Index', function () { }) describe('onPassportVerify', () => { - it('Should call passportTrackEvent', () => { - const credentials = { type: 'local', username: 'test' } - const user = { id: '1234', username: 'Test' } + beforeEach(() => { + web.root.resetHistory() + sinon.stub(storage, 'getStore').returns({ req }) + }) - sinon.stub(storage, 'getStore').returns({ req: {} }) + it('should block when UserTracking.login() returns action', () => { + UserTracking.trackLogin.returns(resultActions) - passportVerify.publish({ credentials, user }) + const abortController = new AbortController() + const payload = { + framework: 'passport-local', + login: 'test', + user: { _id: 1, username: 'test', password: '1234' }, + success: true, + abortController + } + + passportVerify.publish(payload) + + expect(storage.getStore).to.have.been.calledOnce + expect(web.root).to.have.been.calledOnceWithExactly(req) + expect(UserTracking.trackLogin).to.have.been.calledOnceWithExactly( + payload.framework, + payload.login, + payload.user, + payload.success, + rootSpan + ) + expect(abortController.signal.aborted).to.be.true + expect(res.end).to.have.been.called + }) - expect(passport.passportTrackEvent).to.have.been.calledOnceWithExactly( - credentials, - user, - rootSpan, - config.appsec.eventTracking.mode) + it('should not block when UserTracking.login() returns nothing', () => { + UserTracking.trackLogin.returns(undefined) + + const abortController = new AbortController() + const payload = { + framework: 'passport-local', + login: 'test', + user: { _id: 1, username: 'test', password: '1234' }, + success: true, + abortController + } + + passportVerify.publish(payload) + + expect(storage.getStore).to.have.been.calledOnce + expect(web.root).to.have.been.calledOnceWithExactly(req) + expect(UserTracking.trackLogin).to.have.been.calledOnceWithExactly( + payload.framework, + payload.login, + payload.user, + payload.success, + rootSpan + ) + expect(abortController.signal.aborted).to.be.false + expect(res.end).to.not.have.been.called }) - it('Should call log if no rootSpan is found', () => { - const credentials = { type: 'local', username: 'test' } - const user = { id: '1234', username: 'Test' } + it('should not block and call log if no rootSpan is found', () => { + storage.getStore.returns(undefined) - sinon.stub(storage, 'getStore').returns(undefined) + const abortController = new AbortController() + const payload = { + framework: 'passport-local', + login: 'test', + user: { _id: 1, username: 'test', password: '1234' }, + success: true, + abortController + } - passportVerify.publish({ credentials, user }) + passportVerify.publish(payload) + expect(storage.getStore).to.have.been.calledOnce expect(log.warn).to.have.been.calledOnceWithExactly('[ASM] No rootSpan found in onPassportVerify') - expect(passport.passportTrackEvent).not.to.have.been.called + expect(UserTracking.trackLogin).to.not.have.been.called + expect(abortController.signal.aborted).to.be.false + expect(res.end).to.not.have.been.called }) }) @@ -837,7 +897,7 @@ describe('AppSec Index', function () { const responseHeaders = { 'content-type': 'application/json', - 'content-lenght': 42, + 'content-length': 42, 'set-cookie': 'a=1;b=2' } @@ -848,7 +908,7 @@ describe('AppSec Index', function () { 'server.response.status': '404', 'server.response.headers.no_cookies': { 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 } } }, req) @@ -869,7 +929,7 @@ describe('AppSec Index', function () { const responseHeaders = { 'content-type': 'application/json', - 'content-lenght': 42, + 'content-length': 42, 'set-cookie': 'a=1;b=2' } @@ -880,7 +940,7 @@ describe('AppSec Index', function () { 'server.response.status': '404', 'server.response.headers.no_cookies': { 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 } } }, req) @@ -900,7 +960,7 @@ describe('AppSec Index', function () { const responseHeaders = { 'content-type': 'application/json', - 'content-lenght': 42, + 'content-length': 42, 'set-cookie': 'a=1;b=2' } @@ -916,7 +976,7 @@ describe('AppSec Index', function () { const responseHeaders = { 'content-type': 'application/json', - 'content-lenght': 42, + 'content-length': 42, 'set-cookie': 'a=1;b=2' } @@ -927,7 +987,7 @@ describe('AppSec Index', function () { 'server.response.status': '404', 'server.response.headers.no_cookies': { 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 } } }, req) @@ -943,7 +1003,7 @@ describe('AppSec Index', function () { const responseHeaders = { 'content-type': 'application/json', - 'content-lenght': 42, + 'content-length': 42, 'set-cookie': 'a=1;b=2' } responseWriteHead.publish({ req, res, abortController, statusCode: 404, responseHeaders }) diff --git a/packages/dd-trace/test/appsec/passport.spec.js b/packages/dd-trace/test/appsec/passport.spec.js deleted file mode 100644 index 7a3db36798c..00000000000 --- a/packages/dd-trace/test/appsec/passport.spec.js +++ /dev/null @@ -1,245 +0,0 @@ -'use strict' - -const proxyquire = require('proxyquire') - -describe('Passport', () => { - const rootSpan = { - context: () => { return {} } - } - const loginLocal = { type: 'local', username: 'test' } - const userUuid = { - id: '591dc126-8431-4d0f-9509-b23318d3dce4', - email: 'testUser@test.com', - username: 'Test User' - } - - let passportModule, log, events, setUser - - beforeEach(() => { - rootSpan.context = () => { return {} } - - log = { - warn: sinon.stub() - } - - events = { - trackEvent: sinon.stub() - } - - setUser = { - setUserTags: sinon.stub() - } - - passportModule = proxyquire('../../src/appsec/passport', { - '../log': log, - './sdk/track_event': events, - './sdk/set_user': setUser - }) - }) - - describe('passportTrackEvent', () => { - it('should call log when credentials is undefined', () => { - passportModule.passportTrackEvent(undefined, undefined, undefined, 'safe') - - expect(log.warn).to.have.been.calledOnceWithExactly('No user ID found in authentication instrumentation') - }) - - it('should call log when type is not known', () => { - const credentials = { type: 'unknown', username: 'test' } - - passportModule.passportTrackEvent(credentials, undefined, undefined, 'safe') - - expect(log.warn).to.have.been.calledOnceWithExactly('No user ID found in authentication instrumentation') - }) - - it('should call log when type is known but username not present', () => { - const credentials = { type: 'http' } - - passportModule.passportTrackEvent(credentials, undefined, undefined, 'safe') - - expect(log.warn).to.have.been.calledOnceWithExactly('No user ID found in authentication instrumentation') - }) - - it('should report login failure when passportUser is not present', () => { - passportModule.passportTrackEvent(loginLocal, undefined, undefined, 'safe') - - expect(setUser.setUserTags).not.to.have.been.called - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.failure', - { 'usr.id': '' }, - 'passportTrackEvent', - undefined, - 'safe' - ) - }) - - it('should report login success when passportUser is present', () => { - passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'safe') - - expect(setUser.setUserTags).to.have.been.calledOnceWithExactly({ id: userUuid.id }, rootSpan) - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.success', - null, - 'passportTrackEvent', - rootSpan, - 'safe' - ) - }) - - it('should report login success and blank id in safe mode when id is not a uuid', () => { - const user = { - id: 'publicName', - email: 'testUser@test.com', - username: 'Test User' - } - passportModule.passportTrackEvent(loginLocal, user, rootSpan, 'safe') - - expect(setUser.setUserTags).to.have.been.calledOnceWithExactly({ id: '' }, rootSpan) - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.success', - null, - 'passportTrackEvent', - rootSpan, - 'safe' - ) - }) - - it('should report login success and the extended fields in extended mode', () => { - const user = { - id: 'publicName', - email: 'testUser@test.com', - username: 'Test User' - } - - passportModule.passportTrackEvent(loginLocal, user, rootSpan, 'extended') - expect(setUser.setUserTags).to.have.been.calledOnceWithExactly( - { - id: 'publicName', - login: 'test', - email: 'testUser@test.com', - username: 'Test User' - }, - rootSpan - ) - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.success', - null, - 'passportTrackEvent', - rootSpan, - 'extended' - ) - }) - - it('should not call trackEvent in safe mode if sdk user event functions are already called', () => { - rootSpan.context = () => { - return { - _tags: { - '_dd.appsec.events.users.login.success.sdk': 'true' - } - } - } - - passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'safe') - expect(setUser.setUserTags).not.to.have.been.called - expect(events.trackEvent).not.to.have.been.called - }) - - it('should not call trackEvent in extended mode if trackUserLoginSuccessEvent is already called', () => { - rootSpan.context = () => { - return { - _tags: { - '_dd.appsec.events.users.login.success.sdk': 'true' - } - } - } - - passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'extended') - expect(setUser.setUserTags).not.to.have.been.called - expect(events.trackEvent).not.to.have.been.called - }) - - it('should call trackEvent in extended mode if trackCustomEvent function is already called', () => { - rootSpan.context = () => { - return { - _tags: { - '_dd.appsec.events.custom.event.sdk': 'true' - } - } - } - - passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'extended') - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.success', - null, - 'passportTrackEvent', - rootSpan, - 'extended' - ) - }) - - it('should not call trackEvent in extended mode if trackUserLoginFailureEvent is already called', () => { - rootSpan.context = () => { - return { - _tags: { - '_dd.appsec.events.users.login.failure.sdk': 'true' - } - } - } - - passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'extended') - expect(setUser.setUserTags).not.to.have.been.called - expect(events.trackEvent).not.to.have.been.called - }) - - it('should report login success with the _id field', () => { - const user = { - _id: '591dc126-8431-4d0f-9509-b23318d3dce4', - email: 'testUser@test.com', - username: 'Test User' - } - - passportModule.passportTrackEvent(loginLocal, user, rootSpan, 'extended') - expect(setUser.setUserTags).to.have.been.calledOnceWithExactly( - { - id: '591dc126-8431-4d0f-9509-b23318d3dce4', - login: 'test', - email: 'testUser@test.com', - username: 'Test User' - }, - rootSpan - ) - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.success', - null, - 'passportTrackEvent', - rootSpan, - 'extended' - ) - }) - - it('should report login success with the username field passport name', () => { - const user = { - email: 'testUser@test.com', - name: 'Test User' - } - - rootSpan.context = () => { return {} } - - passportModule.passportTrackEvent(loginLocal, user, rootSpan, 'extended') - expect(setUser.setUserTags).to.have.been.calledOnceWithExactly( - { - id: 'test', - login: 'test', - email: 'testUser@test.com', - username: 'Test User' - }, rootSpan) - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.success', - null, - 'passportTrackEvent', - rootSpan, - 'extended' - ) - }) - }) -}) diff --git a/packages/dd-trace/test/appsec/rasp/command_injection.express.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/command_injection.express.plugin.spec.js index 3943bd0c3c3..d7609367ab9 100644 --- a/packages/dd-trace/test/appsec/rasp/command_injection.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/rasp/command_injection.express.plugin.spec.js @@ -5,42 +5,25 @@ const appsec = require('../../../src/appsec') const Config = require('../../../src/config') const path = require('path') const Axios = require('axios') -const { getWebSpan, checkRaspExecutedAndHasThreat, checkRaspExecutedAndNotThreat } = require('./utils') +const { checkRaspExecutedAndHasThreat, checkRaspExecutedAndNotThreat } = require('./utils') const { assert } = require('chai') describe('RASP - command_injection', () => { withVersions('express', 'express', expressVersion => { let app, server, axios + function testShellBlockingAndSafeRequests () { + it('should block the threat', async () => { + try { + await axios.get('/?dir=$(cat /etc/passwd 1>%262 ; echo .)') + } catch (e) { + if (!e.response) { + throw e + } - async function testBlockingRequest () { - try { - await axios.get('/?dir=$(cat /etc/passwd 1>%262 ; echo .)') - } catch (e) { - if (!e.response) { - throw e - } - - return checkRaspExecutedAndHasThreat(agent, 'rasp-command_injection-rule-id-3') - } - - assert.fail('Request should be blocked') - } - - function checkRaspNotExecutedAndNotThreat (agent, checkRuleEval = true) { - return agent.use((traces) => { - const span = getWebSpan(traces) - - assert.notProperty(span.meta, '_dd.appsec.json') - assert.notProperty(span.meta_struct || {}, '_dd.stack') - if (checkRuleEval) { - assert.notProperty(span.metrics, '_dd.appsec.rasp.rule.eval') + return checkRaspExecutedAndHasThreat(agent, 'rasp-command_injection-rule-id-3') } - }) - } - function testBlockingAndSafeRequests () { - it('should block the threat', async () => { - await testBlockingRequest() + assert.fail('Request should be blocked') }) it('should not block safe request', async () => { @@ -50,17 +33,25 @@ describe('RASP - command_injection', () => { }) } - function testSafeInNonShell () { - it('should not block the threat', async () => { - await axios.get('/?dir=$(cat /etc/passwd 1>%262 ; echo .)') + function testNonShellBlockingAndSafeRequests () { + it('should block the threat', async () => { + try { + await axios.get('/?command=/usr/bin/reboot') + } catch (e) { + if (!e.response) { + throw e + } - return checkRaspNotExecutedAndNotThreat(agent) + return checkRaspExecutedAndHasThreat(agent, 'rasp-command_injection-rule-id-4') + } + + assert.fail('Request should be blocked') }) it('should not block safe request', async () => { - await axios.get('/?dir=.') + await axios.get('/?command=.') - return checkRaspNotExecutedAndNotThreat(agent) + return checkRaspExecutedAndNotThreat(agent) }) } @@ -116,7 +107,7 @@ describe('RASP - command_injection', () => { } }) - testBlockingAndSafeRequests() + testShellBlockingAndSafeRequests() }) describe('with promise', () => { @@ -137,7 +128,7 @@ describe('RASP - command_injection', () => { } }) - testBlockingAndSafeRequests() + testShellBlockingAndSafeRequests() }) describe('with event emitter', () => { @@ -158,7 +149,7 @@ describe('RASP - command_injection', () => { } }) - testBlockingAndSafeRequests() + testShellBlockingAndSafeRequests() }) describe('execSync', () => { @@ -178,7 +169,7 @@ describe('RASP - command_injection', () => { } }) - testBlockingAndSafeRequests() + testShellBlockingAndSafeRequests() }) }) @@ -199,7 +190,7 @@ describe('RASP - command_injection', () => { } }) - testBlockingAndSafeRequests() + testShellBlockingAndSafeRequests() }) describe('with promise', () => { @@ -220,7 +211,7 @@ describe('RASP - command_injection', () => { } }) - testBlockingAndSafeRequests() + testShellBlockingAndSafeRequests() }) describe('with event emitter', () => { @@ -241,7 +232,7 @@ describe('RASP - command_injection', () => { } }) - testBlockingAndSafeRequests() + testShellBlockingAndSafeRequests() }) describe('execFileSync', () => { @@ -261,7 +252,7 @@ describe('RASP - command_injection', () => { } }) - testBlockingAndSafeRequests() + testShellBlockingAndSafeRequests() }) }) @@ -271,7 +262,7 @@ describe('RASP - command_injection', () => { app = (req, res) => { const childProcess = require('child_process') - childProcess.execFile('ls', [req.query.dir], function (e) { + childProcess.execFile(req.query.command, function (e) { if (e?.name === 'DatadogRaspAbortError') { res.writeHead(500) } @@ -281,7 +272,7 @@ describe('RASP - command_injection', () => { } }) - testSafeInNonShell() + testNonShellBlockingAndSafeRequests() }) describe('with promise', () => { @@ -291,7 +282,7 @@ describe('RASP - command_injection', () => { const execFile = util.promisify(require('child_process').execFile) try { - await execFile('ls', [req.query.dir]) + await execFile([req.query.command]) } catch (e) { if (e.name === 'DatadogRaspAbortError') { res.writeHead(500) @@ -302,15 +293,14 @@ describe('RASP - command_injection', () => { } }) - testSafeInNonShell() + testNonShellBlockingAndSafeRequests() }) describe('with event emitter', () => { beforeEach(() => { app = (req, res) => { const childProcess = require('child_process') - - const child = childProcess.execFile('ls', [req.query.dir]) + const child = childProcess.execFile(req.query.command) child.on('error', (e) => { if (e.name === 'DatadogRaspAbortError') { res.writeHead(500) @@ -323,7 +313,7 @@ describe('RASP - command_injection', () => { } }) - testSafeInNonShell() + testNonShellBlockingAndSafeRequests() }) describe('execFileSync', () => { @@ -332,7 +322,7 @@ describe('RASP - command_injection', () => { const childProcess = require('child_process') try { - childProcess.execFileSync('ls', [req.query.dir]) + childProcess.execFileSync([req.query.command]) } catch (e) { if (e.name === 'DatadogRaspAbortError') { res.writeHead(500) @@ -343,7 +333,7 @@ describe('RASP - command_injection', () => { } }) - testSafeInNonShell() + testNonShellBlockingAndSafeRequests() }) }) }) @@ -368,7 +358,7 @@ describe('RASP - command_injection', () => { } }) - testBlockingAndSafeRequests() + testShellBlockingAndSafeRequests() }) describe('spawnSync', () => { @@ -385,7 +375,7 @@ describe('RASP - command_injection', () => { } }) - testBlockingAndSafeRequests() + testShellBlockingAndSafeRequests() }) }) @@ -395,7 +385,7 @@ describe('RASP - command_injection', () => { app = (req, res) => { const childProcess = require('child_process') - const child = childProcess.spawn('ls', [req.query.dir]) + const child = childProcess.spawn(req.query.command) child.on('error', (e) => { if (e.name === 'DatadogRaspAbortError') { res.writeHead(500) @@ -408,7 +398,7 @@ describe('RASP - command_injection', () => { } }) - testSafeInNonShell() + testNonShellBlockingAndSafeRequests() }) describe('spawnSync', () => { @@ -416,7 +406,7 @@ describe('RASP - command_injection', () => { app = (req, res) => { const childProcess = require('child_process') - const child = childProcess.spawnSync('ls', [req.query.dir]) + const child = childProcess.spawnSync(req.query.command) if (child.error?.name === 'DatadogRaspAbortError') { res.writeHead(500) } @@ -425,7 +415,7 @@ describe('RASP - command_injection', () => { } }) - testSafeInNonShell() + testNonShellBlockingAndSafeRequests() }) }) }) diff --git a/packages/dd-trace/test/appsec/rasp/command_injection.integration.spec.js b/packages/dd-trace/test/appsec/rasp/command_injection.integration.spec.js index 4ebb8c4910a..d6fe4015202 100644 --- a/packages/dd-trace/test/appsec/rasp/command_injection.integration.spec.js +++ b/packages/dd-trace/test/appsec/rasp/command_injection.integration.spec.js @@ -42,6 +42,7 @@ describe('RASP - command_injection - integration', () => { APP_PORT: appPort, DD_APPSEC_ENABLED: 'true', DD_APPSEC_RASP_ENABLED: 'true', + DD_TELEMETRY_HEARTBEAT_INTERVAL: 1, DD_APPSEC_RULES: path.join(cwd, 'resources', 'rasp_rules.json') } }) @@ -52,7 +53,7 @@ describe('RASP - command_injection - integration', () => { await agent.stop() }) - async function testRequestBlocked (url) { + async function testRequestBlocked (url, ruleId = 3, variant = 'shell') { try { await axios.get(url) } catch (e) { @@ -61,28 +62,72 @@ describe('RASP - command_injection - integration', () => { } assert.strictEqual(e.response.status, 403) - return await agent.assertMessageReceived(({ headers, payload }) => { + + let appsecTelemetryReceived = false + + const checkMessages = await agent.assertMessageReceived(({ headers, payload }) => { assert.property(payload[0][0].meta, '_dd.appsec.json') - assert.include(payload[0][0].meta['_dd.appsec.json'], '"rasp-command_injection-rule-id-3"') + assert.include(payload[0][0].meta['_dd.appsec.json'], `"rasp-command_injection-rule-id-${ruleId}"`) }) + + const checkTelemetry = await agent.assertTelemetryReceived(({ headers, payload }) => { + const namespace = payload.payload.namespace + + // Only check telemetry received in appsec namespace and ignore others + if (namespace === 'appsec') { + appsecTelemetryReceived = true + const series = payload.payload.series + const evalSerie = series.find(s => s.metric === 'rasp.rule.eval') + const matchSerie = series.find(s => s.metric === 'rasp.rule.match') + + assert.exists(evalSerie, 'eval serie should exist') + assert.include(evalSerie.tags, 'rule_type:command_injection') + assert.include(evalSerie.tags, `rule_variant:${variant}`) + assert.strictEqual(evalSerie.type, 'count') + + assert.exists(matchSerie, 'match serie should exist') + assert.include(matchSerie.tags, 'rule_type:command_injection') + assert.include(matchSerie.tags, `rule_variant:${variant}`) + assert.strictEqual(matchSerie.type, 'count') + } + }, 30_000, 'generate-metrics', 2) + + const checks = await Promise.all([checkMessages, checkTelemetry]) + assert.equal(appsecTelemetryReceived, true) + + return checks } throw new Error('Request should be blocked') } - it('should block using execFileSync and exception handled by express', async () => { - await testRequestBlocked('/shi/execFileSync?dir=$(cat /etc/passwd 1>%262 ; echo .)') - }) + describe('with shell', () => { + it('should block using execFileSync and exception handled by express', async () => { + await testRequestBlocked('/shi/execFileSync?dir=$(cat /etc/passwd 1>%262 ; echo .)') + }) - it('should block using execFileSync and unhandled exception', async () => { - await testRequestBlocked('/shi/execFileSync/out-of-express-scope?dir=$(cat /etc/passwd 1>%262 ; echo .)') - }) + it('should block using execFileSync and unhandled exception', async () => { + await testRequestBlocked('/shi/execFileSync/out-of-express-scope?dir=$(cat /etc/passwd 1>%262 ; echo .)') + }) + + it('should block using execSync and exception handled by express', async () => { + await testRequestBlocked('/shi/execSync?dir=$(cat /etc/passwd 1>%262 ; echo .)') + }) - it('should block using execSync and exception handled by express', async () => { - await testRequestBlocked('/shi/execSync?dir=$(cat /etc/passwd 1>%262 ; echo .)') + it('should block using execSync and unhandled exception', async () => { + await testRequestBlocked('/shi/execSync/out-of-express-scope?dir=$(cat /etc/passwd 1>%262 ; echo .)') + }) }) - it('should block using execSync and unhandled exception', async () => { - await testRequestBlocked('/shi/execSync/out-of-express-scope?dir=$(cat /etc/passwd 1>%262 ; echo .)') + describe('without shell', () => { + it('should block using execFileSync and exception handled by express', async () => { + await testRequestBlocked('/cmdi/execFileSync?command=cat /etc/passwd 1>&2 ; echo .', 4, 'exec') + }) + + it('should block using execFileSync and unhandled exception', async () => { + await testRequestBlocked( + '/cmdi/execFileSync/out-of-express-scope?command=cat /etc/passwd 1>&2 ; echo .', 4, 'exec' + ) + }) }) }) diff --git a/packages/dd-trace/test/appsec/rasp/command_injection.spec.js b/packages/dd-trace/test/appsec/rasp/command_injection.spec.js index 785b155a113..bf920940c7a 100644 --- a/packages/dd-trace/test/appsec/rasp/command_injection.spec.js +++ b/packages/dd-trace/test/appsec/rasp/command_injection.spec.js @@ -49,49 +49,6 @@ describe('RASP - command_injection.js', () => { }) describe('analyzeCommandInjection', () => { - it('should analyze command_injection without arguments', () => { - const ctx = { - file: 'cmd', - shell: true - } - const req = {} - datadogCore.storage.getStore.returns({ req }) - - start.publish(ctx) - - const persistent = { [addresses.SHELL_COMMAND]: 'cmd' } - sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, 'command_injection') - }) - - it('should analyze command_injection with arguments', () => { - const ctx = { - file: 'cmd', - fileArgs: ['arg0', 'arg1'], - shell: true - } - const req = {} - datadogCore.storage.getStore.returns({ req }) - - start.publish(ctx) - - const persistent = { [addresses.SHELL_COMMAND]: ['cmd', 'arg0', 'arg1'] } - sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, 'command_injection') - }) - - it('should not analyze command_injection when it is not shell', () => { - const ctx = { - file: 'cmd', - fileArgs: ['arg0', 'arg1'], - shell: false - } - const req = {} - datadogCore.storage.getStore.returns({ req }) - - start.publish(ctx) - - sinon.assert.notCalled(waf.run) - }) - it('should not analyze command_injection if rasp is disabled', () => { commandInjection.disable() const ctx = { @@ -139,18 +96,102 @@ describe('RASP - command_injection.js', () => { sinon.assert.notCalled(waf.run) }) - it('should call handleResult', () => { - const abortController = { abort: 'abort' } - const ctx = { file: 'cmd', abortController, shell: true } - const wafResult = { waf: 'waf' } - const req = { req: 'req' } - const res = { res: 'res' } - waf.run.returns(wafResult) - datadogCore.storage.getStore.returns({ req, res }) - - start.publish(ctx) + describe('command_injection with shell', () => { + it('should analyze command_injection without arguments', () => { + const ctx = { + file: 'cmd', + shell: true + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + start.publish(ctx) + + const persistent = { [addresses.SHELL_COMMAND]: 'cmd' } + sinon.assert.calledOnceWithExactly( + waf.run, { persistent }, req, { type: 'command_injection', variant: 'shell' } + ) + }) + + it('should analyze command_injection with arguments', () => { + const ctx = { + file: 'cmd', + fileArgs: ['arg0', 'arg1'], + shell: true + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + start.publish(ctx) + + const persistent = { [addresses.SHELL_COMMAND]: ['cmd', 'arg0', 'arg1'] } + sinon.assert.calledOnceWithExactly( + waf.run, { persistent }, req, { type: 'command_injection', variant: 'shell' } + ) + }) + + it('should call handleResult', () => { + const abortController = { abort: 'abort' } + const ctx = { file: 'cmd', abortController, shell: true } + const wafResult = { waf: 'waf' } + const req = { req: 'req' } + const res = { res: 'res' } + waf.run.returns(wafResult) + datadogCore.storage.getStore.returns({ req, res }) + + start.publish(ctx) + + sinon.assert.calledOnceWithExactly(utils.handleResult, wafResult, req, res, abortController, config) + }) + }) - sinon.assert.calledOnceWithExactly(utils.handleResult, wafResult, req, res, abortController, config) + describe('command_injection without shell', () => { + it('should analyze command injection without arguments', () => { + const ctx = { + file: 'ls', + shell: false + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + start.publish(ctx) + + const persistent = { [addresses.EXEC_COMMAND]: ['ls'] } + sinon.assert.calledOnceWithExactly( + waf.run, { persistent }, req, { type: 'command_injection', variant: 'exec' } + ) + }) + + it('should analyze command injection with arguments', () => { + const ctx = { + file: 'ls', + fileArgs: ['-la', '/tmp'], + shell: false + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + start.publish(ctx) + + const persistent = { [addresses.EXEC_COMMAND]: ['ls', '-la', '/tmp'] } + sinon.assert.calledOnceWithExactly( + waf.run, { persistent }, req, { type: 'command_injection', variant: 'exec' } + ) + }) + + it('should call handleResult', () => { + const abortController = { abort: 'abort' } + const ctx = { file: 'cmd', abortController, shell: false } + const wafResult = { waf: 'waf' } + const req = { req: 'req' } + const res = { res: 'res' } + waf.run.returns(wafResult) + datadogCore.storage.getStore.returns({ req, res }) + + start.publish(ctx) + + sinon.assert.calledOnceWithExactly(utils.handleResult, wafResult, req, res, abortController, config) + }) }) }) }) diff --git a/packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js index b5b825cc628..210c3849ece 100644 --- a/packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js @@ -102,7 +102,7 @@ describe('RASP - lfi', () => { describe(description, () => { const getAppFn = options.getAppFn ?? getApp - it('should block param from the request', async () => { + it('should block param from the request', () => { app = getAppFn(fn, args, options) const file = args[vulnerableIndex] diff --git a/packages/dd-trace/test/appsec/rasp/lfi.spec.js b/packages/dd-trace/test/appsec/rasp/lfi.spec.js index 405311ae0d3..0a1328e2c52 100644 --- a/packages/dd-trace/test/appsec/rasp/lfi.spec.js +++ b/packages/dd-trace/test/appsec/rasp/lfi.spec.js @@ -111,7 +111,7 @@ describe('RASP - lfi.js', () => { fsOperationStart.publish(ctx) const persistent = { [FS_OPERATION_PATH]: path } - sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, 'lfi') + sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, { type: 'lfi' }) }) it('should NOT analyze lfi for child fs operations', () => { diff --git a/packages/dd-trace/test/appsec/rasp/resources/rasp_rules.json b/packages/dd-trace/test/appsec/rasp/resources/rasp_rules.json index daca47d8d20..c0396bd9871 100644 --- a/packages/dd-trace/test/appsec/rasp/resources/rasp_rules.json +++ b/packages/dd-trace/test/appsec/rasp/resources/rasp_rules.json @@ -110,7 +110,7 @@ }, { "id": "rasp-command_injection-rule-id-3", - "name": "Command injection exploit", + "name": "Shell command injection exploit", "tags": { "type": "command_injection", "category": "vulnerability_trigger", @@ -156,6 +156,55 @@ "block", "stack_trace" ] + }, + { + "id": "rasp-command_injection-rule-id-4", + "name": "OS command injection exploit", + "tags": { + "type": "command_injection", + "category": "vulnerability_trigger", + "cwe": "77", + "capec": "1000/152/248/88", + "confidence": "0", + "module": "rasp" + }, + "conditions": [ + { + "parameters": { + "resource": [ + { + "address": "server.sys.exec.cmd" + } + ], + "params": [ + { + "address": "server.request.query" + }, + { + "address": "server.request.body" + }, + { + "address": "server.request.path_params" + }, + { + "address": "grpc.server.request.message" + }, + { + "address": "graphql.server.all_resolvers" + }, + { + "address": "graphql.server.resolver" + } + ] + }, + "operator": "cmdi_detector" + } + ], + "transformers": [], + "on_match": [ + "block", + "stack_trace" + ] } ] } diff --git a/packages/dd-trace/test/appsec/rasp/resources/shi-app/index.js b/packages/dd-trace/test/appsec/rasp/resources/shi-app/index.js index a6714bd2148..133c57dfb2b 100644 --- a/packages/dd-trace/test/appsec/rasp/resources/shi-app/index.js +++ b/packages/dd-trace/test/appsec/rasp/resources/shi-app/index.js @@ -39,6 +39,20 @@ app.get('/shi/execSync/out-of-express-scope', async (req, res) => { }) }) +app.get('/cmdi/execFileSync', async (req, res) => { + childProcess.execFileSync('sh', ['-c', req.query.command]) + + res.end('OK') +}) + +app.get('/cmdi/execFileSync/out-of-express-scope', async (req, res) => { + process.nextTick(() => { + childProcess.execFileSync('sh', ['-c', req.query.command]) + + res.end('OK') + }) +}) + app.listen(port, () => { process.send({ port }) }) diff --git a/packages/dd-trace/test/appsec/rasp/sql_injection.pg.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/sql_injection.pg.plugin.spec.js index 8f05158c22d..2d4dd779c17 100644 --- a/packages/dd-trace/test/appsec/rasp/sql_injection.pg.plugin.spec.js +++ b/packages/dd-trace/test/appsec/rasp/sql_injection.pg.plugin.spec.js @@ -219,7 +219,7 @@ describe('RASP - sql_injection', () => { await axios.get('/') - assert.equal(run.args.filter(arg => arg[1] === 'sql_injection').length, 1) + assert.equal(run.args.filter(arg => arg[1]?.type === 'sql_injection').length, 1) }) it('should call to waf twice for sql injection with two different queries in pg Pool', async () => { @@ -232,7 +232,7 @@ describe('RASP - sql_injection', () => { await axios.get('/') - assert.equal(run.args.filter(arg => arg[1] === 'sql_injection').length, 2) + assert.equal(run.args.filter(arg => arg[1]?.type === 'sql_injection').length, 2) }) it('should call to waf twice for sql injection and same query when input address is updated', async () => { @@ -254,7 +254,7 @@ describe('RASP - sql_injection', () => { await axios.get('/') - assert.equal(run.args.filter(arg => arg[1] === 'sql_injection').length, 2) + assert.equal(run.args.filter(arg => arg[1]?.type === 'sql_injection').length, 2) }) it('should call to waf once for sql injection and same query when input address is updated', async () => { @@ -276,7 +276,7 @@ describe('RASP - sql_injection', () => { await axios.get('/') - assert.equal(run.args.filter(arg => arg[1] === 'sql_injection').length, 1) + assert.equal(run.args.filter(arg => arg[1]?.type === 'sql_injection').length, 1) }) }) }) diff --git a/packages/dd-trace/test/appsec/rasp/sql_injection.spec.js b/packages/dd-trace/test/appsec/rasp/sql_injection.spec.js index d713521e986..fe7c9af082d 100644 --- a/packages/dd-trace/test/appsec/rasp/sql_injection.spec.js +++ b/packages/dd-trace/test/appsec/rasp/sql_injection.spec.js @@ -57,7 +57,7 @@ describe('RASP - sql_injection', () => { [addresses.DB_STATEMENT]: 'SELECT 1', [addresses.DB_SYSTEM]: 'postgresql' } - sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, 'sql_injection') + sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, { type: 'sql_injection' }) }) it('should not analyze sql injection if rasp is disabled', () => { @@ -128,7 +128,7 @@ describe('RASP - sql_injection', () => { [addresses.DB_STATEMENT]: 'SELECT 1', [addresses.DB_SYSTEM]: 'mysql' } - sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, 'sql_injection') + sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, { type: 'sql_injection' }) }) it('should not analyze sql injection if rasp is disabled', () => { diff --git a/packages/dd-trace/test/appsec/rasp/ssrf.spec.js b/packages/dd-trace/test/appsec/rasp/ssrf.spec.js index c40867ea254..98d5c8a0104 100644 --- a/packages/dd-trace/test/appsec/rasp/ssrf.spec.js +++ b/packages/dd-trace/test/appsec/rasp/ssrf.spec.js @@ -54,7 +54,7 @@ describe('RASP - ssrf.js', () => { httpClientRequestStart.publish(ctx) const persistent = { [addresses.HTTP_OUTGOING_URL]: 'http://example.com' } - sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, 'ssrf') + sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, { type: 'ssrf' }) }) it('should not analyze ssrf if rasp is disabled', () => { diff --git a/packages/dd-trace/test/appsec/remote_config/index.spec.js b/packages/dd-trace/test/appsec/remote_config/index.spec.js index 67447cf7a69..4d296d100d1 100644 --- a/packages/dd-trace/test/appsec/remote_config/index.spec.js +++ b/packages/dd-trace/test/appsec/remote_config/index.spec.js @@ -7,6 +7,8 @@ let config let rc let RemoteConfigManager let RuleManager +let UserTracking +let log let appsec let remoteConfig @@ -14,7 +16,10 @@ describe('Remote Config index', () => { beforeEach(() => { config = { appsec: { - enabled: undefined + enabled: undefined, + eventTracking: { + mode: 'identification' + } } } @@ -32,6 +37,14 @@ describe('Remote Config index', () => { updateWafFromRC: sinon.stub() } + UserTracking = { + setCollectionMode: sinon.stub() + } + + log = { + error: sinon.stub() + } + appsec = { enable: sinon.spy(), disable: sinon.spy() @@ -40,40 +53,48 @@ describe('Remote Config index', () => { remoteConfig = proxyquire('../src/appsec/remote_config', { './manager': RemoteConfigManager, '../rule_manager': RuleManager, + '../user_tracking': UserTracking, + '../../log': log, '..': appsec }) }) describe('enable', () => { it('should listen to remote config when appsec is not explicitly configured', () => { - config.appsec = { enabled: undefined } + config.appsec.enabled = undefined remoteConfig.enable(config) expect(RemoteConfigManager).to.have.been.calledOnceWithExactly(config) expect(rc.updateCapabilities).to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_ACTIVATION, true) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_AUTO_USER_INSTRUM_MODE, true) expect(rc.setProductHandler).to.have.been.calledWith('ASM_FEATURES') expect(rc.setProductHandler.firstCall.args[1]).to.be.a('function') }) it('should listen to remote config when appsec is explicitly configured as enabled=true', () => { - config.appsec = { enabled: true } + config.appsec.enabled = true remoteConfig.enable(config) expect(RemoteConfigManager).to.have.been.calledOnceWithExactly(config) - expect(rc.updateCapabilities).to.not.have.been.calledWith('ASM_ACTIVATION') + expect(rc.updateCapabilities).to.not.have.been.calledWith(RemoteConfigCapabilities.ASM_ACTIVATION) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_AUTO_USER_INSTRUM_MODE, true) expect(rc.setProductHandler).to.have.been.calledOnceWith('ASM_FEATURES') expect(rc.setProductHandler.firstCall.args[1]).to.be.a('function') }) it('should not listen to remote config when appsec is explicitly configured as enabled=false', () => { - config.appsec = { enabled: false } + config.appsec.enabled = false remoteConfig.enable(config) expect(RemoteConfigManager).to.have.been.calledOnceWithExactly(config) expect(rc.updateCapabilities).to.not.have.been.calledWith(RemoteConfigCapabilities.ASM_ACTIVATION, true) + expect(rc.updateCapabilities) + .to.not.have.been.calledWith(RemoteConfigCapabilities.ASM_AUTO_USER_INSTRUM_MODE, true) expect(rc.setProductHandler).to.not.have.been.called }) @@ -81,8 +102,6 @@ describe('Remote Config index', () => { let listener beforeEach(() => { - config.appsec = { enabled: undefined } - remoteConfig.enable(config, appsec) listener = rc.setProductHandler.firstCall.args[1] @@ -100,8 +119,8 @@ describe('Remote Config index', () => { expect(appsec.enable).to.have.been.called }) - it('should disable appsec when listener is called with unnaply and enabled', () => { - listener('unnaply', { asm: { enabled: true } }) + it('should disable appsec when listener is called with unapply and enabled', () => { + listener('unapply', { asm: { enabled: true } }) expect(appsec.disable).to.have.been.calledOnce }) @@ -112,6 +131,60 @@ describe('Remote Config index', () => { expect(appsec.enable).to.not.have.been.called expect(appsec.disable).to.not.have.been.called }) + + describe('auto_user_instrum', () => { + const rcConfig = { auto_user_instrum: { mode: 'anonymous' } } + const configId = 'collectionModeId' + + afterEach(() => { + listener('unapply', rcConfig, configId) + }) + + it('should not update collection mode when not a string', () => { + listener('apply', { auto_user_instrum: { mode: 123 } }, configId) + + expect(UserTracking.setCollectionMode).to.not.have.been.called + }) + + it('should throw when called two times with different config ids', () => { + listener('apply', rcConfig, configId) + + expect(() => listener('apply', rcConfig, 'anotherId')).to.throw() + expect(log.error).to.have.been.calledOnceWithExactly( + '[RC] Multiple auto_user_instrum received in ASM_FEATURES. Discarding config' + ) + }) + + it('should update collection mode when called with apply', () => { + listener('apply', rcConfig, configId) + + expect(UserTracking.setCollectionMode).to.have.been.calledOnceWithExactly(rcConfig.auto_user_instrum.mode) + }) + + it('should update collection mode when called with modify', () => { + listener('modify', rcConfig, configId) + + expect(UserTracking.setCollectionMode).to.have.been.calledOnceWithExactly(rcConfig.auto_user_instrum.mode) + }) + + it('should revert collection mode when called with unapply', () => { + listener('apply', rcConfig, configId) + UserTracking.setCollectionMode.resetHistory() + + listener('unapply', rcConfig, configId) + + expect(UserTracking.setCollectionMode).to.have.been.calledOnceWithExactly(config.appsec.eventTracking.mode) + }) + + it('should not revert collection mode when called with unapply and unknown id', () => { + listener('apply', rcConfig, configId) + UserTracking.setCollectionMode.resetHistory() + + listener('unapply', rcConfig, 'unknownId') + + expect(UserTracking.setCollectionMode).to.not.have.been.called + }) + }) }) }) @@ -171,6 +244,8 @@ describe('Remote Config index', () => { .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI, true) expect(rc.updateCapabilities) .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SHI, true) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_CMDI, true) expect(rc.setProductHandler).to.have.been.calledWith('ASM_DATA') expect(rc.setProductHandler).to.have.been.calledWith('ASM_DD') @@ -215,6 +290,8 @@ describe('Remote Config index', () => { .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI, true) expect(rc.updateCapabilities) .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SHI, true) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_CMDI, true) expect(rc.setProductHandler).to.have.been.calledWith('ASM_DATA') expect(rc.setProductHandler).to.have.been.calledWith('ASM_DD') @@ -261,6 +338,8 @@ describe('Remote Config index', () => { .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI, true) expect(rc.updateCapabilities) .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SHI, true) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_CMDI, true) }) it('should not activate rasp capabilities if rasp is disabled', () => { @@ -302,6 +381,8 @@ describe('Remote Config index', () => { .to.not.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI) expect(rc.updateCapabilities) .to.not.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SHI) + expect(rc.updateCapabilities) + .to.not.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_CMDI) }) }) @@ -343,6 +424,8 @@ describe('Remote Config index', () => { .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI, false) expect(rc.updateCapabilities) .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SHI, false) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_CMDI, false) expect(rc.removeProductHandler).to.have.been.calledWith('ASM_DATA') expect(rc.removeProductHandler).to.have.been.calledWith('ASM_DD') diff --git a/packages/dd-trace/test/appsec/reporter.spec.js b/packages/dd-trace/test/appsec/reporter.spec.js index 757884c3566..a38092e728d 100644 --- a/packages/dd-trace/test/appsec/reporter.spec.js +++ b/packages/dd-trace/test/appsec/reporter.spec.js @@ -192,13 +192,15 @@ describe('reporter', () => { expect(telemetry.updateRaspRequestsMetricTags).to.not.have.been.called }) - it('should call updateRaspRequestsMetricTags when ruleType if provided', () => { + it('should call updateRaspRequestsMetricTags when raspRule is provided', () => { const metrics = { rulesVersion: '1.2.3' } const store = storage.getStore() - Reporter.reportMetrics(metrics, 'rule_type') + const raspRule = { type: 'rule_type', variant: 'rule_variant' } - expect(telemetry.updateRaspRequestsMetricTags).to.have.been.calledOnceWithExactly(metrics, store.req, 'rule_type') + Reporter.reportMetrics(metrics, raspRule) + + expect(telemetry.updateRaspRequestsMetricTags).to.have.been.calledOnceWithExactly(metrics, store.req, raspRule) expect(telemetry.updateWafRequestsMetricTags).to.not.have.been.called }) }) @@ -223,6 +225,22 @@ describe('reporter', () => { storage.disable() }) + it('should add tags to request span when socket is not there', () => { + delete req.socket + + const result = Reporter.reportAttack('[{"rule":{},"rule_matches":[{}]}]') + + expect(result).to.not.be.false + expect(web.root).to.have.been.calledOnceWith(req) + + expect(span.addTags).to.have.been.calledOnceWithExactly({ + 'appsec.event': 'true', + '_dd.origin': 'appsec', + '_dd.appsec.json': '{"triggers":[{"rule":{},"rule_matches":[{}]}]}' + }) + expect(prioritySampler.setPriority).to.have.been.calledOnceWithExactly(span, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + }) + it('should add tags to request span', () => { const result = Reporter.reportAttack('[{"rule":{},"rule_matches":[{}]}]') expect(result).to.not.be.false diff --git a/packages/dd-trace/test/appsec/sdk/track_event.spec.js b/packages/dd-trace/test/appsec/sdk/track_event.spec.js index 97f1ac07bd7..8e3c1a177bd 100644 --- a/packages/dd-trace/test/appsec/sdk/track_event.spec.js +++ b/packages/dd-trace/test/appsec/sdk/track_event.spec.js @@ -4,7 +4,7 @@ const proxyquire = require('proxyquire') const agent = require('../../plugins/agent') const axios = require('axios') const tracer = require('../../../../../index') -const { LOGIN_SUCCESS, LOGIN_FAILURE } = require('../../../src/appsec/addresses') +const { LOGIN_SUCCESS, LOGIN_FAILURE, USER_ID, USER_LOGIN } = require('../../../src/appsec/addresses') const { SAMPLING_MECHANISM_APPSEC } = require('../../../src/constants') const { USER_KEEP } = require('../../../../../ext/priority') @@ -12,13 +12,13 @@ describe('track_event', () => { describe('Internal API', () => { const tracer = {} let log + let prioritySampler let rootSpan let getRootSpan let setUserTags - let trackUserLoginSuccessEvent, trackUserLoginFailureEvent, trackCustomEvent, trackEvent let sample let waf - let prioritySampler + let trackUserLoginSuccessEvent, trackUserLoginFailureEvent, trackCustomEvent beforeEach(() => { log = { @@ -62,11 +62,6 @@ describe('track_event', () => { trackUserLoginSuccessEvent = trackEvents.trackUserLoginSuccessEvent trackUserLoginFailureEvent = trackEvents.trackUserLoginFailureEvent trackCustomEvent = trackEvents.trackCustomEvent - trackEvent = trackEvents.trackEvent - }) - - afterEach(() => { - sinon.restore() }) describe('trackUserLoginSuccessEvent', () => { @@ -108,12 +103,21 @@ describe('track_event', () => { { 'appsec.events.users.login.success.track': 'true', '_dd.appsec.events.users.login.success.sdk': 'true', + 'appsec.events.users.login.success.usr.login': 'user_id', 'appsec.events.users.login.success.metakey1': 'metaValue1', 'appsec.events.users.login.success.metakey2': 'metaValue2', 'appsec.events.users.login.success.metakey3': 'metaValue3' }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) + expect(waf.run).to.have.been.calledOnceWithExactly({ + persistent: { + [LOGIN_SUCCESS]: null, + [USER_ID]: 'user_id', + [USER_LOGIN]: 'user_id' + } + }) }) it('should call setUser and addTags without metadata', () => { @@ -125,27 +129,50 @@ describe('track_event', () => { expect(setUserTags).to.have.been.calledOnceWithExactly(user, rootSpan) expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ 'appsec.events.users.login.success.track': 'true', - '_dd.appsec.events.users.login.success.sdk': 'true' + '_dd.appsec.events.users.login.success.sdk': 'true', + 'appsec.events.users.login.success.usr.login': 'user_id' }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) + expect(waf.run).to.have.been.calledOnceWithExactly({ + persistent: { + [LOGIN_SUCCESS]: null, + [USER_ID]: 'user_id', + [USER_LOGIN]: 'user_id' + } + }) }) - it('should call waf run with login success address', () => { - const user = { id: 'user_id' } + it('should call waf with user login', () => { + const user = { id: 'user_id', login: 'user_login' } trackUserLoginSuccessEvent(tracer, user) - sinon.assert.calledOnceWithExactly( - waf.run, - { persistent: { [LOGIN_SUCCESS]: null } } - ) + + expect(log.warn).to.not.have.been.called + expect(setUserTags).to.have.been.calledOnceWithExactly(user, rootSpan) + expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.sdk': 'true', + 'appsec.events.users.login.success.usr.login': 'user_login' + }) + expect(prioritySampler.setPriority) + .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) + expect(waf.run).to.have.been.calledOnceWithExactly({ + persistent: { + [LOGIN_SUCCESS]: null, + [USER_ID]: 'user_id', + [USER_LOGIN]: 'user_login' + } + }) }) }) describe('trackUserLoginFailureEvent', () => { it('should log warning when passed invalid userId', () => { - trackUserLoginFailureEvent(tracer, null, false) - trackUserLoginFailureEvent(tracer, [], false) + trackUserLoginFailureEvent(tracer, null, false, { key: 'value' }) + trackUserLoginFailureEvent(tracer, [], false, { key: 'value' }) expect(log.warn).to.have.been.calledTwice expect(log.warn.firstCall) @@ -159,7 +186,7 @@ describe('track_event', () => { it('should log warning when root span is not available', () => { rootSpan = undefined - trackUserLoginFailureEvent(tracer, 'user_id', false) + trackUserLoginFailureEvent(tracer, 'user_id', false, { key: 'value' }) expect(log.warn) .to.have.been.calledOnceWithExactly('[ASM] Root span not available in %s', 'trackUserLoginFailureEvent') @@ -168,7 +195,9 @@ describe('track_event', () => { it('should call addTags with metadata', () => { trackUserLoginFailureEvent(tracer, 'user_id', true, { - metakey1: 'metaValue1', metakey2: 'metaValue2', metakey3: 'metaValue3' + metakey1: 'metaValue1', + metakey2: 'metaValue2', + metakey3: 'metaValue3' }) expect(log.warn).to.not.have.been.called @@ -177,6 +206,7 @@ describe('track_event', () => { 'appsec.events.users.login.failure.track': 'true', '_dd.appsec.events.users.login.failure.sdk': 'true', 'appsec.events.users.login.failure.usr.id': 'user_id', + 'appsec.events.users.login.failure.usr.login': 'user_id', 'appsec.events.users.login.failure.usr.exists': 'true', 'appsec.events.users.login.failure.metakey1': 'metaValue1', 'appsec.events.users.login.failure.metakey2': 'metaValue2', @@ -184,11 +214,20 @@ describe('track_event', () => { }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) + expect(waf.run).to.have.been.calledOnceWithExactly({ + persistent: { + [LOGIN_FAILURE]: null, + [USER_LOGIN]: 'user_id' + } + }) }) it('should send false `usr.exists` property when the user does not exist', () => { trackUserLoginFailureEvent(tracer, 'user_id', false, { - metakey1: 'metaValue1', metakey2: 'metaValue2', metakey3: 'metaValue3' + metakey1: 'metaValue1', + metakey2: 'metaValue2', + metakey3: 'metaValue3' }) expect(log.warn).to.not.have.been.called @@ -197,6 +236,7 @@ describe('track_event', () => { 'appsec.events.users.login.failure.track': 'true', '_dd.appsec.events.users.login.failure.sdk': 'true', 'appsec.events.users.login.failure.usr.id': 'user_id', + 'appsec.events.users.login.failure.usr.login': 'user_id', 'appsec.events.users.login.failure.usr.exists': 'false', 'appsec.events.users.login.failure.metakey1': 'metaValue1', 'appsec.events.users.login.failure.metakey2': 'metaValue2', @@ -204,6 +244,13 @@ describe('track_event', () => { }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) + expect(waf.run).to.have.been.calledOnceWithExactly({ + persistent: { + [LOGIN_FAILURE]: null, + [USER_LOGIN]: 'user_id' + } + }) }) it('should call addTags without metadata', () => { @@ -215,18 +262,18 @@ describe('track_event', () => { 'appsec.events.users.login.failure.track': 'true', '_dd.appsec.events.users.login.failure.sdk': 'true', 'appsec.events.users.login.failure.usr.id': 'user_id', + 'appsec.events.users.login.failure.usr.login': 'user_id', 'appsec.events.users.login.failure.usr.exists': 'true' }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) - }) - - it('should call waf run with login failure address', () => { - trackUserLoginFailureEvent(tracer, 'user_id') - sinon.assert.calledOnceWithExactly( - waf.run, - { persistent: { [LOGIN_FAILURE]: null } } - ) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) + expect(waf.run).to.have.been.calledOnceWithExactly({ + persistent: { + [LOGIN_FAILURE]: null, + [USER_LOGIN]: 'user_id' + } + }) }) }) @@ -255,7 +302,10 @@ describe('track_event', () => { }) it('should call addTags with metadata', () => { - trackCustomEvent(tracer, 'custom_event', { metaKey1: 'metaValue1', metakey2: 'metaValue2' }) + trackCustomEvent(tracer, 'custom_event', { + metaKey1: 'metaValue1', + metakey2: 'metaValue2' + }) expect(log.warn).to.not.have.been.called expect(setUserTags).to.not.have.been.called @@ -267,6 +317,7 @@ describe('track_event', () => { }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) }) it('should call addTags without metadata', () => { @@ -280,42 +331,6 @@ describe('track_event', () => { }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) - }) - }) - - describe('trackEvent', () => { - it('should call addTags with safe mode', () => { - trackEvent('event', { metaKey1: 'metaValue1', metakey2: 'metaValue2' }, 'trackEvent', rootSpan, 'safe') - expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ - 'appsec.events.event.track': 'true', - '_dd.appsec.events.event.auto.mode': 'safe', - 'appsec.events.event.metaKey1': 'metaValue1', - 'appsec.events.event.metakey2': 'metaValue2' - }) - expect(prioritySampler.setPriority) - .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) - }) - - it('should call addTags with extended mode', () => { - trackEvent('event', { metaKey1: 'metaValue1', metakey2: 'metaValue2' }, 'trackEvent', rootSpan, 'extended') - expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ - 'appsec.events.event.track': 'true', - '_dd.appsec.events.event.auto.mode': 'extended', - 'appsec.events.event.metaKey1': 'metaValue1', - 'appsec.events.event.metakey2': 'metaValue2' - }) - expect(prioritySampler.setPriority) - .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) - }) - - it('should call standalone sample', () => { - trackEvent('event', undefined, 'trackEvent', rootSpan, undefined) - - expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ - 'appsec.events.event.track': 'true' - }) - expect(prioritySampler.setPriority) - .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) expect(sample).to.have.been.calledOnceWithExactly(rootSpan) }) }) diff --git a/packages/dd-trace/test/appsec/telemetry.spec.js b/packages/dd-trace/test/appsec/telemetry.spec.js index a297ede3280..3eb3b8521b4 100644 --- a/packages/dd-trace/test/appsec/telemetry.spec.js +++ b/packages/dd-trace/test/appsec/telemetry.spec.js @@ -339,6 +339,17 @@ describe('Appsec Telemetry metrics', () => { expect(count).to.not.have.been.called }) }) + + describe('incrementMissingUserLoginMetric', () => { + it('should increment instrum.user_auth.missing_user_login metric', () => { + appsecTelemetry.incrementMissingUserLoginMetric('passport-local', 'login_success') + + expect(count).to.have.been.calledOnceWithExactly('instrum.user_auth.missing_user_login', { + framework: 'passport-local', + event_type: 'login_success' + }) + }) + }) }) describe('if disabled', () => { diff --git a/packages/dd-trace/test/appsec/user_tracking.spec.js b/packages/dd-trace/test/appsec/user_tracking.spec.js new file mode 100644 index 00000000000..651048d5515 --- /dev/null +++ b/packages/dd-trace/test/appsec/user_tracking.spec.js @@ -0,0 +1,696 @@ +'use strict' + +const assert = require('assert') + +const log = require('../../src/log') +const telemetry = require('../../src/appsec/telemetry') +const { SAMPLING_MECHANISM_APPSEC } = require('../../src/constants') +const standalone = require('../../src/appsec/standalone') +const waf = require('../../src/appsec/waf') + +describe('User Tracking', () => { + let currentTags + let rootSpan + let keepTrace + + let setCollectionMode + let trackLogin + + beforeEach(() => { + sinon.stub(log, 'warn') + sinon.stub(log, 'error') + sinon.stub(telemetry, 'incrementMissingUserLoginMetric') + sinon.stub(standalone, 'sample') + sinon.stub(waf, 'run').returns(['action1']) + + currentTags = {} + + rootSpan = { + context: () => ({ _tags: currentTags }), + addTags: sinon.stub() + } + + keepTrace = sinon.stub() + + const UserTracking = proxyquire('../src/appsec/user_tracking', { + '../priority_sampler': { keepTrace } + }) + + setCollectionMode = UserTracking.setCollectionMode + trackLogin = UserTracking.trackLogin + }) + + afterEach(() => { + sinon.restore() + }) + + describe('getUserId', () => { + beforeEach(() => { + setCollectionMode('identification') + }) + + it('should find an id field in user object', () => { + const user = { + notId: 'no', + id: '123', + email: 'a@b.c' + } + + const results = trackLogin('passport-local', 'login', user, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login', + '_dd.appsec.usr.id': '123', + 'usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'usr.id': '123', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should find an id-like field in user object when no id field is present', () => { + const user = { + notId: 'no', + email: 'a@b.c', + username: 'azerty' + } + + const results = trackLogin('passport-local', 'login', user, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login', + '_dd.appsec.usr.id': 'a@b.c', + 'usr.id': 'a@b.c' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'usr.id': 'a@b.c', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should find a stringifiable id in user object', () => { + const stringifiableObject = { + a: 1, + toString: () => '123' + } + + const user = { + notId: 'no', + id: { a: 1 }, + _id: stringifiableObject, + email: 'a@b.c' + } + + const results = trackLogin('passport-local', 'login', user, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login', + '_dd.appsec.usr.id': '123', + 'usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'usr.id': '123', + 'server.business_logic.users.login.success': null + } + }) + }) + }) + + describe('trackLogin', () => { + it('should not do anything if collectionMode is empty or disabled', () => { + setCollectionMode('disabled') + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, undefined) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + sinon.assert.notCalled(keepTrace) + sinon.assert.notCalled(standalone.sample) + sinon.assert.notCalled(rootSpan.addTags) + sinon.assert.notCalled(waf.run) + }) + + it('should log error when rootSpan is not found', () => { + setCollectionMode('identification') + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true) + + assert.deepStrictEqual(results, undefined) + + sinon.assert.calledOnceWithExactly(log.error, '[ASM] No rootSpan found in AppSec trackLogin') + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + sinon.assert.notCalled(keepTrace) + sinon.assert.notCalled(standalone.sample) + sinon.assert.notCalled(rootSpan.addTags) + sinon.assert.notCalled(waf.run) + }) + + it('should log error and send telemetry when login success is not a string', () => { + setCollectionMode('identification') + + const results = trackLogin('passport-local', {}, { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, undefined) + + sinon.assert.calledOnceWithExactly(log.error, '[ASM] Invalid login provided to AppSec trackLogin') + sinon.assert.calledOnceWithExactly(telemetry.incrementMissingUserLoginMetric, 'passport-local', 'login_success') + sinon.assert.notCalled(keepTrace) + sinon.assert.notCalled(standalone.sample) + sinon.assert.notCalled(rootSpan.addTags) + sinon.assert.notCalled(waf.run) + }) + + it('should log error and send telemetry when login failure is not a string', () => { + setCollectionMode('identification') + + const results = trackLogin('passport-local', {}, { id: '123', email: 'a@b.c' }, false, rootSpan) + + assert.deepStrictEqual(results, undefined) + + sinon.assert.calledOnceWithExactly(log.error, '[ASM] Invalid login provided to AppSec trackLogin') + sinon.assert.calledOnceWithExactly(telemetry.incrementMissingUserLoginMetric, 'passport-local', 'login_failure') + sinon.assert.notCalled(keepTrace) + sinon.assert.notCalled(standalone.sample) + sinon.assert.notCalled(rootSpan.addTags) + sinon.assert.notCalled(waf.run) + }) + + describe('when collectionMode is indentification', () => { + beforeEach(() => { + setCollectionMode('identification') + }) + + it('should write tags and call waf when success is true', () => { + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login', + '_dd.appsec.usr.id': '123', + 'usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'usr.id': '123', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should write tags and call waf when success is false', () => { + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, false, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.failure.usr.login': 'login', + '_dd.appsec.usr.id': '123', + 'appsec.events.users.login.failure.usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'server.business_logic.users.login.failure': null + } + }) + }) + + it('should not overwrite tags set by SDK when success is true', () => { + currentTags = { + '_dd.appsec.events.users.login.success.sdk': 'true', + 'appsec.events.users.login.success.usr.login': 'sdk_login', + 'usr.id': 'sdk_id' + } + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + '_dd.appsec.usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should not overwwrite tags set by SDK when success is false', () => { + currentTags = { + '_dd.appsec.events.users.login.failure.sdk': 'true', + 'appsec.events.users.login.failure.usr.login': 'sdk_login', + 'appsec.events.users.login.failure.usr.id': 'sdk_id' + } + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, false, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + '_dd.appsec.usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'server.business_logic.users.login.failure': null + } + }) + }) + + it('should write tags and call waf without user object when success is true', () => { + const results = trackLogin('passport-local', 'login', null, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should write tags and call waf without user object when success is false', () => { + const results = trackLogin('passport-local', 'login', null, false, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.failure.usr.login': 'login' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'server.business_logic.users.login.failure': null + } + }) + }) + }) + + describe('when collectionMode is anonymization', () => { + beforeEach(() => { + setCollectionMode('anonymization') + }) + + it('should write tags and call waf when success is true', () => { + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'appsec.events.users.login.success.usr.login': 'anon_428821350e9691491f616b754cd8315f', + '_dd.appsec.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should write tags and call waf when success is false', () => { + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, false, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'appsec.events.users.login.failure.usr.login': 'anon_428821350e9691491f616b754cd8315f', + '_dd.appsec.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'appsec.events.users.login.failure.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'server.business_logic.users.login.failure': null + } + }) + }) + + it('should not overwrite tags set by SDK when success is true', () => { + currentTags = { + '_dd.appsec.events.users.login.success.sdk': 'true', + 'appsec.events.users.login.success.usr.login': 'sdk_login', + 'usr.id': 'sdk_id' + } + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + '_dd.appsec.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should not overwwrite tags set by SDK when success is false', () => { + currentTags = { + '_dd.appsec.events.users.login.failure.sdk': 'true', + 'appsec.events.users.login.failure.usr.login': 'sdk_login', + 'appsec.events.users.login.failure.usr.id': 'sdk_id' + } + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, false, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + '_dd.appsec.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'server.business_logic.users.login.failure': null + } + }) + }) + + it('should write tags and call waf without user object when success is true', () => { + const results = trackLogin('passport-local', 'login', null, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'appsec.events.users.login.success.usr.login': 'anon_428821350e9691491f616b754cd8315f' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should write tags and call waf without user object when success is false', () => { + const results = trackLogin('passport-local', 'login', null, false, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'appsec.events.users.login.failure.usr.login': 'anon_428821350e9691491f616b754cd8315f' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'server.business_logic.users.login.failure': null + } + }) + }) + }) + + describe('collectionMode aliases', () => { + it('should log warning and use anonymization mode when collectionMode is safe', () => { + setCollectionMode('safe') + + sinon.assert.calledOnceWithExactly( + log.warn, + '[ASM] Using deprecated value "safe" in config.appsec.eventTracking.mode' + ) + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'appsec.events.users.login.success.usr.login': 'anon_428821350e9691491f616b754cd8315f', + '_dd.appsec.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should use anonymization mode when collectionMode is anon', () => { + setCollectionMode('anon') + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'appsec.events.users.login.success.usr.login': 'anon_428821350e9691491f616b754cd8315f', + '_dd.appsec.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should log warning and use identification mode when collectionMode is extended', () => { + setCollectionMode('extended') + + sinon.assert.calledOnceWithExactly( + log.warn, + '[ASM] Using deprecated value "extended" in config.appsec.eventTracking.mode' + ) + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login', + '_dd.appsec.usr.id': '123', + 'usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'usr.id': '123', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should use identification mode when collectionMode is ident', () => { + setCollectionMode('ident') + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login', + '_dd.appsec.usr.id': '123', + 'usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'usr.id': '123', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should use disabled mode when collectionMode is not recognized', () => { + setCollectionMode('saperlipopette') + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, undefined) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + sinon.assert.notCalled(keepTrace) + sinon.assert.notCalled(standalone.sample) + sinon.assert.notCalled(rootSpan.addTags) + sinon.assert.notCalled(waf.run) + }) + }) + }) +}) diff --git a/packages/dd-trace/test/ci-visibility/exporters/agentless/coverage-writer.spec.js b/packages/dd-trace/test/ci-visibility/exporters/agentless/coverage-writer.spec.js index 62e10e9753e..61ffee21181 100644 --- a/packages/dd-trace/test/ci-visibility/exporters/agentless/coverage-writer.spec.js +++ b/packages/dd-trace/test/ci-visibility/exporters/agentless/coverage-writer.spec.js @@ -111,7 +111,7 @@ describe('CI Visibility Coverage Writer', () => { encoder.makePayload.returns(payload) coverageWriter.flush(() => { - expect(log.error).to.have.been.calledWith(error) + expect(log.error).to.have.been.calledWith('Error sending CI coverage payload', error) done() }) }) diff --git a/packages/dd-trace/test/ci-visibility/exporters/agentless/writer.spec.js b/packages/dd-trace/test/ci-visibility/exporters/agentless/writer.spec.js index 85765c6bf3a..29ac58fbd31 100644 --- a/packages/dd-trace/test/ci-visibility/exporters/agentless/writer.spec.js +++ b/packages/dd-trace/test/ci-visibility/exporters/agentless/writer.spec.js @@ -113,7 +113,7 @@ describe('CI Visibility Writer', () => { encoder.count.returns(1) writer.flush(() => { - expect(log.error).to.have.been.calledWith(error) + expect(log.error).to.have.been.calledWith('Error sending CI agentless payload', error) done() }) }) diff --git a/packages/dd-trace/test/config.spec.js b/packages/dd-trace/test/config.spec.js index 1720c4a5c91..8e87b6fa855 100644 --- a/packages/dd-trace/test/config.spec.js +++ b/packages/dd-trace/test/config.spec.js @@ -28,6 +28,14 @@ describe('Config', () => { const BLOCKED_TEMPLATE_GRAPHQL_PATH = require.resolve('./fixtures/config/appsec-blocked-graphql-template.json') const BLOCKED_TEMPLATE_GRAPHQL = readFileSync(BLOCKED_TEMPLATE_GRAPHQL_PATH, { encoding: 'utf8' }) const DD_GIT_PROPERTIES_FILE = require.resolve('./fixtures/config/git.properties') + const CONFIG_NORM_RULES_PATH = require.resolve('./fixtures/telemetry/config_norm_rules.json') + const CONFIG_NORM_RULES = readFileSync(CONFIG_NORM_RULES_PATH, { encoding: 'utf8' }) + const CONFIG_PREFIX_BLOCK_LIST_PATH = require.resolve('./fixtures/telemetry/config_prefix_block_list.json') + const CONFIG_PREFIX_BLOCK_LIST = readFileSync(CONFIG_PREFIX_BLOCK_LIST_PATH, { encoding: 'utf8' }) + const CONFIG_AGGREGATION_LIST_PATH = require.resolve('./fixtures/telemetry/config_aggregation_list.json') + const CONFIG_AGGREGATION_LIST = readFileSync(CONFIG_AGGREGATION_LIST_PATH, { encoding: 'utf8' }) + const NODEJS_CONFIG_RULES_PATH = require.resolve('./fixtures/telemetry/nodejs_config_rules.json') + const NODEJS_CONFIG_RULES = readFileSync(NODEJS_CONFIG_RULES_PATH, { encoding: 'utf8' }) function reloadLoggerAndConfig () { log = proxyquire('../src/log', {}) @@ -162,7 +170,7 @@ describe('Config', () => { it('should correctly map OTEL_RESOURCE_ATTRIBUTES', () => { process.env.OTEL_RESOURCE_ATTRIBUTES = - 'deployment.environment=test1,service.name=test2,service.version=5,foo=bar1,baz=qux1' + 'deployment.environment=test1,service.name=test2,service.version=5,foo=bar1,baz=qux1' const config = new Config() expect(config).to.have.property('env', 'test1') @@ -212,7 +220,7 @@ describe('Config', () => { expect(config).to.have.property('queryStringObfuscation').with.length(626) expect(config).to.have.property('clientIpEnabled', false) expect(config).to.have.property('clientIpHeader', null) - expect(config).to.have.nested.property('crashtracking.enabled', false) + expect(config).to.have.nested.property('crashtracking.enabled', true) expect(config).to.have.property('sampleRate', undefined) expect(config).to.have.property('runtimeMetrics', false) expect(config.tags).to.have.property('service', 'node') @@ -251,8 +259,7 @@ describe('Config', () => { expect(config).to.have.nested.property('appsec.blockedTemplateHtml', undefined) expect(config).to.have.nested.property('appsec.blockedTemplateJson', undefined) expect(config).to.have.nested.property('appsec.blockedTemplateGraphql', undefined) - expect(config).to.have.nested.property('appsec.eventTracking.enabled', true) - expect(config).to.have.nested.property('appsec.eventTracking.mode', 'safe') + expect(config).to.have.nested.property('appsec.eventTracking.mode', 'identification') expect(config).to.have.nested.property('appsec.apiSecurity.enabled', true) expect(config).to.have.nested.property('appsec.apiSecurity.sampleDelay', 30) expect(config).to.have.nested.property('appsec.sca.enabled', null) @@ -277,15 +284,16 @@ describe('Config', () => { { name: 'appsec.blockedTemplateHtml', value: undefined, origin: 'default' }, { name: 'appsec.blockedTemplateJson', value: undefined, origin: 'default' }, { name: 'appsec.enabled', value: undefined, origin: 'default' }, + { name: 'appsec.eventTracking.mode', value: 'identification', origin: 'default' }, { name: 'appsec.obfuscatorKeyRegex', - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len value: '(?i)pass|pw(?:or)?d|secret|(?:api|private|public|access)[_-]?key|token|consumer[_-]?(?:id|key|secret)|sign(?:ed|ature)|bearer|authorization|jsessionid|phpsessid|asp\\.net[_-]sessionid|sid|jwt', origin: 'default' }, { name: 'appsec.obfuscatorValueRegex', - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len value: '(?i)(?:p(?:ass)?w(?:or)?d|pass(?:[_-]?phrase)?|secret(?:[_-]?key)?|(?:(?:api|private|public|access)[_-]?)key(?:[_-]?id)?|(?:(?:auth|access|id|refresh)[_-]?)?token|consumer[_-]?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?|jsessionid|phpsessid|asp\\.net(?:[_-]|-)sessionid|sid|jwt)(?:\\s*=[^;]|"\\s*:\\s*"[^"]+")|bearer\\s+[a-z0-9\\._\\-]+|token:[a-z0-9]{13}|gh[opsu]_[0-9a-zA-Z]{36}|ey[I-L][\\w=-]+\\.ey[I-L][\\w=-]+(?:\\.[\\w.+\\/=-]+)?|[\\-]{5}BEGIN[a-z\\s]+PRIVATE\\sKEY[\\-]{5}[^\\-]+[\\-]{5}END[a-z\\s]+PRIVATE\\sKEY|ssh-rsa\\s*[a-z0-9\\/\\.+]{100,}', origin: 'default' }, @@ -316,6 +324,7 @@ describe('Config', () => { { name: 'headerTags', value: [], origin: 'default' }, { name: 'hostname', value: '127.0.0.1', origin: 'default' }, { name: 'iast.cookieFilterPattern', value: '.{32,}', origin: 'default' }, + { name: 'iast.dbRowsToTaint', value: 1, origin: 'default' }, { name: 'iast.deduplicationEnabled', value: true, origin: 'default' }, { name: 'iast.enabled', value: false, origin: 'default' }, { name: 'iast.maxConcurrentRequests', value: 2, origin: 'default' }, @@ -354,7 +363,7 @@ describe('Config', () => { { name: 'protocolVersion', value: '0.4', origin: 'default' }, { name: 'queryStringObfuscation', - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len value: '(?:p(?:ass)?w(?:or)?d|pass(?:_?phrase)?|secret|(?:api_?|private_?|public_?|access_?|secret_?)key(?:_?id)?|token|consumer_?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?)(?:(?:\\s|%20)*(?:=|%3D)[^&]+|(?:"|%22)(?:\\s|%20)*(?::|%3A)(?:\\s|%20)*(?:"|%22)(?:%2[^2]|%[^2]|[^"%])+(?:"|%22))|bearer(?:\\s|%20)+[a-z0-9\\._\\-]+|token(?::|%3A)[a-z0-9]{13}|gh[opsu]_[0-9a-zA-Z]{36}|ey[I-L](?:[\\w=-]|%3D)+\\.ey[I-L](?:[\\w=-]|%3D)+(?:\\.(?:[\\w.+\\/=-]|%3D|%2F|%2B)+)?|[\\-]{5}BEGIN(?:[a-z\\s]|%20)+PRIVATE(?:\\s|%20)KEY[\\-]{5}[^\\-]+[\\-]{5}END(?:[a-z\\s]|%20)+PRIVATE(?:\\s|%20)KEY|ssh-rsa(?:\\s|%20)*(?:[a-z0-9\\/\\.+]|%2F|%5C|%2B){100,}', origin: 'default' }, @@ -380,7 +389,7 @@ describe('Config', () => { { name: 'telemetry.dependencyCollection', value: true, origin: 'default' }, { name: 'telemetry.enabled', value: true, origin: 'env_var' }, { name: 'telemetry.heartbeatInterval', value: 60000, origin: 'default' }, - { name: 'telemetry.logCollection', value: false, origin: 'default' }, + { name: 'telemetry.logCollection', value: true, origin: 'default' }, { name: 'telemetry.metrics', value: true, origin: 'default' }, { name: 'traceId128BitGenerationEnabled', value: true, origin: 'default' }, { name: 'traceId128BitLoggingEnabled', value: false, origin: 'default' }, @@ -443,7 +452,7 @@ describe('Config', () => { process.env.DD_TRACE_OBFUSCATION_QUERY_STRING_REGEXP = '.*' process.env.DD_TRACE_CLIENT_IP_ENABLED = 'true' process.env.DD_TRACE_CLIENT_IP_HEADER = 'x-true-client-ip' - process.env.DD_CRASHTRACKING_ENABLED = 'true' + process.env.DD_CRASHTRACKING_ENABLED = 'false' process.env.DD_RUNTIME_METRICS_ENABLED = 'true' process.env.DD_TRACE_REPORT_HOSTNAME = 'true' process.env.DD_ENV = 'test' @@ -496,6 +505,7 @@ describe('Config', () => { process.env.DD_IAST_MAX_CONCURRENT_REQUESTS = '3' process.env.DD_IAST_MAX_CONTEXT_OPERATIONS = '4' process.env.DD_IAST_COOKIE_FILTER_PATTERN = '.*' + process.env.DD_IAST_DB_ROWS_TO_TAINT = 2 process.env.DD_IAST_DEDUPLICATION_ENABLED = false process.env.DD_IAST_REDACTION_ENABLED = false process.env.DD_IAST_REDACTION_NAME_PATTERN = 'REDACTION_NAME_PATTERN' @@ -535,7 +545,7 @@ describe('Config', () => { expect(config).to.have.property('queryStringObfuscation', '.*') expect(config).to.have.property('clientIpEnabled', true) expect(config).to.have.property('clientIpHeader', 'x-true-client-ip') - expect(config).to.have.nested.property('crashtracking.enabled', true) + expect(config).to.have.nested.property('crashtracking.enabled', false) expect(config.grpc.client.error.statuses).to.deep.equal([3, 13, 400, 401, 402, 403]) expect(config.grpc.server.error.statuses).to.deep.equal([3, 13, 400, 401, 402, 403]) expect(config).to.have.property('runtimeMetrics', true) @@ -595,7 +605,6 @@ describe('Config', () => { expect(config).to.have.nested.property('appsec.blockedTemplateHtml', BLOCKED_TEMPLATE_HTML) expect(config).to.have.nested.property('appsec.blockedTemplateJson', BLOCKED_TEMPLATE_JSON) expect(config).to.have.nested.property('appsec.blockedTemplateGraphql', BLOCKED_TEMPLATE_GRAPHQL) - expect(config).to.have.nested.property('appsec.eventTracking.enabled', true) expect(config).to.have.nested.property('appsec.eventTracking.mode', 'extended') expect(config).to.have.nested.property('appsec.apiSecurity.enabled', true) expect(config).to.have.nested.property('appsec.apiSecurity.sampleDelay', 25) @@ -608,6 +617,7 @@ describe('Config', () => { expect(config).to.have.nested.property('iast.maxConcurrentRequests', 3) expect(config).to.have.nested.property('iast.maxContextOperations', 4) expect(config).to.have.nested.property('iast.cookieFilterPattern', '.*') + expect(config).to.have.nested.property('iast.dbRowsToTaint', 2) expect(config).to.have.nested.property('iast.deduplicationEnabled', false) expect(config).to.have.nested.property('iast.redactionEnabled', false) expect(config).to.have.nested.property('iast.redactionNamePattern', 'REDACTION_NAME_PATTERN') @@ -627,6 +637,7 @@ describe('Config', () => { { name: 'appsec.blockedTemplateHtml', value: BLOCKED_TEMPLATE_HTML_PATH, origin: 'env_var' }, { name: 'appsec.blockedTemplateJson', value: BLOCKED_TEMPLATE_JSON_PATH, origin: 'env_var' }, { name: 'appsec.enabled', value: true, origin: 'env_var' }, + { name: 'appsec.eventTracking.mode', value: 'extended', origin: 'env_var' }, { name: 'appsec.obfuscatorKeyRegex', value: '.*', origin: 'env_var' }, { name: 'appsec.obfuscatorValueRegex', value: '.*', origin: 'env_var' }, { name: 'appsec.rateLimit', value: '42', origin: 'env_var' }, @@ -640,7 +651,7 @@ describe('Config', () => { { name: 'appsec.wafTimeout', value: '42', origin: 'env_var' }, { name: 'clientIpEnabled', value: true, origin: 'env_var' }, { name: 'clientIpHeader', value: 'x-true-client-ip', origin: 'env_var' }, - { name: 'crashtracking.enabled', value: true, origin: 'env_var' }, + { name: 'crashtracking.enabled', value: false, origin: 'env_var' }, { name: 'codeOriginForSpans.enabled', value: true, origin: 'env_var' }, { name: 'dogstatsd.hostname', value: 'dsd-agent', origin: 'env_var' }, { name: 'dogstatsd.port', value: '5218', origin: 'env_var' }, @@ -651,6 +662,7 @@ describe('Config', () => { { name: 'experimental.runtimeId', value: true, origin: 'env_var' }, { name: 'hostname', value: 'agent', origin: 'env_var' }, { name: 'iast.cookieFilterPattern', value: '.*', origin: 'env_var' }, + { name: 'iast.dbRowsToTaint', value: 2, origin: 'env_var' }, { name: 'iast.deduplicationEnabled', value: false, origin: 'env_var' }, { name: 'iast.enabled', value: true, origin: 'env_var' }, { name: 'iast.maxConcurrentRequests', value: '3', origin: 'env_var' }, @@ -765,6 +777,15 @@ describe('Config', () => { expect(config).to.have.nested.deep.property('crashtracking.enabled', false) }) + it('should prioritize DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE over DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING', () => { + process.env.DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE = 'anonymous' + process.env.DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING = 'extended' + + const config = new Config() + + expect(config).to.have.nested.property('appsec.eventTracking.mode', 'anonymous') + }) + it('should initialize from the options', () => { const logger = {} const tags = { @@ -840,6 +861,7 @@ describe('Config', () => { maxConcurrentRequests: 4, maxContextOperations: 5, cookieFilterPattern: '.*', + dbRowsToTaint: 2, deduplicationEnabled: false, redactionEnabled: false, redactionNamePattern: 'REDACTION_NAME_PATTERN', @@ -912,6 +934,7 @@ describe('Config', () => { expect(config).to.have.nested.property('iast.maxConcurrentRequests', 4) expect(config).to.have.nested.property('iast.maxContextOperations', 5) expect(config).to.have.nested.property('iast.cookieFilterPattern', '.*') + expect(config).to.have.nested.property('iast.dbRowsToTaint', 2) expect(config).to.have.nested.property('iast.deduplicationEnabled', false) expect(config).to.have.nested.property('iast.redactionEnabled', false) expect(config).to.have.nested.property('iast.redactionNamePattern', 'REDACTION_NAME_PATTERN') @@ -959,6 +982,7 @@ describe('Config', () => { { name: 'flushMinSpans', value: 500, origin: 'code' }, { name: 'hostname', value: 'agent', origin: 'code' }, { name: 'iast.cookieFilterPattern', value: '.*', origin: 'code' }, + { name: 'iast.dbRowsToTaint', value: 2, origin: 'code' }, { name: 'iast.deduplicationEnabled', value: false, origin: 'code' }, { name: 'iast.enabled', value: true, origin: 'code' }, { name: 'iast.maxConcurrentRequests', value: 4, origin: 'code' }, @@ -1179,10 +1203,12 @@ describe('Config', () => { process.env.DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML = BLOCKED_TEMPLATE_JSON_PATH // note the inversion between process.env.DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON = BLOCKED_TEMPLATE_HTML_PATH // json and html here process.env.DD_APPSEC_GRAPHQL_BLOCKED_TEMPLATE_JSON = BLOCKED_TEMPLATE_JSON_PATH // json and html here + process.env.DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE = 'disabled' process.env.DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING = 'disabled' process.env.DD_API_SECURITY_ENABLED = 'false' process.env.DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS = 11 process.env.DD_IAST_ENABLED = 'false' + process.env.DD_IAST_DB_ROWS_TO_TAINT = '2' process.env.DD_IAST_COOKIE_FILTER_PATTERN = '.*' process.env.DD_IAST_REDACTION_NAME_PATTERN = 'name_pattern_to_be_overriden_by_options' process.env.DD_IAST_REDACTION_VALUE_PATTERN = 'value_pattern_to_be_overriden_by_options' @@ -1243,7 +1269,7 @@ describe('Config', () => { blockedTemplateJson: BLOCKED_TEMPLATE_JSON_PATH, blockedTemplateGraphql: BLOCKED_TEMPLATE_GRAPHQL_PATH, eventTracking: { - mode: 'safe' + mode: 'anonymous' }, apiSecurity: { enabled: true @@ -1260,6 +1286,7 @@ describe('Config', () => { iast: { enabled: true, cookieFilterPattern: '.{10,}', + dbRowsToTaint: 3, redactionNamePattern: 'REDACTION_NAME_PATTERN', redactionValuePattern: 'REDACTION_VALUE_PATTERN' }, @@ -1321,14 +1348,14 @@ describe('Config', () => { expect(config).to.have.nested.property('appsec.blockedTemplateHtml', BLOCKED_TEMPLATE_HTML) expect(config).to.have.nested.property('appsec.blockedTemplateJson', BLOCKED_TEMPLATE_JSON) expect(config).to.have.nested.property('appsec.blockedTemplateGraphql', BLOCKED_TEMPLATE_GRAPHQL) - expect(config).to.have.nested.property('appsec.eventTracking.enabled', true) - expect(config).to.have.nested.property('appsec.eventTracking.mode', 'safe') + expect(config).to.have.nested.property('appsec.eventTracking.mode', 'anonymous') expect(config).to.have.nested.property('appsec.apiSecurity.enabled', true) expect(config).to.have.nested.property('remoteConfig.pollInterval', 42) expect(config).to.have.nested.property('iast.enabled', true) expect(config).to.have.nested.property('iast.requestSampling', 30) expect(config).to.have.nested.property('iast.maxConcurrentRequests', 2) expect(config).to.have.nested.property('iast.maxContextOperations', 2) + expect(config).to.have.nested.property('iast.dbRowsToTaint', 3) expect(config).to.have.nested.property('iast.deduplicationEnabled', true) expect(config).to.have.nested.property('iast.cookieFilterPattern', '.{10,}') expect(config).to.have.nested.property('iast.redactionEnabled', true) @@ -1366,6 +1393,7 @@ describe('Config', () => { maxConcurrentRequests: 3, maxContextOperations: 4, cookieFilterPattern: '.*', + dbRowsToTaint: 3, deduplicationEnabled: false, redactionEnabled: false, redactionNamePattern: 'REDACTION_NAME_PATTERN', @@ -1384,7 +1412,7 @@ describe('Config', () => { blockedTemplateJson: BLOCKED_TEMPLATE_JSON_PATH, blockedTemplateGraphql: BLOCKED_TEMPLATE_GRAPHQL_PATH, eventTracking: { - mode: 'safe' + mode: 'anonymous' }, apiSecurity: { enabled: false @@ -1399,6 +1427,7 @@ describe('Config', () => { maxConcurrentRequests: 6, maxContextOperations: 7, cookieFilterPattern: '.{10,}', + dbRowsToTaint: 2, deduplicationEnabled: true, redactionEnabled: true, redactionNamePattern: 'IGNORED_REDACTION_NAME_PATTERN', @@ -1419,7 +1448,6 @@ describe('Config', () => { blockedTemplateJson: undefined, blockedTemplateGraphql: undefined, eventTracking: { - enabled: false, mode: 'disabled' }, apiSecurity: { @@ -1448,6 +1476,7 @@ describe('Config', () => { maxConcurrentRequests: 3, maxContextOperations: 4, cookieFilterPattern: '.*', + dbRowsToTaint: 3, deduplicationEnabled: false, redactionEnabled: false, redactionNamePattern: 'REDACTION_NAME_PATTERN', @@ -1577,7 +1606,7 @@ describe('Config', () => { expect(config.telemetry).to.not.be.undefined expect(config.telemetry.enabled).to.be.true expect(config.telemetry.heartbeatInterval).to.eq(60000) - expect(config.telemetry.logCollection).to.be.false + expect(config.telemetry.logCollection).to.be.true expect(config.telemetry.debug).to.be.false expect(config.telemetry.metrics).to.be.true }) @@ -1615,7 +1644,7 @@ describe('Config', () => { process.env.DD_TELEMETRY_METRICS_ENABLED = origTelemetryMetricsEnabledValue }) - it('should not set DD_TELEMETRY_LOG_COLLECTION_ENABLED', () => { + it('should disable log collection if DD_TELEMETRY_LOG_COLLECTION_ENABLED is false', () => { const origLogsValue = process.env.DD_TELEMETRY_LOG_COLLECTION_ENABLED process.env.DD_TELEMETRY_LOG_COLLECTION_ENABLED = 'false' @@ -1626,17 +1655,6 @@ describe('Config', () => { process.env.DD_TELEMETRY_LOG_COLLECTION_ENABLED = origLogsValue }) - it('should set DD_TELEMETRY_LOG_COLLECTION_ENABLED if DD_IAST_ENABLED', () => { - const origIastEnabledValue = process.env.DD_IAST_ENABLED - process.env.DD_IAST_ENABLED = 'true' - - const config = new Config() - - expect(config.telemetry.logCollection).to.be.true - - process.env.DD_IAST_ENABLED = origIastEnabledValue - }) - it('should set DD_TELEMETRY_DEBUG', () => { const origTelemetryDebugValue = process.env.DD_TELEMETRY_DEBUG process.env.DD_TELEMETRY_DEBUG = 'true' @@ -1792,9 +1810,12 @@ describe('Config', () => { }) expect(log.error).to.be.callCount(3) - expect(log.error.firstCall).to.have.been.calledWithExactly(error) - expect(log.error.secondCall).to.have.been.calledWithExactly(error) - expect(log.error.thirdCall).to.have.been.calledWithExactly(error) + expect(log.error.firstCall) + .to.have.been.calledWithExactly('Error reading file %s', 'DOES_NOT_EXIST.json', error) + expect(log.error.secondCall) + .to.have.been.calledWithExactly('Error reading file %s', 'DOES_NOT_EXIST.html', error) + expect(log.error.thirdCall) + .to.have.been.calledWithExactly('Error reading file %s', 'DOES_NOT_EXIST.json', error) expect(config.appsec.enabled).to.be.true expect(config.appsec.rules).to.eq('path/to/rules.json') @@ -1828,6 +1849,15 @@ describe('Config', () => { expect(config.appsec.apiSecurity.enabled).to.be.true }) + it('should prioritize DD_DOGSTATSD_HOST over DD_DOGSTATSD_HOSTNAME', () => { + process.env.DD_DOGSTATSD_HOSTNAME = 'dsd-agent' + process.env.DD_DOGSTATSD_HOST = 'localhost' + + const config = new Config() + + expect(config).to.have.nested.property('dogstatsd.hostname', 'localhost') + }) + context('auto configuration w/ unix domain sockets', () => { context('on windows', () => { it('should not be used', () => { @@ -2258,5 +2288,76 @@ describe('Config', () => { expect(taggingConfig).to.have.property('responsesEnabled', true) expect(taggingConfig).to.have.property('maxDepth', 7) }) + + it('config_norm_rules completeness', () => { + // ⚠️ Did this test just fail? Read here! ⚠️ + // + // Some files are manually copied from dd-go from/to the following paths + // from: https://github.com/DataDog/dd-go/blob/prod/trace/apps/tracer-telemetry-intake/telemetry-payload/static/ + // to: packages/dd-trace/test/fixtures/telemetry/ + // files: + // - config_norm_rules.json + // - config_prefix_block_list.json + // - config_aggregation_list.json + // - nodejs_config_rules.json + // + // If this test fails, it means that a telemetry key was found in config.js that does not + // exist in any of the files listed above in dd-go + // The impact is that telemetry will not be reported to the Datadog backend won't be unusable + // + // To fix this, you must update dd-go to either + // 1) Add an exact config key to match config_norm_rules.json + // 2) Add a prefix that matches the config keys to config_prefix_block_list.json + // 3) Add a prefix rule that fits an existing prefix to config_aggregation_list.json + // 4) (Discouraged) Add a language-specific rule to nodejs_config_rules.json + // + // Once dd-go is updated, you can copy over the files to this repo and merge them in as part of your changes + + function getKeysInDotNotation (obj, parentKey = '') { + const keys = [] + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const fullKey = parentKey ? `${parentKey}.${key}` : key + + if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { + keys.push(...getKeysInDotNotation(obj[key], fullKey)) + } else { + keys.push(fullKey) + } + } + } + + return keys + } + + const config = new Config() + + const libraryConfigKeys = getKeysInDotNotation(config).sort() + + const nodejsConfigRules = JSON.parse(NODEJS_CONFIG_RULES) + const configNormRules = JSON.parse(CONFIG_NORM_RULES) + const configPrefixBlockList = JSON.parse(CONFIG_PREFIX_BLOCK_LIST) + const configAggregationList = JSON.parse(CONFIG_AGGREGATION_LIST) + + const allowedConfigKeys = [ + ...Object.keys(configNormRules), + ...Object.keys(nodejsConfigRules.normalization_rules) + ] + const blockedConfigKeyPrefixes = [...configPrefixBlockList, ...nodejsConfigRules.prefix_block_list] + const configAggregationPrefixes = [ + ...Object.keys(configAggregationList), + ...Object.keys(nodejsConfigRules.reduce_rules) + ] + + const missingConfigKeys = libraryConfigKeys.filter(key => { + const isAllowed = allowedConfigKeys.includes(key) + const isBlocked = blockedConfigKeyPrefixes.some(prefix => key.startsWith(prefix)) + const isReduced = configAggregationPrefixes.some(prefix => key.startsWith(prefix)) + return !isAllowed && !isBlocked && !isReduced + }) + + expect(missingConfigKeys).to.be.empty + }) }) }) diff --git a/packages/dd-trace/test/datastreams/processor.spec.js b/packages/dd-trace/test/datastreams/processor.spec.js index 0c30bc77947..110d9ff6c35 100644 --- a/packages/dd-trace/test/datastreams/processor.spec.js +++ b/packages/dd-trace/test/datastreams/processor.spec.js @@ -294,11 +294,11 @@ describe('DataStreamsProcessor', () => { Service: 'service1', Version: 'v1', Stats: [{ - Start: new Uint64(1680000000000), - Duration: new Uint64(10000000000), + Start: 1680000000000n, + Duration: 10000000000n, Stats: [{ - Hash: new Uint64(DEFAULT_CURRENT_HASH), - ParentHash: new Uint64(DEFAULT_PARENT_HASH), + Hash: DEFAULT_CURRENT_HASH.readBigUInt64BE(), + ParentHash: DEFAULT_PARENT_HASH.readBigUInt64BE(), EdgeTags: mockCheckpoint.edgeTags, EdgeLatency: edgeLatency.toProto(), PathwayLatency: pathwayLatency.toProto(), diff --git a/packages/dd-trace/test/debugger/devtools_client/status.spec.js b/packages/dd-trace/test/debugger/devtools_client/status.spec.js index 41433f453c5..365d86d6e96 100644 --- a/packages/dd-trace/test/debugger/devtools_client/status.spec.js +++ b/packages/dd-trace/test/debugger/devtools_client/status.spec.js @@ -79,7 +79,7 @@ describe('diagnostic message http request caching', function () { function assertRequestData (request, { probeId, version, status, exception }) { const payload = getFormPayload(request) - const diagnostics = { probeId, runtimeId, version, status } + const diagnostics = { probeId, runtimeId, probeVersion: version, status } // Error requests will also contain an `exception` property if (exception) diagnostics.exception = exception diff --git a/packages/dd-trace/test/debugger/devtools_client/utils.js b/packages/dd-trace/test/debugger/devtools_client/utils.js new file mode 100644 index 00000000000..e15d567a7c1 --- /dev/null +++ b/packages/dd-trace/test/debugger/devtools_client/utils.js @@ -0,0 +1,25 @@ +'use strict' + +const { randomUUID } = require('node:crypto') + +module.exports = { + generateProbeConfig +} + +function generateProbeConfig (breakpoint, overrides = {}) { + overrides.capture = { maxReferenceDepth: 3, ...overrides.capture } + overrides.sampling = { snapshotsPerSecond: 5000, ...overrides.sampling } + return { + id: randomUUID(), + version: 0, + type: 'LOG_PROBE', + language: 'javascript', + where: { sourceFile: breakpoint.file, lines: [String(breakpoint.line)] }, + tags: [], + template: 'Hello World!', + segments: [{ str: 'Hello World!' }], + captureSnapshot: false, + evaluateAt: 'EXIT', + ...overrides + } +} diff --git a/packages/dd-trace/test/encode/agentless-ci-visibility.spec.js b/packages/dd-trace/test/encode/agentless-ci-visibility.spec.js index 54ddab1a2a6..259ff78df2e 100644 --- a/packages/dd-trace/test/encode/agentless-ci-visibility.spec.js +++ b/packages/dd-trace/test/encode/agentless-ci-visibility.spec.js @@ -67,14 +67,14 @@ describe('agentless-ci-visibility-encode', () => { const buffer = encoder.makePayload() const decodedTrace = msgpack.decode(buffer, { codec }) - expect(decodedTrace.version.toNumber()).to.equal(1) + expect(decodedTrace.version).to.equal(1) expect(decodedTrace.metadata['*']).to.contain({ language: 'javascript', library_version: ddTraceVersion }) const spanEvent = decodedTrace.events[0] expect(spanEvent.type).to.equal('span') - expect(spanEvent.version.toNumber()).to.equal(1) + expect(spanEvent.version).to.equal(1) expect(spanEvent.content.trace_id.toString(10)).to.equal(trace[0].trace_id.toString(10)) expect(spanEvent.content.span_id.toString(10)).to.equal(trace[0].span_id.toString(10)) expect(spanEvent.content.parent_id.toString(10)).to.equal(trace[0].parent_id.toString(10)) @@ -84,9 +84,9 @@ describe('agentless-ci-visibility-encode', () => { service: 'test-s', type: 'foo' }) - expect(spanEvent.content.error.toNumber()).to.equal(0) - expect(spanEvent.content.start.toNumber()).to.equal(123) - expect(spanEvent.content.duration.toNumber()).to.equal(456) + expect(spanEvent.content.error).to.equal(0) + expect(spanEvent.content.start).to.equal(123) + expect(spanEvent.content.duration).to.equal(456) expect(spanEvent.content.meta).to.eql({ bar: 'baz' @@ -276,6 +276,6 @@ describe('agentless-ci-visibility-encode', () => { const decodedTrace = msgpack.decode(buffer, { codec }) const spanEvent = decodedTrace.events[0] expect(spanEvent.type).to.equal('span') - expect(spanEvent.version.toNumber()).to.equal(1) + expect(spanEvent.version).to.equal(1) }) }) diff --git a/packages/dd-trace/test/exporters/agent/writer.spec.js b/packages/dd-trace/test/exporters/agent/writer.spec.js index ce7a62d49bf..aad8749ef37 100644 --- a/packages/dd-trace/test/exporters/agent/writer.spec.js +++ b/packages/dd-trace/test/exporters/agent/writer.spec.js @@ -149,6 +149,7 @@ function describeWriter (protocolVersion) { it('should log request errors', done => { const error = new Error('boom') + error.status = 42 request.yields(error) @@ -156,7 +157,8 @@ function describeWriter (protocolVersion) { writer.flush() setTimeout(() => { - expect(log.error).to.have.been.calledWith(error) + expect(log.error) + .to.have.been.calledWith('Error sending payload to the agent (status code: %s)', error.status, error) done() }) }) diff --git a/packages/dd-trace/test/exporters/common/docker.spec.js b/packages/dd-trace/test/exporters/common/docker.spec.js index dd1610c8e60..2c2bc9275b8 100644 --- a/packages/dd-trace/test/exporters/common/docker.spec.js +++ b/packages/dd-trace/test/exporters/common/docker.spec.js @@ -53,7 +53,7 @@ describe('docker', () => { it('should support IDs with Kubernetes format', () => { const cgroup = [ - '1:name=systemd:/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod2d3da189_6407_48e3_9ab6_78188d75e609.slice/docker-7b8952daecf4c0e44bbcefe1b5c5ebc7b4839d4eefeccefe694709d3809b6199.scope' // eslint-disable-line max-len + '1:name=systemd:/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod2d3da189_6407_48e3_9ab6_78188d75e609.slice/docker-7b8952daecf4c0e44bbcefe1b5c5ebc7b4839d4eefeccefe694709d3809b6199.scope' // eslint-disable-line @stylistic/js/max-len ].join('\n') fs.readFileSync.withArgs('/proc/self/cgroup').returns(Buffer.from(cgroup)) diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index 55bcb603a27..a6efcc45fa6 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -429,7 +429,7 @@ describe('request', function () { 'accept-encoding': 'gzip' } }, (err, res) => { - expect(log.error).to.have.been.calledWith('Could not gunzip response: unexpected end of file') + expect(log.error).to.have.been.calledWith('Could not gunzip response: %s', 'unexpected end of file') expect(res).to.equal('') done(err) }) diff --git a/packages/dd-trace/test/exporters/span-stats/writer.spec.js b/packages/dd-trace/test/exporters/span-stats/writer.spec.js index d65d480409d..f8e65500e04 100644 --- a/packages/dd-trace/test/exporters/span-stats/writer.spec.js +++ b/packages/dd-trace/test/exporters/span-stats/writer.spec.js @@ -106,7 +106,7 @@ describe('span-stats writer', () => { encoder.count.returns(1) writer.flush(() => { - expect(log.error).to.have.been.calledWith(error) + expect(log.error).to.have.been.calledWith('Error sending span stats', error) done() }) }) diff --git a/packages/dd-trace/test/fixtures/esm/esm-hook-test.mjs b/packages/dd-trace/test/fixtures/esm/esm-hook-test.mjs index 9f9bd110f04..ea6a7ab34fe 100644 --- a/packages/dd-trace/test/fixtures/esm/esm-hook-test.mjs +++ b/packages/dd-trace/test/fixtures/esm/esm-hook-test.mjs @@ -20,6 +20,7 @@ esmHook(['express', 'os'], (exports, name, baseDir) => { const { freemem } = await import('os') const expressResult = expressDefault() const express = typeof expressResult === 'function' ? 'express()' : expressResult + // eslint-disable-next-line no-console console.log(JSON.stringify({ express, freemem: freemem() diff --git a/packages/dd-trace/test/fixtures/telemetry/config_aggregation_list.json b/packages/dd-trace/test/fixtures/telemetry/config_aggregation_list.json new file mode 100644 index 00000000000..b23fc7ff760 --- /dev/null +++ b/packages/dd-trace/test/fixtures/telemetry/config_aggregation_list.json @@ -0,0 +1,24 @@ +{ + "tags": "tags", + "global_tag_": "global_tags", + "trace_global_tags": "trace_global_tags", + "DD_TAGS": "tags", + "trace_span_tags": "trace_span_tags", + "http_client_tag_headers": "http_client_tag_headers", + "DD_TRACE_HEADER_TAGS": "trace_header_tags", + "trace_header_tags": "trace_header_tags", + "_options.headertags": "trace_header_tags", + "trace_request_header_tags": "trace_request_header_tags", + "trace_response_header_tags": "trace_response_header_tags", + "trace_request_header_tags_comma_allowed": "trace_request_header_tags", + "trace.header_tags": "trace_header_tags", + "DD_TRACE_GRPC_TAGS": "trace_grpc_tags", + "DD_TRACE_SERVICE_MAPPING": "trace_service_mappings", + "service_mapping": "trace_service_mappings", + "serviceMapping.": "trace_service_mappings", + "logger.": "logger_configs", + "sampler.rules.": "sampler_rules", + "sampler.spansamplingrules.": "sampler_span_sampling_rules", + "appsec.rules.rules": "appsec_rules", + "installSignature": "install_signature" +} diff --git a/packages/dd-trace/test/fixtures/telemetry/config_norm_rules.json b/packages/dd-trace/test/fixtures/telemetry/config_norm_rules.json new file mode 100644 index 00000000000..d4014e8b839 --- /dev/null +++ b/packages/dd-trace/test/fixtures/telemetry/config_norm_rules.json @@ -0,0 +1,808 @@ +{ + "AWS_LAMBDA_INITIALIZATION_TYPE": "aws_lambda_initialization_type", + "COMPUTERNAME": "aas_instance_name", + "DATADOG_TRACE_AGENT_HOSTNAME": "agent_host", + "DATADOG_TRACE_AGENT_PORT": "trace_agent_port", + "DD_AAS_DOTNET_EXTENSION_VERSION": "aas_site_extensions_version", + "DD_AAS_ENABLE_CUSTOM_METRICS": "aas_custom_metrics_enabled", + "DD_AAS_ENABLE_CUSTOM_TRACING": "aas_custom_tracing_enabled", + "DD_AGENT_TRANSPORT": "agent_transport", + "DD_API_SECURITY_ENABLED": "api_security_enabled", + "DD_API_SECURITY_MAX_CONCURRENT_REQUESTS": "api_security_max_concurrent_requests", + "DD_API_SECURITY_REQUEST_SAMPLE_RATE": "api_security_request_sample_rate", + "DD_API_SECURITY_SAMPLE_DELAY": "api_security_sample_delay", + "DD_APM_ENABLE_RARE_SAMPLER": "trace_rare_sampler_enabled", + "DD_APM_RECEIVER_PORT": "trace_agent_port", + "DD_APM_RECEIVER_SOCKET": "trace_agent_socket", + "DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING": "appsec_auto_user_events_tracking", + "DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE": "appsec_auto_user_instrumentation_mode", + "DD_APPSEC_ENABLED": "appsec_enabled", + "DD_APPSEC_EXTRA_HEADERS": "appsec_extra_headers", + "DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML": "appsec_blocked_template_html", + "DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON": "appsec_blocked_template_json", + "DD_APPSEC_IPHEADER": "appsec_ip_header", + "DD_APPSEC_KEEP_TRACES": "appsec_force_keep_traces_enabled", + "DD_APPSEC_MAX_STACK_TRACES": "appsec_max_stack_traces", + "DD_APPSEC_MAX_STACK_TRACE_DEPTH": "appsec_max_stack_trace_depth", + "DD_APPSEC_MAX_STACK_TRACE_DEPTH_TOP_PERCENT": "appsec_max_stack_trace_depth_top_percent", + "DD_APPSEC_OBFUSCATION_PARAMETER_KEY_REGEXP": "appsec_obfuscation_parameter_key_regexp", + "DD_APPSEC_OBFUSCATION_PARAMETER_VALUE_REGEXP": "appsec_obfuscation_parameter_value_regexp", + "DD_APPSEC_RASP_ENABLED": "appsec_rasp_enabled", + "DD_APPSEC_RULES": "appsec_rules", + "DD_APPSEC_SCA_ENABLED": "appsec_sca_enabled", + "DD_APPSEC_STACK_TRACE_ENABLED": "appsec_stack_trace_enabled", + "DD_APPSEC_TRACE_RATE_LIMIT": "appsec_trace_rate_limit", + "DD_APPSEC_WAF_DEBUG": "appsec_waf_debug_enabled", + "DD_APPSEC_WAF_TIMEOUT": "appsec_waf_timeout", + "DD_AZURE_APP_SERVICES": "aas_enabled", + "DD_CALL_BASIC_CONFIG": "dd_call_basic_config", + "DD_CIVISIBILITY_AGENTLESS_ENABLED": "ci_visibility_agentless_enabled", + "DD_CIVISIBILITY_AGENTLESS_URL": "ci_visibility_agentless_url", + "DD_CIVISIBILITY_CODE_COVERAGE_COLLECTORPATH": "ci_visibility_code_coverage_collectorpath", + "DD_CIVISIBILITY_CODE_COVERAGE_ENABLED": "ci_visibility_code_coverage_enabled", + "DD_CIVISIBILITY_CODE_COVERAGE_ENABLE_JIT_OPTIMIZATIONS": "ci_visibility_code_coverage_jit_optimisations_enabled", + "DD_CIVISIBILITY_CODE_COVERAGE_MODE": "ci_visibility_code_coverage_mode", + "DD_CIVISIBILITY_CODE_COVERAGE_PATH": "ci_visibility_code_coverage_path", + "DD_CIVISIBILITY_CODE_COVERAGE_SNK_FILEPATH": "ci_visibility_code_coverage_snk_path", + "DD_CIVISIBILITY_EARLY_FLAKE_DETECTION_ENABLED": "ci_visibility_early_flake_detection_enabled", + "DD_CIVISIBILITY_ENABLED": "ci_visibility_enabled", + "DD_CIVISIBILITY_EXTERNAL_CODE_COVERAGE_PATH": "ci_visibility_code_coverage_external_path", + "DD_CIVISIBILITY_FLAKY_RETRY_COUNT": "ci_visibility_flaky_retry_count", + "DD_CIVISIBILITY_FLAKY_RETRY_ENABLED": "ci_visibility_flaky_retry_enabled", + "DD_CIVISIBILITY_FORCE_AGENT_EVP_PROXY": "ci_visibility_force_agent_evp_proxy_enabled", + "DD_CIVISIBILITY_GAC_INSTALL_ENABLED": "ci_visibility_gac_install_enabled", + "DD_CIVISIBILITY_GIT_UPLOAD_ENABLED": "ci_visibility_git_upload_enabled", + "DD_CIVISIBILITY_ITR_ENABLED": "ci_visibility_intelligent_test_runner_enabled", + "DD_CIVISIBILITY_LOGS_ENABLED": "ci_visibility_logs_enabled", + "DD_CIVISIBILITY_RUM_FLUSH_WAIT_MILLIS": "ci_visibility_rum_flush_wait_millis", + "DD_CIVISIBILITY_TESTSSKIPPING_ENABLED": "ci_visibility_test_skipping_enabled", + "DD_CIVISIBILITY_TOTAL_FLAKY_RETRY_COUNT": "ci_visibility_total_flaky_retry_count", + "DD_CODE_ORIGIN_FOR_SPANS_ENABLED": "code_origin_for_spans_enabled", + "DD_CODE_ORIGIN_FOR_SPANS_MAX_USER_FRAMES": "code_origin_for_spans_max_user_frames", + "DD_DATA_STREAMS_ENABLED": "data_streams_enabled", + "DD_DATA_STREAMS_LEGACY_HEADERS": "data_streams_legacy_headers", + "DD_DBM_PROPAGATION_MODE": "dbm_propagation_mode", + "DD_DEBUGGER_DIAGNOSTICS_INTERVAL": "dynamic_instrumentation_diagnostics_interval", + "DD_DEBUGGER_MAX_DEPTH_TO_SERIALIZE": "dynamic_instrumentation_serialization_max_depth", + "DD_DEBUGGER_MAX_TIME_TO_SERIALIZE": "dynamic_instrumentation_serialization_max_duration", + "DD_DEBUGGER_UPLOAD_BATCH_SIZE": "dynamic_instrumentation_upload_batch_size", + "DD_DEBUGGER_UPLOAD_FLUSH_INTERVAL": "dynamic_instrumentation_upload_interval", + "DD_DIAGNOSTIC_SOURCE_ENABLED": "trace_diagnostic_source_enabled", + "DD_DISABLED_INTEGRATIONS": "trace_disabled_integrations", + "DD_DOGSTATSD_ARGS": "agent_dogstatsd_executable_args", + "DD_DOGSTATSD_PATH": "agent_dogstatsd_executable_path", + "DD_DOGSTATSD_PIPE_NAME": "dogstatsd_named_pipe", + "DD_DOGSTATSD_PORT": "dogstatsd_port", + "DD_DOGSTATSD_SOCKET": "dogstatsd_socket", + "DD_DOGSTATSD_URL": "dogstatsd_url", + "DD_DOTNET_TRACER_CONFIG_FILE": "trace_config_file", + "DD_DYNAMIC_INSTRUMENTATION_DIAGNOSTICS_INTERVAL": "dynamic_instrumentation_diagnostics_interval", + "DD_DYNAMIC_INSTRUMENTATION_ENABLED": "dynamic_instrumentation_enabled", + "DD_DYNAMIC_INSTRUMENTATION_MAX_DEPTH_TO_SERIALIZE": "dynamic_instrumentation_serialization_max_depth", + "DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE": "dynamic_instrumentation_serialization_max_duration", + "DD_DYNAMIC_INSTRUMENTATION_REDACTED_IDENTIFIERS": "dynamic_instrumentation_redacted_identifiers", + "DD_DYNAMIC_INSTRUMENTATION_REDACTED_TYPES": "dynamic_instrumentation_redacted_types", + "DD_DYNAMIC_INSTRUMENTATION_SYMBOL_DATABASE_BATCH_SIZE_BYTES": "dynamic_instrumentation_symbol_database_batch_size_bytes", + "DD_DYNAMIC_INSTRUMENTATION_SYMBOL_DATABASE_UPLOAD_ENABLED": "dynamic_instrumentation_symbol_database_upload_enabled", + "DD_DYNAMIC_INSTRUMENTATION_UPLOAD_BATCH_SIZE": "dynamic_instrumentation_upload_batch_size", + "DD_DYNAMIC_INSTRUMENTATION_UPLOAD_FLUSH_INTERVAL": "dynamic_instrumentation_upload_interval", + "DD_ENV": "env", + "DD_EXCEPTION_DEBUGGING_CAPTURE_FULL_CALLSTACK_ENABLED": "dd_exception_debugging_capture_full_callstack_enabled", + "DD_EXCEPTION_DEBUGGING_ENABLED": "dd_exception_debugging_enabled", + "DD_EXCEPTION_DEBUGGING_MAX_EXCEPTION_ANALYSIS_LIMIT": "dd_exception_debugging_max_exception_analysis_limit", + "DD_EXCEPTION_DEBUGGING_MAX_FRAMES_TO_CAPTURE": "dd_exception_debugging_max_frames_to_capture", + "DD_EXCEPTION_DEBUGGING_RATE_LIMIT_SECONDS": "dd_exception_debugging_rate_limit_seconds", + "DD_EXCEPTION_REPLAY_CAPTURE_FULL_CALLSTACK_ENABLED": "dd_exception_replay_capture_full_callstack_enabled", + "DD_EXCEPTION_REPLAY_ENABLED": "dd_exception_replay_enabled", + "DD_EXCEPTION_REPLAY_MAX_EXCEPTION_ANALYSIS_LIMIT": "dd_exception_replay_max_exception_analysis_limit", + "DD_EXCEPTION_REPLAY_MAX_FRAMES_TO_CAPTURE": "dd_exception_replay_max_frames_to_capture", + "DD_EXCEPTION_REPLAY_RATE_LIMIT_SECONDS": "dd_exception_replay_rate_limit_seconds", + "DD_EXPERIMENTAL_API_SECURITY_ENABLED": "experimental_api_security_enabled", + "DD_EXPERIMENTAL_APPSEC_STANDALONE_ENABLED": "experimental_appsec_standalone_enabled", + "DD_EXPERIMENTAL_APPSEC_USE_UNSAFE_ENCODER": "appsec_use_unsafe_encoder", + "DD_GIT_COMMIT_SHA": "commit_sha", + "DD_GIT_REPOSITORY_URL": "repository_url", + "DD_GRPC_CLIENT_ERROR_STATUSES": "trace_grpc_client_error_statuses", + "DD_GRPC_SERVER_ERROR_STATUSES": "trace_grpc_server_error_statuses", + "DD_HTTP_CLIENT_ERROR_STATUSES": "trace_http_client_error_statuses", + "DD_HTTP_SERVER_ERROR_STATUSES": "trace_http_server_error_statuses", + "DD_HTTP_SERVER_TAG_QUERY_STRING": "trace_http_server_tag_query_string_enabled", + "DD_HTTP_SERVER_TAG_QUERY_STRING_SIZE": "trace_http_server_tag_query_string_size", + "DD_IAST_COOKIE_FILTER_PATTERN": "iast_cookie_filter_pattern", + "DD_IAST_DB_ROWS_TO_TAINT": "iast_db_rows_to_taint", + "DD_IAST_DEDUPLICATION_ENABLED": "iast_deduplication_enabled", + "DD_IAST_ENABLED": "iast_enabled", + "DD_IAST_MAX_CONCURRENT_REQUESTS": "iast_max_concurrent_requests", + "DD_IAST_MAX_RANGE_COUNT": "iast_max_range_count", + "DD_IAST_REDACTION_ENABLED": "iast_redaction_enabled", + "DD_IAST_REDACTION_KEYS_REGEXP": "iast_redaction_keys_regexp", + "DD_IAST_REDACTION_NAME_PATTERN": "iast_redaction_name_pattern", + "DD_IAST_REDACTION_REGEXP_TIMEOUT": "iast_redaction_regexp_timeout", + "DD_IAST_REDACTION_VALUES_REGEXP": "iast_redaction_values_regexp", + "DD_IAST_REDACTION_VALUE_PATTERN": "iast_redaction_value_pattern", + "DD_IAST_REGEXP_TIMEOUT": "iast_regexp_timeout", + "DD_IAST_REQUEST_SAMPLING": "iast_request_sampling_percentage", + "DD_IAST_STACK_TRACE_ENABLED": "appsec_stack_trace_enabled", + "DD_IAST_TELEMETRY_VERBOSITY": "iast_telemetry_verbosity", + "DD_IAST_TRUNCATION_MAX_VALUE_LENGTH": "iast_truncation_max_value_length", + "DD_IAST_VULNERABILITIES_PER_REQUEST": "iast_vulnerability_per_request", + "DD_IAST_WEAK_CIPHER_ALGORITHMS": "iast_weak_cipher_algorithms", + "DD_IAST_WEAK_HASH_ALGORITHMS": "iast_weak_hash_algorithms", + "DD_INJECTION_ENABLED": "ssi_injection_enabled", + "DD_INJECT_FORCE": "ssi_forced_injection_enabled", + "DD_INJECT_FORCED": "dd_lib_injection_forced", + "DD_INSTRUMENTATION_TELEMETRY_AGENTLESS_ENABLED": "instrumentation_telemetry_agentless_enabled", + "DD_INSTRUMENTATION_TELEMETRY_AGENT_PROXY_ENABLED": "instrumentation_telemetry_agent_proxy_enabled", + "DD_INSTRUMENTATION_TELEMETRY_ENABLED": "instrumentation_telemetry_enabled", + "DD_INSTRUMENTATION_TELEMETRY_URL": "instrumentation_telemetry_agentless_url", + "DD_INTERAL_FORCE_SYMBOL_DATABASE_UPLOAD": "internal_force_symbol_database_upload", + "DD_INTERNAL_RCM_POLL_INTERVAL": "remote_config_poll_interval", + "DD_INTERNAL_TELEMETRY_DEBUG_ENABLED": "instrumentation_telemetry_debug_enabled", + "DD_INTERNAL_TELEMETRY_V2_ENABLED": "instrumentation_telemetry_v2_enabled", + "DD_INTERNAL_WAIT_FOR_DEBUGGER_ATTACH": "internal_wait_for_debugger_attach_enabled", + "DD_INTERNAL_WAIT_FOR_NATIVE_DEBUGGER_ATTACH": "internal_wait_for_native_debugger_attach_enabled", + "DD_LIB_INJECTED": "dd_lib_injected", + "DD_LIB_INJECTION_ATTEMPTED": "dd_lib_injection_attempted", + "DD_LOGS_DIRECT_SUBMISSION_BATCH_PERIOD_SECONDS": "logs_direct_submission_batch_period_seconds", + "DD_LOGS_DIRECT_SUBMISSION_HOST": "logs_direct_submission_host", + "DD_LOGS_DIRECT_SUBMISSION_INTEGRATIONS": "logs_direct_submission_integrations", + "DD_LOGS_DIRECT_SUBMISSION_MAX_BATCH_SIZE": "logs_direct_submission_max_batch_size", + "DD_LOGS_DIRECT_SUBMISSION_MAX_QUEUE_SIZE": "logs_direct_submission_max_queue_size", + "DD_LOGS_DIRECT_SUBMISSION_MINIMUM_LEVEL": "logs_direct_submission_minimum_level", + "DD_LOGS_DIRECT_SUBMISSION_SOURCE": "logs_direct_submission_source", + "DD_LOGS_DIRECT_SUBMISSION_TAGS": "logs_direct_submission_tags", + "DD_LOGS_DIRECT_SUBMISSION_URL": "logs_direct_submission_url", + "DD_LOGS_INJECTION": "logs_injection_enabled", + "DD_LOG_INJECTION": "logs_injection_enabled", + "DD_LOG_LEVEL": "agent_log_level", + "DD_MAX_LOGFILE_SIZE": "trace_log_file_max_size", + "DD_MAX_TRACES_PER_SECOND": "trace_rate_limit", + "DD_PROFILING_CODEHOTSPOTS_ENABLED": "profiling_codehotspots_enabled", + "DD_PROFILING_ENABLED": "profiling_enabled", + "DD_PROFILING_ENDPOINT_COLLECTION_ENABLED": "profiling_endpoint_collection_enabled", + "DD_PROPAGATION_STYLE_EXTRACT": "trace_propagation_style_extract", + "DD_PROPAGATION_STYLE_INJECT": "trace_propagation_style_inject", + "DD_PROXY_HTTPS": "proxy_https", + "DD_PROXY_NO_PROXY": "proxy_no_proxy", + "DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS": "remote_config_poll_interval", + "DD_RUNTIME_METRICS_ENABLED": "runtime_metrics_enabled", + "DD_SERVICE": "service", + "DD_SERVICE_MAPPING": "dd_service_mapping", + "DD_SERVICE_NAME": "service", + "DD_SITE": "site", + "DD_SPAN_SAMPLING_RULES": "span_sample_rules", + "DD_SPAN_SAMPLING_RULES_FILE": "dd_span_sampling_rules_file", + "DD_SYMBOL_DATABASE_BATCH_SIZE_BYTES": "symbol_database_batch_size_bytes", + "DD_SYMBOL_DATABASE_THIRD_PARTY_DETECTION_EXCLUDES": "symbol_database_third_party_detection_excludes", + "DD_SYMBOL_DATABASE_THIRD_PARTY_DETECTION_INCLUDES": "symbol_database_third_party_detection_includes", + "DD_SYMBOL_DATABASE_UPLOAD_ENABLED": "symbol_database_upload_enabled", + "DD_SYMBOL_DATABASE_COMPRESSION_ENABLED": "symbol_database_compression_enabled", + "DD_TAGS": "agent_tags", + "DD_TELEMETRY_DEPENDENCY_COLLECTION_ENABLED": "instrumentation_telemetry_dependency_collection_enabled", + "DD_TELEMETRY_HEARTBEAT_INTERVAL": "instrumentation_telemetry_heartbeat_interval", + "DD_TELEMETRY_LOG_COLLECTION_ENABLED": "instrumentation_telemetry_log_collection_enabled", + "DD_TELEMETRY_METRICS_ENABLED": "instrumentation_telemetry_metrics_enabled", + "DD_TEST_SESSION_NAME": "test_session_name", + "DD_THIRD_PARTY_DETECTION_EXCLUDES": "third_party_detection_excludes", + "DD_THIRD_PARTY_DETECTION_INCLUDES": "third_party_detection_includes", + "DD_TRACE_128_BIT_TRACEID_GENERATION_ENABLED": "trace_128_bits_id_enabled", + "DD_TRACE_128_BIT_TRACEID_LOGGING_ENABLED": "trace_128_bits_id_logging_enabled", + "DD_TRACE_ACTIVITY_LISTENER_ENABLED": "trace_activity_listener_enabled", + "DD_TRACE_AGENT_ARGS": "agent_trace_agent_excecutable_args", + "DD_TRACE_AGENT_HOSTNAME": "agent_host", + "DD_TRACE_AGENT_PATH": "agent_trace_agent_excecutable_path", + "DD_TRACE_AGENT_PORT": "trace_agent_port", + "DD_TRACE_AGENT_URL": "trace_agent_url", + "DD_TRACE_ANALYTICS_ENABLED": "trace_analytics_enabled", + "DD_TRACE_BAGGAGE_MAX_BYTES": "trace_baggage_max_bytes", + "DD_TRACE_BAGGAGE_MAX_ITEMS": "trace_baggage_max_items", + "DD_TRACE_BATCH_INTERVAL": "trace_serialization_batch_interval", + "DD_TRACE_BUFFER_SIZE": "trace_serialization_buffer_size", + "DD_TRACE_CLIENT_IP_ENABLED": "trace_client_ip_enabled", + "DD_TRACE_CLIENT_IP_HEADER": "trace_client_ip_header", + "DD_TRACE_COMMANDS_COLLECTION_ENABLED": "trace_commands_collection_enabled", + "DD_TRACE_COMPUTE_STATS": "dd_trace_compute_stats", + "DD_TRACE_CONFIG_FILE": "trace_config_file", + "DD_TRACE_DEBUG": "trace_debug_enabled", + "DD_TRACE_DEBUG_LOOKUP_FALLBACK": "trace_lookup_fallback_enabled", + "DD_TRACE_DEBUG_LOOKUP_MDTOKEN": "trace_lookup_mdtoken_enabled", + "DD_TRACE_DELAY_WCF_INSTRUMENTATION_ENABLED": "trace_delay_wcf_instrumentation_enabled", + "DD_TRACE_DELEGATE_SAMPLING": "trace_sample_delegation", + "DD_TRACE_DISABLED_ADONET_COMMAND_TYPES": "trace_disabled_adonet_command_types", + "DD_TRACE_ENABLED": "trace_enabled", + "DD_TRACE_EXPAND_ROUTE_TEMPLATES_ENABLED": "trace_route_template_expansion_enabled", + "DD_TRACE_GIT_METADATA_ENABLED": "git_metadata_enabled", + "DD_TRACE_GLOBAL_TAGS": "trace_tags", + "DD_TRACE_HEADER_TAGS": "trace_header_tags", + "DD_TRACE_HEADER_TAG_NORMALIZATION_FIX_ENABLED": "trace_header_tag_normalization_fix_enabled", + "DD_TRACE_HEALTH_METRICS_ENABLED": "dd_trace_health_metrics_enabled", + "DD_TRACE_HTTP_CLIENT_ERROR_STATUSES": "trace_http_client_error_statuses", + "DD_TRACE_HTTP_CLIENT_EXCLUDED_URL_SUBSTRINGS": "trace_http_client_excluded_urls", + "DD_TRACE_HTTP_CLIENT_TAG_QUERY_STRING": "trace_http_client_tag_query_string", + "DD_TRACE_HTTP_SERVER_ERROR_STATUSES": "trace_http_server_error_statuses", + "DD_TRACE_KAFKA_CREATE_CONSUMER_SCOPE_ENABLED": "trace_kafka_create_consumer_scope_enabled", + "DD_TRACE_LOGFILE_RETENTION_DAYS": "trace_log_file_retention_days", + "DD_TRACE_LOGGING_RATE": "trace_log_rate", + "DD_TRACE_LOG_DIRECTORY": "trace_log_directory", + "DD_TRACE_LOG_PATH": "trace_log_path", + "DD_TRACE_LOG_SINKS": "trace_log_sinks", + "DD_TRACE_METHODS": "trace_methods", + "DD_TRACE_METRICS_ENABLED": "trace_metrics_enabled", + "DD_TRACE_OBFUSCATION_QUERY_STRING_PATTERN": "dd_trace_obfuscation_query_string_pattern", + "DD_TRACE_OBFUSCATION_QUERY_STRING_REGEXP": "trace_obfuscation_query_string_regexp", + "DD_TRACE_OBFUSCATION_QUERY_STRING_REGEXP_TIMEOUT": "trace_obfuscation_query_string_regexp_timeout", + "DD_TRACE_OTEL_ENABLED": "trace_otel_enabled", + "DD_TRACE_OTEL_LEGACY_OPERATION_NAME_ENABLED": "trace_otel_legacy_operation_name_enabled", + "DD_TRACE_PARTIAL_FLUSH_ENABLED": "trace_partial_flush_enabled", + "DD_TRACE_PARTIAL_FLUSH_MIN_SPANS": "trace_partial_flush_min_spans", + "DD_TRACE_PEER_SERVICE_DEFAULTS_ENABLED": "trace_peer_service_defaults_enabled", + "DD_TRACE_PEER_SERVICE_MAPPING": "trace_peer_service_mapping", + "DD_TRACE_PIPE_NAME": "trace_agent_named_pipe", + "DD_TRACE_PIPE_TIMEOUT_MS": "trace_agent_named_pipe_timeout_ms", + "DD_TRACE_PROPAGATION_EXTRACT_FIRST": "trace_propagation_extract_first", + "DD_TRACE_PROPAGATION_STYLE": "trace_propagation_style", + "DD_TRACE_PROPAGATION_STYLE_EXTRACT": "trace_propagation_style_extract", + "DD_TRACE_PROPAGATION_STYLE_INJECT": "trace_propagation_style_inject", + "DD_TRACE_RATE_LIMIT": "trace_rate_limit", + "DD_TRACE_REMOVE_INTEGRATION_SERVICE_NAMES_ENABLED": "trace_remove_integration_service_names_enabled", + "DD_TRACE_ROUTE_TEMPLATE_RESOURCE_NAMES_ENABLED": "trace_route_template_resource_names_enabled", + "DD_TRACE_SAMPLING_RULES": "trace_sample_rules", + "DD_TRACE_SAMPLING_RULES_FORMAT": "trace_sampling_rules_format", + "DD_TRACE_SPAN_ATTRIBUTE_SCHEMA": "trace_span_attribute_schema", + "DD_TRACE_STARTUP_LOGS": "trace_startup_logs_enabled", + "DD_TRACE_STATS_COMPUTATION_ENABLED": "trace_stats_computation_enabled", + "DD_TRACE_WCF_RESOURCE_OBFUSCATION_ENABLED": "trace_wcf_obfuscation_enabled", + "DD_TRACE_WCF_WEB_HTTP_RESOURCE_NAMES_ENABLED": "trace_wcf_web_http_resource_names_enabled", + "DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH": "trace_x_datadog_tags_max_length", + "DD_VERSION": "application_version", + "FUNCTIONS_EXTENSION_VERSION": "aas_functions_runtime_version", + "FUNCTIONS_WORKER_RUNTIME": "aas_functions_worker_runtime", + "FUNCTION_NAME": "gcp_deprecated_function_name", + "FUNCTION_TARGET": "gcp_function_target", + "GCP_PROJECT": "gcp_deprecated_project", + "K_SERVICE": "gcp_function_name", + "OTEL_LOGS_EXPORTER": "otel_logs_exporter", + "OTEL_LOG_LEVEL": "otel_log_level", + "OTEL_METRICS_EXPORTER": "otel_metrics_exporter", + "OTEL_PROPAGATORS": "otel_propagators", + "OTEL_RESOURCE_ATTRIBUTES": "otel_resource_attributes", + "OTEL_SDK_DISABLED": "otel_sdk_disabled", + "OTEL_SERVICE_NAME": "otel_service_name", + "OTEL_TRACES_EXPORTER": "otel_traces_exporter", + "OTEL_TRACES_SAMPLER": "otel_traces_sampler", + "OTEL_TRACES_SAMPLER_ARG": "otel_traces_sampler_arg", + "WEBSITE_INSTANCE_ID": "aas_website_instance_id", + "WEBSITE_OS": "aas_website_os", + "WEBSITE_OWNER_NAME": "aas_website_owner_name", + "WEBSITE_RESOURCE_GROUP": "aas_website_resource_group", + "WEBSITE_SITE_NAME": "aas_website_site_name", + "WEBSITE_SKU": "aas_website_sku", + "_DD_TRACE_STATS_COMPUTATION_INTERVAL": "trace_stats_computation_interval", + "_dd_appsec_deduplication_enabled": "appsec_deduplication_enabled", + "_dd_iast_debug": "iast_debug_enabled", + "_dd_iast_lazy_taint": "iast_lazy_taint", + "_dd_iast_propagation_debug": "iast_propagation_debug", + "_dd_inject_was_attempted": "trace_inject_was_attempted", + "_dd_llmobs_evaluator_sampling_rules": "llmobs_evaluator_sampling_rules", + "aas_app_type": "aas_app_type", + "aas_configuration_error": "aas_configuration_error", + "aas_functions_runtime_version": "aas_functions_runtime_version", + "aas_siteextensions_version": "aas_site_extensions_version", + "activity_listener_enabled": "activity_listener_enabled", + "agent_feature_drop_p0s": "agent_feature_drop_p0s", + "agent_transport": "agent_transport", + "agent_url": "trace_agent_url", + "analytics_enabled": "analytics_enabled", + "appsec.apiSecurity.enabled": "api_security_enabled", + "appsec.apiSecurity.requestSampling": "api_security_request_sample_rate", + "appsec.apiSecurity.sampleDelay": "api_security_sample_delay", + "appsec.blockedTemplateGraphql": "appsec_blocked_template_graphql", + "appsec.blockedTemplateHtml": "appsec_blocked_template_html", + "appsec.blockedTemplateJson": "appsec_blocked_template_json", + "appsec.customRulesProvided": "appsec_rules_custom_provided", + "appsec.enabled": "appsec_enabled", + "appsec.eventTracking": "appsec_auto_user_events_tracking", + "appsec.eventTracking.mode": "appsec_auto_user_events_tracking", + "appsec.obfuscatorKeyRegex": "appsec_obfuscation_parameter_key_regexp", + "appsec.obfuscatorValueRegex": "appsec_obfuscation_parameter_value_regexp", + "appsec.rasp.enabled": "appsec_rasp_enabled", + "appsec.rateLimit": "appsec_rate_limit", + "appsec.rules": "appsec_rules", + "appsec.rules.metadata.rules_version": "appsec_rules_metadata_rules_version", + "appsec.rules.version": "appsec_rules_version", + "appsec.sca.enabled": "appsec_sca_enabled", + "appsec.sca_enabled": "appsec_sca_enabled", + "appsec.stackTrace.enabled": "appsec_stack_trace_enabled", + "appsec.stackTrace.maxDepth": "appsec_max_stack_trace_depth", + "appsec.stackTrace.maxStackTraces": "appsec_max_stack_traces", + "appsec.standalone.enabled": "experimental_appsec_standalone_enabled", + "appsec.testing": "appsec_testing", + "appsec.trace.rate.limit": "appsec_trace_rate_limit", + "appsec.waf.timeout": "appsec_waf_timeout", + "appsec.wafTimeout": "appsec_waf_timeout", + "autofinish_spans": "trace_auto_finish_spans_enabled", + "autoload_no_compile": "autoload_no_compile", + "aws.dynamoDb.tablePrimaryKeys": "aws_dynamodb_table_primary_keys", + "baggageMaxBytes": "trace_baggage_max_bytes", + "baggageMaxItems": "trace_baggage_max_items", + "ciVisAgentlessLogSubmissionEnabled": "ci_visibility_agentless_enabled", + "ciVisibilityTestSessionName": "test_session_name", + "civisibility.agentless.enabled": "ci_visibility_agentless_enabled", + "civisibility.enabled": "ci_visibility_enabled", + "clientIpEnabled": "trace_client_ip_enabled", + "clientIpHeader": "trace_client_ip_header", + "clientIpHeaderDisabled": "client_ip_header_disabled", + "cloudPayloadTagging.maxDepth": "cloud_payload_tagging_max_depth", + "cloudPayloadTagging.requestsEnabled": "cloud_payload_tagging_requests_enabled", + "cloudPayloadTagging.responsesEnabled": "cloud_payload_tagging_responses_enabled", + "cloudPayloadTagging.rules.aws.eventbridge.expand": "cloud_payload_tagging_rules_aws_eventbridge_expand", + "cloudPayloadTagging.rules.aws.eventbridge.request": "cloud_payload_tagging_rules_aws_eventbridge_request", + "cloudPayloadTagging.rules.aws.eventbridge.response": "cloud_payload_tagging_rules_aws_eventbridge_response", + "cloudPayloadTagging.rules.aws.kinesis.expand": "cloud_payload_tagging_rules_aws_kinesis_expand", + "cloudPayloadTagging.rules.aws.kinesis.request": "cloud_payload_tagging_rules_aws_kinesis_request", + "cloudPayloadTagging.rules.aws.kinesis.response": "cloud_payload_tagging_rules_aws_kinesis_response", + "cloudPayloadTagging.rules.aws.s3.expand": "cloud_payload_tagging_rules_aws_s3_expand", + "cloudPayloadTagging.rules.aws.s3.request": "cloud_payload_tagging_rules_aws_s3_request", + "cloudPayloadTagging.rules.aws.s3.response": "cloud_payload_tagging_rules_aws_s3_response", + "cloudPayloadTagging.rules.aws.sns.expand": "cloud_payload_tagging_rules_aws_sns_expand", + "cloudPayloadTagging.rules.aws.sns.request": "cloud_payload_tagging_rules_aws_sns_request", + "cloudPayloadTagging.rules.aws.sns.response": "cloud_payload_tagging_rules_aws_sns_response", + "cloudPayloadTagging.rules.aws.sqs.expand": "cloud_payload_tagging_rules_aws_sqs_expand", + "cloudPayloadTagging.rules.aws.sqs.request": "cloud_payload_tagging_rules_aws_sqs_request", + "cloudPayloadTagging.rules.aws.sqs.response": "cloud_payload_tagging_rules_aws_sqs_response", + "cloud_hosting": "cloud_hosting_provider", + "codeOriginForSpans.enabled": "code_origin_for_spans_enabled", + "code_hotspots_enabled": "code_hotspots_enabled", + "commitSHA": "commit_sha", + "crashtracking.enabled": "crashtracking_enabled", + "crashtracking_alt_stack": "crashtracking_alt_stack", + "crashtracking_available": "crashtracking_available", + "crashtracking_debug_url": "crashtracking_debug_url", + "crashtracking_enabled": "crashtracking_enabled", + "crashtracking_stacktrace_resolver": "crashtracking_stacktrace_resolver", + "crashtracking_started": "crashtracking_started", + "crashtracking_stderr_filename": "crashtracking_stderr_filename", + "crashtracking_stdout_filename": "crashtracking_stdout_filename", + "cws.enabled": "cws_enabled", + "data.streams.enabled": "data_streams_enabled", + "data_streams_enabled": "data_streams_enabled", + "dbmPropagationMode": "dbm_propagation_mode", + "dbm_propagation_mode": "dbm_propagation_mode", + "dd.trace.debug": "trace_debug_enabled", + "dd_agent_host": "agent_host", + "dd_agent_port": "trace_agent_port", + "dd_analytics_enabled": "analytics_enabled", + "dd_api_security_parse_response_body": "appsec_parse_response_body", + "dd_appsec_automated_user_events_tracking_enabled": "appsec_auto_user_events_tracking_enabled", + "dd_civisibility_log_level": "ci_visibility_log_level", + "dd_crashtracking_create_alt_stack": "crashtracking_create_alt_stack", + "dd_crashtracking_debug_url": "crashtracking_debug_url", + "dd_crashtracking_enabled": "crashtracking_enabled", + "dd_crashtracking_stacktrace_resolver": "crashtracking_stacktrace_resolver", + "dd_crashtracking_stderr_filename": "crashtracking_stderr_filename", + "dd_crashtracking_stdout_filename": "crashtracking_stdout_filename", + "dd_crashtracking_tags": "crashtracking_tags", + "dd_crashtracking_use_alt_stack": "crashtracking_alt_stack", + "dd_crashtracking_wait_for_receiver": "crashtracking_wait_for_receiver", + "dd_dynamic_instrumentation_max_payload_size": "dynamic_instrumentation_max_payload_size", + "dd_dynamic_instrumentation_metrics_enabled": "dynamic_instrumentation_metrics_enabled", + "dd_dynamic_instrumentation_upload_timeout": "dynamic_instrumentation_upload_timeout", + "dd_http_client_tag_query_string": "trace_http_client_tag_query_string", + "dd_iast_redaction_value_numeral": "iast_redaction_value_numeral", + "dd_instrumentation_install_id": "instrumentation_install_id", + "dd_instrumentation_install_type": "instrumentation_install_type", + "dd_llmobs_agentless_enabled": "llmobs_agentless_enabled", + "dd_llmobs_enabled": "llmobs_enabled", + "dd_llmobs_ml_app": "llmobs_ml_app", + "dd_llmobs_sample_rate": "llmobs_sample_rate", + "dd_priority_sampling": "trace_priority_sampling_enabled", + "dd_profiling_agentless": "profiling_agentless", + "dd_profiling_api_timeout": "profiling_api_timeout", + "dd_profiling_capture_pct": "profiling_capture_pct", + "dd_profiling_enable_asserts": "profiling_enable_asserts", + "dd_profiling_enable_code_provenance": "profiling_enable_code_provenance", + "dd_profiling_export_libdd_enabled": "profiling_export_libdd_enabled", + "dd_profiling_export_py_enabled": "profiling_export_py_enabled", + "dd_profiling_force_legacy_exporter": "profiling_force_legacy_exporter", + "dd_profiling_heap_enabled": "profiling_heap_enabled", + "dd_profiling_heap_sample_size": "profiling_heap_sample_size", + "dd_profiling_ignore_profiler": "profiling_ignore_profiler", + "dd_profiling_lock_enabled": "profiling_lock_enabled", + "dd_profiling_lock_name_inspect_dir": "profiling_lock_name_inspect_dir", + "dd_profiling_max_events": "profiling_max_events", + "dd_profiling_max_frames": "profiling_max_frames", + "dd_profiling_max_time_usage_pct": "profiling_max_time_usage_pct", + "dd_profiling_memory_enabled": "profiling_memory_enabled", + "dd_profiling_memory_events_buffer": "profiling_memory_events_buffer", + "dd_profiling_output_pprof": "profiling_output_pprof", + "dd_profiling_sample_pool_capacity": "profiling_sample_pool_capacity", + "dd_profiling_stack_enabled": "profiling_stack_enabled", + "dd_profiling_stack_v2_enabled": "profiling_stack_v2_enabled", + "dd_profiling_tags": "profiling_tags", + "dd_profiling_timeline_enabled": "profiling_timeline_enabled", + "dd_profiling_upload_interval": "profiling_upload_interval", + "dd_remote_configuration_enabled": "remote_config_enabled", + "dd_remoteconfig_poll_seconds": "remote_config_poll_interval", + "dd_symbol_database_includes": "symbol_database_includes", + "dd_testing_raise": "testing_raise", + "dd_trace_agent_timeout_seconds": "trace_agent_timeout", + "dd_trace_api_version": "trace_api_version", + "dd_trace_propagation_http_baggage_enabled": "trace_propagation_http_baggage_enabled", + "dd_trace_report_hostname": "trace_report_hostname", + "dd_trace_sample_rate": "trace_sample_rate", + "dd_trace_span_links_enabled": "trace_span_links_enabled", + "dd_trace_span_traceback_max_size": "trace_span_traceback_max_size", + "dd_trace_writer_buffer_size_bytes": "trace_serialization_buffer_size", + "dd_trace_writer_interval_seconds": "trace_agent_flush_interval", + "dd_trace_writer_max_payload_size_bytes": "trace_agent_max_payload_size", + "dd_trace_writer_reuse_connections": "trace_agent_reuse_connections", + "ddtrace_auto_used": "ddtrace_auto_used", + "ddtrace_bootstrapped": "ddtrace_bootstrapped", + "debug": "trace_debug_enabled", + "debug_stack_enabled": "debug_stack_enabled", + "discovery": "agent_discovery_enabled", + "distributed_tracing": "trace_distributed_trace_enabled", + "dogstatsd.hostname": "dogstatsd_hostname", + "dogstatsd.port": "dogstatsd_port", + "dogstatsd.start-delay": "dogstatsd_start_delay", + "dogstatsd_addr": "dogstatsd_url", + "dogstatsd_url": "dogstatsd_url", + "dsmEnabled": "data_streams_enabled", + "dynamic.instrumentation.classfile.dump.enabled": "dynamic_instrumentation_classfile_dump_enabled", + "dynamic.instrumentation.enabled": "dynamic_instrumentation_enabled", + "dynamic.instrumentation.metrics.enabled": "dynamic_instrumentation_metrics_enabled", + "dynamicInstrumentationEnabled": "dynamic_instrumentation_enabled", + "dynamic_instrumentation.enabled": "dynamic_instrumentation_enabled", + "dynamic_instrumentation.redacted_identifiers": "dynamic_instrumentation_redacted_identifiers", + "dynamic_instrumentation.redacted_types": "dynamic_instrumentation_redacted_types", + "enabled": "trace_enabled", + "env": "env", + "environment_fulltrust_appdomain": "environment_fulltrust_appdomain_enabled", + "exception_replay_capture_interval_seconds": "dd_exception_replay_capture_interval_seconds", + "exception_replay_capture_max_frames": "dd_exception_replay_capture_max_frames", + "exception_replay_enabled": "dd_exception_replay_enabled", + "experimental.b3": "experimental_b3", + "experimental.enableGetRumData": "experimental_enable_get_rum_data", + "experimental.exporter": "experimental_exporter", + "experimental.runtimeId": "experimental_runtime_id", + "experimental.sampler.rateLimit": "experimental_sampler_rate_limit", + "experimental.sampler.sampleRate": "experimental_sampler_sample_rate", + "experimental.traceparent": "experimental_traceparent", + "flakyTestRetriesCount": "ci_visibility_flaky_retry_count", + "flushInterval": "flush_interval", + "flushMinSpans": "flush_min_spans", + "gitMetadataEnabled": "git_metadata_enabled", + "git_commit_sha": "commit_sha", + "git_repository_url": "repository_url", + "global_tag_version": "version", + "grpc.client.error.statuses": "trace_grpc_client_error_statuses", + "grpc.server.error.statuses": "trace_grpc_server_error_statuses", + "headerTags": "trace_header_tags", + "hostname": "agent_hostname", + "http.client.tag.query-string": "trace_http_client_tag_query_string", + "http.server.route-based-naming": "trace_http_server_route_based_naming_enabled", + "http.server.tag.query-string": "trace_http_server_tag_query_string", + "http_server_route_based_naming": "http_server_route_based_naming", + "hystrix.measured.enabled": "hystrix_measured_enabled", + "hystrix.tags.enabled": "hystrix_tags_enabled", + "iast.cookieFilterPattern": "iast_cookie_filter_pattern", + "iast.dbRowsToTaint": "iast_db_rows_to_taint", + "iast.debug.enabled": "iast_debug_enabled", + "iast.deduplication.enabled": "iast_deduplication_enabled", + "iast.deduplicationEnabled": "iast_deduplication_enabled", + "iast.enabled": "iast_enabled", + "iast.max-concurrent-requests": "iast_max_concurrent_requests", + "iast.maxConcurrentRequests": "iast_max_concurrent_requests", + "iast.maxContextOperations": "iast_max_context_operations", + "iast.redactionEnabled": "iast_redaction_enabled", + "iast.redactionNamePattern": "iast_redaction_name_pattern", + "iast.redactionValuePattern": "iast_redaction_value_pattern", + "iast.request-sampling": "iast_request_sampling", + "iast.requestSampling": "iast_request_sampling", + "iast.telemetryVerbosity": "iast_telemetry_verbosity", + "iast.vulnerabilities-per-request": "iast_vulnerability_per_request", + "ignite.cache.include_keys": "ignite_cache_include_keys_enabled", + "inferredProxyServicesEnabled": "inferred_proxy_services_enabled", + "inject_force": "ssi_forced_injection_enabled", + "injectionEnabled": "ssi_injection_enabled", + "instrumentation.telemetry.enabled": "instrumentation_telemetry_enabled", + "instrumentation_config_id": "instrumentation_config_id", + "integration_metrics_enabled": "integration_metrics_enabled", + "integrations.enabled": "trace_integrations_enabled", + "integrations_disabled": "trace_disabled_integrations", + "isAzureFunction": "azure_function", + "isCiVisibility": "ci_visibility_enabled", + "isEarlyFlakeDetectionEnabled": "ci_visibility_early_flake_detection_enabled", + "isFlakyTestRetriesEnabled": "ci_visibility_flaky_retry_enabled", + "isGCPFunction": "is_gcp_function", + "isGitUploadEnabled": "git_upload_enabled", + "isIntelligentTestRunnerEnabled": "intelligent_test_runner_enabled", + "isManualApiEnabled": "ci_visibility_manual_api_enabled", + "isTestDynamicInstrumentationEnabled": "ci_visibility_test_dynamic_instrumentation_enabled", + "jmxfetch.check-period": "jmxfetch_check_period", + "jmxfetch.enabled": "jmxfetch_enabled", + "jmxfetch.initial-refresh-beans-period": "jmxfetch_initial_refresh_beans_period", + "jmxfetch.multiple-runtime-services.enabled": "jmxfetch_multiple_runtime_services_enabled", + "jmxfetch.refresh-beans-period": "jmxfetch_initial_refresh_beans_period", + "jmxfetch.statsd.port": "jmxfetch_statsd_port", + "kafka.client.base64.decoding.enabled": "trace_kafka_client_base64_decoding_enabled", + "lambda_mode": "lambda_mode", + "langchain.spanCharLimit": "open_ai_span_char_limit", + "langchain.spanPromptCompletionSampleRate": "open_ai_span_prompt_completion_sample_rate", + "legacy.installer.enabled": "legacy_installer_enabled", + "legacyBaggageEnabled": "trace_legacy_baggage_enabled", + "llmobs.agentlessEnabled": "open_ai_agentless_enabled", + "llmobs.enabled": "open_ai_enabled", + "llmobs.mlApp": "open_ai_ml_app", + "logInjection": "logs_injection_enabled", + "logInjection_enabled": "logs_injection_enabled", + "logLevel": "trace_log_level", + "log_backtrace": "trace_log_backtrace_enabled", + "logger": "logger", + "logs.injection": "logs_injection_enabled", + "logs.mdc.tags.injection": "logs_mdc_tags_injection_enabled", + "lookup": "lookup", + "managed_tracer_framework": "managed_tracer_framework", + "memcachedCommandEnabled": "memchached_command_enabled", + "message.broker.split-by-destination": "message_broker_split_by_destination", + "native_tracer_version": "native_tracer_version", + "openAiLogsEnabled": "open_ai_logs_enabled", + "openaiSpanCharLimit": "open_ai_span_char_limit", + "openai_log_prompt_completion_sample_rate": "open_ai_log_prompt_completion_sample_rate", + "openai_logs_enabled": "open_ai_logs_enabled", + "openai_metrics_enabled": "open_ai_metrics_enabled", + "openai_service": "open_ai_service", + "openai_span_char_limit": "open_ai_span_char_limit", + "openai_span_prompt_completion_sample_rate": "open_ai_span_prompt_completion_sample_rate", + "orchestrion_enabled": "orchestrion_enabled", + "orchestrion_version": "orchestrion_version", + "os.name": "os_name", + "otel_enabled": "trace_otel_enabled", + "partialflush_enabled": "trace_partial_flush_enabled", + "partialflush_minspans": "trace_partial_flush_min_spans", + "peerServiceMapping": "trace_peer_service_mapping", + "platform": "platform", + "plugins": "plugins", + "port": "trace_agent_port", + "priority.sampling": "trace_priority_sample_enabled", + "priority_sampling": "trace_priority_sampling_enabled", + "profiler_loaded": "profiler_loaded", + "profiling.advanced.code_provenance_enabled": "profiling_enable_code_provenance", + "profiling.advanced.endpoint.collection.enabled": "profiling_endpoint_collection_enabled", + "profiling.allocation.enabled": "profiling_allocation_enabled", + "profiling.async.alloc.enabled": "profiling_async_alloc_enabled", + "profiling.async.cpu.enabled": "profiling_async_cpu_enabled", + "profiling.async.enabled": "profiling_async_enabled", + "profiling.async.memleak.enabled": "profiling_async_memleak_enabled", + "profiling.async.wall.enabled": "profiling_async_wall_enabled", + "profiling.ddprof.alloc.enabled": "profiling_ddprof_alloc_enabled", + "profiling.ddprof.cpu.enabled": "profiling_ddprof_cpu_enabled", + "profiling.ddprof.enabled": "profiling_ddprof_enabled", + "profiling.ddprof.memleak.enabled": "profiling_ddprof_memleak_enabled", + "profiling.ddprof.wall.enabled": "profiling_ddprof_wall_enabled", + "profiling.directallocation.enabled": "profiling_direct_allocation_enabled", + "profiling.enabled": "profiling_enabled", + "profiling.exporters": "profiling_exporters", + "profiling.heap.enabled": "profiling_heap_enabled", + "profiling.hotspots.enabled": "profiling_hotspots_enabled", + "profiling.legacy.tracing.integration": "profiling_legacy_tracing_integration_enabled", + "profiling.longLivedThreshold": "profiling_long_lived_threshold", + "profiling.sourceMap": "profiling_source_map_enabled", + "profiling.start-delay": "profiling_start_delay", + "profiling.start-force-first": "profiling_start_force_first", + "profiling.upload.period": "profiling_upload_period", + "profiling_endpoints_enabled": "profiling_endpoints_enabled", + "protocolVersion": "trace_agent_protocol_version", + "queryStringObfuscation": "trace_obfuscation_query_string_regexp", + "rcPollingInterval": "rc_polling_interval", + "remoteConfig.enabled": "remote_config_enabled", + "remoteConfig.pollInterval": "remote_config_poll_interval", + "remote_config.enabled": "remote_config_enabled", + "remote_config_poll_interval_seconds": "remote_config_poll_interval", + "reportHostname": "trace_report_hostname", + "repositoryUrl": "repository_url", + "resolver.outline.pool.enabled": "resolver_outline_pool_enabled", + "resolver.use.loadclass": "resolver_use_loadclass", + "retry_interval": "retry_interval", + "routetemplate_expansion_enabled": "trace_route_template_expansion_enabled", + "routetemplate_resourcenames_enabled": "trace_route_template_resource_names_enabled", + "runtime.metrics.enabled": "runtime_metrics_enabled", + "runtimeMetrics": "runtime_metrics_enabled", + "runtime_metrics.enabled": "runtime_metrics_enabled", + "runtime_metrics_v2_enabled": "runtime_metrics_v2_enabled", + "runtimemetrics_enabled": "runtime_metrics_enabled", + "sampleRate": "trace_sample_rate", + "sample_rate": "trace_sample_rate", + "sampler.rateLimit": "trace_rate_limit", + "sampler.rules": "trace_sample_rules", + "sampler.sampleRate": "trace_sample_rate", + "sampler.spanSamplingRules": "span_sample_rules", + "sampling_rules": "trace_sample_rules", + "scope": "scope", + "security_enabled": "appsec_enabled", + "send_retries": "trace_send_retries", + "service": "service", + "serviceMapping": "dd_service_mapping", + "site": "site", + "spanAttributeSchema": "trace_span_attribute_schema", + "spanComputePeerService": "trace_peer_service_defaults_enabled", + "spanLeakDebug": "span_leak_debug", + "spanRemoveIntegrationFromService": "trace_remove_integration_service_names_enabled", + "span_sampling_rules": "span_sample_rules", + "span_sampling_rules_file": "span_sample_rules_file", + "ssi_forced_injection_enabled": "ssi_forced_injection_enabled", + "ssi_injection_enabled": "ssi_injection_enabled", + "startupLogs": "trace_startup_logs_enabled", + "stats.enabled": "stats_enabled", + "stats_computation_enabled": "trace_stats_computation_enabled", + "tagsHeaderMaxLength": "trace_header_tags_max_length", + "telemetry.debug": "instrumentation_telemetry_debug_enabled", + "telemetry.dependencyCollection": "instrumentation_telemetry_dependency_collection_enabled", + "telemetry.enabled": "instrumentation_telemetry_enabled", + "telemetry.heartbeat.interval": "instrumentation_telemetry_heartbeat_interval", + "telemetry.heartbeatInterval": "instrumentation_telemetry_heartbeat_interval", + "telemetry.logCollection": "instrumentation_telemetry_log_collection_enabled", + "telemetry.metrics": "instrumentation_telemetry_metrics_enabled", + "telemetry.metricsInterval": "instrumentation_telemetry_metrics_interval", + "telemetryEnabled": "instrumentation_telemetry_enabled", + "telemetry_heartbeat_interval": "instrumentation_telemetry_heartbeat_interval", + "trace.128_bit_traceid_generation_enabled": "trace_128_bits_id_enabled", + "trace.128_bit_traceid_logging_enabled": "trace_128_bits_id_logging_enabled", + "trace.agent.port": "trace_agent_port", + "trace.agent.timeout": "trace_agent_timeout", + "trace.agent.v0.5.enabled": "trace_agent_v0.5_enabled", + "trace.agent_attempt_retry_time_msec": "trace_agent_attempt_retry_time_msec", + "trace.agent_connect_timeout": "trace_agent_connect_timeout", + "trace.agent_debug_verbose_curl": "trace_agent_debug_verbose_curl_enabled", + "trace.agent_flush_after_n_requests": "trace_agent_flush_after_n_requests", + "trace.agent_flush_interval": "trace_agent_flush_interval", + "trace.agent_max_consecutive_failures": "trace_send_retries", + "trace.agent_max_payload_size": "trace_agent_max_payload_size", + "trace.agent_port": "trace_agent_port", + "trace.agent_retries": "trace_send_retries", + "trace.agent_stack_backlog": "trace_agent_stack_backlog", + "trace.agent_stack_initial_size": "trace_agent_stack_initial_size", + "trace.agent_test_session_token": "trace_agent_test_session_token", + "trace.agent_timeout": "trace_agent_timeout", + "trace.agent_url": "trace_agent_url", + "trace.agentless": "trace_agentless", + "trace.analytics.enabled": "trace_analytics_enabled", + "trace.analytics_enabled": "trace_analytics_enabled", + "trace.append_trace_ids_to_logs": "trace_append_trace_ids_to_logs", + "trace.auto_flush_enabled": "trace_auto_flush_enabled", + "trace.aws-sdk.legacy.tracing.enabled": "trace_aws_sdk_legacy_tracing_enabled", + "trace.aws-sdk.propagation.enabled": "trace_aws_sdk_propagation_enabled", + "trace.beta_high_memory_pressure_percent": "trace_beta_high_memory_pressure_percent", + "trace.bgs_connect_timeout": "trace_bgs_connect_timeout", + "trace.bgs_timeout": "trace_bgs_timeout", + "trace.buffer_size": "trace_serialization_buffer_size", + "trace.cli_enabled": "trace_cli_enabled", + "trace.client-ip.enabled": "trace_client_ip_enabled", + "trace.client-ip.resolver.enabled": "trace_client_ip_resolver_enabled", + "trace.client_ip_enabled": "trace_client_ip_enabled", + "trace.client_ip_header": "client_ip_header", + "trace.db.client.split-by-instance": "trace_db_client_split_by_instance", + "trace.db.client.split-by-instance.type.suffix": "trace_db_client_split_by_instance_type_suffix", + "trace.db_client_split_by_instance": "trace_db_client_split_by_instance", + "trace.debug": "trace_debug_enabled", + "trace.debug_curl_output": "trace_debug_curl_output_enabled", + "trace.debug_prng_seed": "trace_debug_prng_seed", + "trace.enabled": "trace_enabled", + "trace.flush_collect_cycles": "trace_flush_collect_cycles_enabled", + "trace.forked_process": "trace_forked_process_enabled", + "trace.generate_root_span": "trace_generate_root_span_enabled", + "trace.git_metadata_enabled": "git_metadata_enabled", + "trace.grpc.server.trim-package-resource": "trace_grpc_server_trim_package_resource_enabled", + "trace.header.tags.legacy.parsing.enabled": "trace_header_tags_legacy_parsing_enabled", + "trace.health.metrics.enabled": "trace_health_metrics_enabled", + "trace.health.metrics.statsd.port": "trace_health_metrics_statsd_port", + "trace.health_metrics_enabled": "trace_health_metrics_enabled", + "trace.health_metrics_heartbeat_sample_rate": "trace_health_metrics_heartbeat_sample_rate", + "trace.hook_limit": "trace_hook_limit", + "trace.http.client.split-by-domain": "trace_http_client_split_by_domain", + "trace.http_client_split_by_domain": "trace_http_client_split_by_domain", + "trace.http_post_data_param_allowed": "trace_http_post_data_param_allowed", + "trace.http_url_query_param_allowed": "trace_http_url_query_param_allowed", + "trace.jms.propagation.enabled": "trace_jms_propagation_enabled", + "trace.jmxfetch.kafka.enabled": "trace_jmxfetch_kafka_enabled", + "trace.jmxfetch.tomcat.enabled": "trace_jmxfetch_tomcat_enabled", + "trace.kafka.client.propagation.enabled": "trace_kafka_client_propagation_enabled", + "trace.laravel_queue_distributed_tracing": "trace_laravel_queue_distributed_tracing", + "trace.log_file": "trace_log_file", + "trace.log_level": "trace_log_level", + "trace.measure_compile_time": "trace_measure_compile_time_enabled", + "trace.measure_peak_memory_usage": "trace_measure_peak_memory_usage_enabled", + "trace.memcached_obfuscation": "trace_memcached_obfuscation_enabled", + "trace.memory_limit": "trace_memory_limit", + "trace.obfuscation_query_string_regexp": "trace_obfuscation_query_string_regexp", + "trace.once_logs": "trace_once_logs", + "trace.otel.enabled": "trace_otel_enabled", + "trace.otel_enabled": "trace_otel_enabled", + "trace.partial.flush.min.spans": "trace_partial_flush_min_spans", + "trace.peer.service.defaults.enabled": "trace_peer_service_defaults_enabled", + "trace.peer.service.mapping": "trace_peer_service_mapping", + "trace.peer_service_defaults_enabled": "trace_peer_service_defaults_enabled", + "trace.peer_service_mapping": "trace_peer_service_mapping", + "trace.peerservicetaginterceptor.enabled": "trace_peer_service_tag_interceptor_enabled", + "trace.perf.metrics.enabled": "trace_perf_metrics_enabled", + "trace.play.report-http-status": "trace_play_report_http_status", + "trace.propagate_service": "trace_propagate_service", + "trace.propagate_user_id_default": "trace_propagate_user_id_default_enabled", + "trace.propagation_extract_first": "trace_propagation_extract_first", + "trace.propagation_style": "trace_propagation_style", + "trace.propagation_style_extract": "trace_propagation_style_extract", + "trace.propagation_style_inject": "trace_propagation_style_inject", + "trace.rabbit.propagation.enabled": "trace_rabbit_propagation_enabled", + "trace.rate.limit": "trace_rate_limit", + "trace.rate_limit": "trace_rate_limit", + "trace.redis_client_split_by_host": "trace_redis_client_split_by_host_enabled", + "trace.remove.integration-service-names.enabled": "trace_remove_integration_service_names_enabled", + "trace.remove_autoinstrumentation_orphans": "trace_remove_auto_instrumentation_orphans_enabled", + "trace.remove_integration_service_names_enabled": "trace_remove_integration_service_names_enabled", + "trace.remove_root_span_laravel_queue": "trace_remove_root_span_laravel_queue_enabled", + "trace.remove_root_span_symfony_messenger": "trace_remove_root_span_symfony_messenger_enabled", + "trace.report-hostname": "trace_report_hostname", + "trace.report_hostname": "trace_report_hostname", + "trace.request_init_hook": "trace_request_init_hook", + "trace.resource_uri_fragment_regex": "trace_resource_uri_fragment_regex", + "trace.resource_uri_mapping_incoming": "trace_resource_uri_mapping_incoming", + "trace.resource_uri_mapping_outgoing": "trace_resource_uri_mapping_outgoing", + "trace.resource_uri_query_param_allowed": "trace_resource_uri_query_param_allowed", + "trace.retain_thread_capabilities": "trace_retain_thread_capabilities_enabled", + "trace.sample.rate": "trace_sample_rate", + "trace.sample_rate": "trace_sample_rate", + "trace.sampling_rules": "trace_sample_rules", + "trace.sampling_rules_format": "trace_sampling_rules_format", + "trace.scope.depth.limit": "trace_scope_depth_limit", + "trace.servlet.async-timeout.error": "trace_servlet_async_timeout_error_enabled", + "trace.servlet.principal.enabled": "trace_servlet_principal_enabled", + "trace.shutdown_timeout": "trace_shutdown_timeout", + "trace.sidecar_trace_sender": "trace_sidecar_trace_sender", + "trace.sources_path": "trace_sources_path", + "trace.span.attribute.schema": "trace_span_attribute_schema", + "trace.spans_limit": "trace_spans_limit", + "trace.sqs.propagation.enabled": "trace_sqs_propagation_enabled", + "trace.startup_logs": "trace_startup_logs", + "trace.status404decorator.enabled": "trace_status_404_decorator_enabled", + "trace.status404rule.enabled": "trace_status_404_rule_enabled", + "trace.symfony_messenger_distributed_tracing": "trace_symfony_messenger_distributed_tracing", + "trace.symfony_messenger_middlewares": "trace_symfony_messenger_middlewares", + "trace.telemetry_enabled": "instrumentation_telemetry_enabled", + "trace.traced_internal_functions": "trace_traced_internal_functions", + "trace.tracer.metrics.enabled": "trace_metrics_enabled", + "trace.url_as_resource_names_enabled": "trace_url_as_resource_names_enabled", + "trace.warn_legacy_dd_trace": "trace_warn_legacy_dd_trace_enabled", + "trace.wordpress_additional_actions": "trace_wordpress_additional_actions", + "trace.wordpress_callbacks": "trace_wordpress_callbacks", + "trace.wordpress_enhanced_integration": "trace_wordpress_enhanced_integration", + "trace.x-datadog-tags.max.length": "trace_x_datadog_tags_max_length", + "trace.x_datadog_tags_max_length": "trace_x_datadog_tags_max_length", + "traceEnabled": "trace_enabled", + "traceId128BitGenerationEnabled": "trace_128_bits_id_enabled", + "traceId128BitLoggingEnabled": "trace_128_bits_id_logging_enabled", + "tracePropagationExtractFirst": "trace_propagation_extract_first", + "tracePropagationStyle,otelPropagators": "trace_propagation_style_otel_propagators", + "tracePropagationStyle.extract": "trace_propagation_style_extract", + "tracePropagationStyle.inject": "trace_propagation_style_inject", + "tracePropagationStyle.otelPropagators": "trace_propagation_style_otel_propagators", + "trace_methods": "trace_methods", + "tracer_instance_count": "trace_instance_count", + "tracing": "trace_enabled", + "tracing.auto_instrument.enabled": "trace_auto_instrument_enabled", + "tracing.distributed_tracing.propagation_extract_style": "trace_propagation_style_extract", + "tracing.distributed_tracing.propagation_inject_style": "trace_propagation_style_inject", + "tracing.enabled": "trace_enabled", + "tracing.log_injection": "logs_injection_enabled", + "tracing.opentelemetry.enabled": "trace_otel_enabled", + "tracing.partial_flush.enabled": "trace_partial_flush_enabled", + "tracing.partial_flush.min_spans_threshold": "trace_partial_flush_min_spans", + "tracing.propagation_style_extract": "trace_propagation_style_extract", + "tracing.propagation_style_inject": "trace_propagation_style_inject", + "tracing.report_hostname": "trace_report_hostname", + "tracing.sampling.rate_limit": "trace_sample_rate", + "tracing_enabled": "trace_enabled", + "universal_version": "universal_version_enabled", + "url": "trace_agent_url", + "version": "application_version", + "wcf_obfuscation_enabled": "trace_wcf_obfuscation_enabled" +} diff --git a/packages/dd-trace/test/fixtures/telemetry/config_prefix_block_list.json b/packages/dd-trace/test/fixtures/telemetry/config_prefix_block_list.json new file mode 100644 index 00000000000..fc5188f2c2b --- /dev/null +++ b/packages/dd-trace/test/fixtures/telemetry/config_prefix_block_list.json @@ -0,0 +1,243 @@ +[ + "apiKey", + "appsec.eventTracking.enabled", + "trace.integration.", + "global_tag_runtime-id", + "tracePropagationStyle.inject.", + "DD_PROFILING_API_KEY", + "dd_profiling_apikey", + "N/A", + "DD_API_KEY", + "DD_APPLICATION_KEY", + "DD_TRACE_HttpMessageHandler_", + "DD_HttpMessageHandler_", + "DD_TRACE_HttpSocketsHandler_", + "DD_HttpSocketsHandler_", + "DD_TRACE_WinHttpHandler_", + "DD_WinHttpHandler_", + "DD_TRACE_CurlHandler_", + "DD_CurlHandler_", + "DD_TRACE_AspNetCore_", + "DD_AspNetCore_", + "DD_TRACE_AdoNet_", + "DD_AdoNet_", + "DD_TRACE_AspNet_", + "DD_AspNet_", + "DD_TRACE_AspNetMvc_", + "DD_AspNetMvc_", + "DD_TRACE_AspNetWebApi2_", + "DD_AspNetWebApi2_", + "DD_TRACE_GraphQL_", + "DD_GraphQL_", + "DD_TRACE_HotChocolate_", + "DD_HotChocolate_", + "DD_TRACE_MongoDb_", + "DD_MongoDb_", + "DD_TRACE_XUnit_", + "DD_XUnit_", + "DD_TRACE_NUnit_", + "DD_NUnit_", + "DD_TRACE_MsTestV2_", + "DD_MsTestV2_", + "DD_TRACE_Wcf_", + "DD_Wcf_", + "DD_TRACE_WebRequest_", + "DD_WebRequest_", + "DD_TRACE_ElasticsearchNet_", + "DD_ElasticsearchNet_", + "DD_TRACE_ServiceStackRedis_", + "DD_ServiceStackRedis_", + "DD_TRACE_StackExchangeRedis_", + "DD_StackExchangeRedis_", + "DD_TRACE_ServiceRemoting_", + "DD_ServiceRemoting_", + "DD_TRACE_RabbitMQ_", + "DD_RabbitMQ_", + "DD_TRACE_Msmq_", + "DD_Msmq_", + "DD_TRACE_Kafka_", + "DD_Kafka_", + "DD_TRACE_CosmosDb_", + "DD_CosmosDb_", + "DD_TRACE_AwsLambda_", + "DD_AwsLambda_", + "DD_TRACE_AwsSdk_", + "DD_AwsSdk_", + "DD_TRACE_AwsSqs_", + "DD_AwsSqs_", + "DD_TRACE_AwsSns_", + "DD_AwsSns_", + "DD_TRACE_ILogger_", + "DD_ILogger_", + "DD_TRACE_Aerospike_", + "DD_Aerospike_", + "DD_TRACE_AzureFunctions_", + "DD_AzureFunctions_", + "DD_TRACE_Couchbase_", + "DD_Couchbase_", + "DD_TRACE_MySql_", + "DD_MySql_", + "DD_TRACE_Npgsql_", + "DD_Npgsql_", + "DD_TRACE_Oracle_", + "DD_Oracle_", + "DD_TRACE_SqlClient_", + "DD_SqlClient_", + "DD_TRACE_Sqlite_", + "DD_Sqlite_", + "DD_TRACE_Serilog_", + "DD_Serilog_", + "DD_TRACE_Log4Net_", + "DD_Log4Net_", + "DD_TRACE_NLog_", + "DD_NLog_", + "DD_TRACE_TraceAnnotations_", + "DD_TraceAnnotations_", + "DD_TRACE_Grpc_", + "DD_Grpc_", + "DD_TRACE_Process_", + "DD_Process_", + "DD_TRACE_HashAlgorithm_", + "DD_HashAlgorithm_", + "DD_TRACE_SymmetricAlgorithm_", + "DD_SymmetricAlgorithm_", + "DD_TRACE_OpenTelemetry_", + "DD_OpenTelemetry_", + "DD_TRACE_PathTraversal_", + "DD_PathTraversal_", + "DD_TRACE_Ssrf_", + "DD_Ssrf_", + "DD_TRACE_Ldap_", + "DD_Ldap_", + "DD_TRACE_AwsKinesis_", + "DD_AwsKinesis_", + "DD_TRACE_AzureServiceBus_", + "DD_AzureServiceBus_", + "DD_TRACE_SystemRandom_", + "DD_SystemRandom_", + "DD_TRACE_AwsDynamoDb_", + "DD_AwsDynamoDb_", + "DD_TRACE_HardcodedSecret_", + "DD_HarcodedSecret_", + "DD_TRACE_IbmMq_", + "DD_IbmMq_", + "DD_TRACE_Remoting_", + "DD_Remoting_", + "trace.amqp_enabled", + "trace.amqp_analytics_enabled", + "trace.amqp_analytics_sample_rate", + "trace.cakephp_enabled", + "trace.cakephp_analytics_enabled", + "trace.cakephp_analytics_sample_rate", + "trace.codeigniter_enabled", + "trace.codeigniter_analytics_enabled", + "trace.codeigniter_analytics_sample_rate", + "trace.curl_enabled", + "trace.curl_analytics_enabled", + "trace.curl_analytics_sample_rate", + "trace.elasticsearch_enabled", + "trace.elasticsearch_analytics_enabled", + "trace.elasticsearch_analytics_sample_rate", + "trace.eloquent_enabled", + "trace.eloquent_analytics_enabled", + "trace.eloquent_analytics_sample_rate", + "trace.frankenphp_enabled", + "trace.frankenphp_analytics_enabled", + "trace.frankenphp_analytics_sample_rate", + "trace.googlespanner_enabled", + "trace.googlespanner_analytics_enabled", + "trace.googlespanner_analytics_sample_rate", + "trace.guzzle_enabled", + "trace.guzzle_analytics_enabled", + "trace.guzzle_analytics_sample_rate", + "trace.laminas_enabled", + "trace.laminas_analytics_enabled", + "trace.laminas_analytics_sample_rate", + "trace.laravel_enabled", + "trace.laravel_analytics_enabled", + "trace.laravel_analytics_sample_rate", + "trace.laravelqueue_enabled", + "trace.laravelqueue_analytics_enabled", + "trace.laravelqueue_analytics_sample_rate", + "trace.logs_enabled", + "trace.logs_analytics_enabled", + "trace.logs_analytics_sample_rate", + "trace.lumen_enabled", + "trace.lumen_analytics_enabled", + "trace.lumen_analytics_sample_rate", + "trace.memcache_enabled", + "trace.memcache_analytics_enabled", + "trace.memcache_analytics_sample_rate", + "trace.memcached_enabled", + "trace.memcached_analytics_enabled", + "trace.memcached_analytics_sample_rate", + "trace.mongo_enabled", + "trace.mongo_analytics_enabled", + "trace.mongo_analytics_sample_rate", + "trace.mongodb_enabled", + "trace.mongodb_analytics_enabled", + "trace.mongodb_analytics_sample_rate", + "trace.mysqli_enabled", + "trace.mysqli_analytics_enabled", + "trace.mysqli_analytics_sample_rate", + "trace.nette_enabled", + "trace.nette_analytics_enabled", + "trace.nette_analytics_sample_rate", + "trace.openai_enabled", + "trace.openai_analytics_enabled", + "trace.openai_analytics_sample_rate", + "trace.pcntl_enabled", + "trace.pcntl_analytics_enabled", + "trace.pcntl_analytics_sample_rate", + "trace.pdo_enabled", + "trace.pdo_analytics_enabled", + "trace.pdo_analytics_sample_rate", + "trace.phpredis_enabled", + "trace.phpredis_analytics_enabled", + "trace.phpredis_analytics_sample_rate", + "trace.predis_enabled", + "trace.predis_analytics_enabled", + "trace.predis_analytics_sample_rate", + "trace.psr18_enabled", + "trace.psr18_analytics_enabled", + "trace.psr18_analytics_sample_rate", + "trace.roadrunner_enabled", + "trace.roadrunner_analytics_enabled", + "trace.roadrunner_analytics_sample_rate", + "trace.sqlsrv_enabled", + "trace.sqlsrv_analytics_enabled", + "trace.sqlsrv_analytics_sample_rate", + "trace.slim_enabled", + "trace.slim_analytics_enabled", + "trace.slim_analytics_sample_rate", + "trace.swoole_enabled", + "trace.swoole_analytics_enabled", + "trace.swoole_analytics_sample_rate", + "trace.symfonymessenger_enabled", + "trace.symfonymessenger_analytics_enabled", + "trace.symfonymessenger_analytics_sample_rate", + "trace.symfony_enabled", + "trace.symfony_analytics_enabled", + "trace.symfony_analytics_sample_rate", + "trace.web_enabled", + "trace.web_analytics_enabled", + "trace.web_analytics_sample_rate", + "trace.wordpress_enabled", + "trace.wordpress_analytics_enabled", + "trace.wordpress_analytics_sample_rate", + "trace.yii_enabled", + "trace.yii_analytics_enabled", + "trace.yii_analytics_sample_rate", + "trace.zendframework_enabled", + "trace.zendframework_analytics_enabled", + "trace.zendframework_analytics_sample_rate", + "trace.drupal_enabled", + "trace.drupal_analytics_enabled", + "trace.drupal_analytics_sample_rate", + "trace.magento_enabled", + "trace.magento_analytics_enabled", + "trace.magento_analytics_sample_rate", + "trace.exec_enabled", + "trace.exec_analytics_enabled", + "trace.exec_analytics_sample_rate" +] diff --git a/packages/dd-trace/test/fixtures/telemetry/nodejs_config_rules.json b/packages/dd-trace/test/fixtures/telemetry/nodejs_config_rules.json new file mode 100644 index 00000000000..b96a6ab5d15 --- /dev/null +++ b/packages/dd-trace/test/fixtures/telemetry/nodejs_config_rules.json @@ -0,0 +1,175 @@ +{ + "normalization_rules" : + { + "HOSTNAME" : "agent_hostname", + "hostname" : "agent_hostname", + "appsec.blockedTemplateHtml" : "appsec_blocked_template_html", + "DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML" : "appsec_blocked_template_html", + "appsec.blockedTemplateJson" : "appsec_blocked_template_json", + "DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON" : "appsec_blocked_template_json", + "security_enabled" : "appsec_enabled", + "appsec.enabled" : "appsec_enabled", + "DD_APPSEC_ENABLED" : "appsec_enabled", + "appsec.obfuscatorKeyRegex" : "appsec_obfuscation_parameter_key_regexp", + "DD_APPSEC_OBFUSCATION_PARAMETER_KEY_REGEXP" : "appsec_obfuscation_parameter_key_regexp", + "appsec.obfuscatorValueRegex" : "appsec_obfuscation_parameter_value_regexp", + "DD_APPSEC_OBFUSCATION_PARAMETER_VALUE_REGEXP" : "appsec_obfuscation_parameter_value_regexp", + "appsec.rateLimit" : "appsec_rate_limit", + "appsec.rules" : "appsec_rules", + "DD_APPSEC_RULES" : "appsec_rules", + "appsec.customRulesProvided" : "appsec_rules_custom_provided", + "appsec.rules.metadata.rules_version" : "appsec_rules_metadata_rules_version", + "appsec.rules.version" : "appsec_rules_version", + "appsec.wafTimeout" : "appsec_waf_timeout", + "appsec.waf.timeout" : "appsec_waf_timeout", + "DD_APPSEC_WAF_TIMEOUT" : "appsec_waf_timeout", + "civisibility.enabled" : "ci_visibility_enabled", + "isCiVisibility" : "ci_visibility_enabled", + "DD_CIVISIBILITY_ENABLED" : "ci_visibility_enabled", + "clientIpHeaderDisabled" : "client_ip_header_disabled", + "dbmPropagationMode" : "dbm_propagation_mode", + "dbm_propagation_mode" : "dbm_propagation_mode", + "DD_DBM_PROPAGATION_MODE" : "dbm_propagation_mode", + "dogstatsd.hostname" : "dogstatsd_hostname", + "dogstatsd.port" : "dogstatsd_port", + "DD_DOGSTATSD_PORT" : "dogstatsd_port", + "env" : "env", + "DD_ENV" : "env", + "experimental.b3" : "experimental_b3", + "experimental.enableGetRumData" : "experimental_enable_get_rum_data", + "experimental.exporter" : "experimental_exporter", + "experimental.runtimeId" : "experimental_runtime_id", + "experimental.sampler.rateLimit" : "experimental_sampler_rate_limit", + "experimental.sampler.sampleRate" : "experimental_sampler_sample_rate", + "experimental.traceparent" : "experimental_traceparent", + "flushInterval" : "flush_interval", + "flushMinSpans" : "flush_min_spans", + "isGitUploadEnabled" : "git_upload_enabled", + "iast.deduplication.enabled" : "iast_deduplication_enabled", + "iast.deduplicationEnabled" : "iast_deduplication_enabled", + "DD_IAST_DEDUPLICATION_ENABLED" : "iast_deduplication_enabled", + "iast.enabled" : "iast_enabled", + "DD_IAST_ENABLED" : "iast_enabled", + "iast.maxConcurrentRequests" : "iast_max_concurrent_requests", + "iast.max-concurrent-requests" : "iast_max_concurrent_requests", + "DD_IAST_MAX_CONCURRENT_REQUESTS" : "iast_max_concurrent_requests", + "iast.maxContextOperations" : "iast_max_context_operations", + "iast.requestSampling" : "iast_request_sampling", + "iast.request-sampling" : "iast_request_sampling", + "telemetry.debug" : "instrumentation_telemetry_debug_enabled", + "DD_INTERNAL_TELEMETRY_DEBUG_ENABLED" : "instrumentation_telemetry_debug_enabled", + "instrumentation.telemetry.enabled" : "instrumentation_telemetry_enabled", + "telemetryEnabled" : "instrumentation_telemetry_enabled", + "telemetry.enabled" : "instrumentation_telemetry_enabled", + "DD_INSTRUMENTATION_TELEMETRY_ENABLED" : "instrumentation_telemetry_enabled", + "trace.telemetry_enabled" : "instrumentation_telemetry_enabled", + "telemetry.logCollection" : "instrumentation_telemetry_log_collection_enabled", + "telemetry.metrics" : "instrumentation_telemetry_metrics_enabled", + "DD_TELEMETRY_METRICS_ENABLED" : "instrumentation_telemetry_metrics_enabled", + "isIntelligentTestRunnerEnabled" : "intelligent_test_runner_enabled", + "logger" : "logger", + "logInjection_enabled" : "logs_injection_enabled", + "logs.injection" : "logs_injection_enabled", + "logInjection" : "logs_injection_enabled", + "DD_LOGS_INJECTION" : "logs_injection_enabled", + "lookup" : "lookup", + "plugins" : "plugins", + "profiling.enabled" : "profiling_enabled", + "DD_PROFILING_ENABLED" : "profiling_enabled", + "profiling.exporters" : "profiling_exporters", + "profiling.sourceMap" : "profiling_source_map_enabled", + "remote_config.enabled" : "remote_config_enabled", + "remoteConfig.enabled" : "remote_config_enabled", + "remoteConfig.pollInterval" : "remote_config_poll_interval", + "DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS" : "remote_config_poll_interval", + "DD_INTERNAL_RCM_POLL_INTERVAL" : "remote_config_poll_interval", + "runtimemetrics_enabled" : "runtime_metrics_enabled", + "runtime.metrics.enabled" : "runtime_metrics_enabled", + "runtimeMetrics" : "runtime_metrics_enabled", + "DD_RUNTIME_METRICS_ENABLED" : "runtime_metrics_enabled", + "scope" : "scope", + "service" : "service", + "DD_SERVICE" : "service", + "DD_SERVICE_NAME" : "service", + "site" : "site", + "DD_SITE" : "site", + "stats.enabled" : "stats_enabled", + "traceId128BitGenerationEnabled" : "trace_128_bits_id_enabled", + "trace.128_bit_traceid_generation_enabled" : "trace_128_bits_id_enabled", + "DD_TRACE_128_BIT_TRACEID_GENERATION_ENABLED" : "trace_128_bits_id_enabled", + "traceId128BitLoggingEnabled" : "trace_128_bits_id_logging_enabled", + "DD_TRACE_128_BIT_TRACEID_LOGGING_ENABLED" : "trace_128_bits_id_logging_enabled", + "trace.128_bit_traceid_logging_enabled" : "trace_128_bits_id_logging_enabled", + "trace.agent.port" : "trace_agent_port", + "port" : "trace_agent_port", + "DD_TRACE_AGENT_PORT" : "trace_agent_port", + "DATADOG_TRACE_AGENT_PORT" : "trace_agent_port", + "DD_APM_RECEIVER_PORT" : "trace_agent_port", + "trace.agent_port" : "trace_agent_port", + "protocolVersion" : "trace_agent_protocol_version", + "agent_url" : "trace_agent_url", + "url" : "trace_agent_url", + "DD_TRACE_AGENT_URL" : "trace_agent_url", + "trace.agent_url" : "trace_agent_url", + "trace.client-ip.enabled" : "trace_client_ip_enabled", + "clientIpEnabled" : "trace_client_ip_enabled", + "DD_TRACE_CLIENT_IP_ENABLED" : "trace_client_ip_enabled", + "trace.client_ip_enabled" : "trace_client_ip_enabled", + "clientIpHeader" : "trace_client_ip_header", + "DD_TRACE_CLIENT_IP_HEADER" : "trace_client_ip_header", + "debug" : "trace_debug_enabled", + "dd.trace.debug" : "trace_debug_enabled", + "DD_TRACE_DEBUG" : "trace_debug_enabled", + "trace.debug" : "trace_debug_enabled", + "enabled" : "trace_enabled", + "trace.enabled" : "trace_enabled", + "tracing" : "trace_enabled", + "DD_TRACE_ENABLED" : "trace_enabled", + "tagsHeaderMaxLength" : "trace_header_tags_max_length", + "logLevel" : "trace_log_level", + "querystringObfuscation" : "trace_obfuscation_query_string_regexp", + "queryStringObfuscation" : "trace_obfuscation_query_string_regexp", + "DD_TRACE_OBFUSCATION_QUERY_STRING_REGEXP" : "trace_obfuscation_query_string_regexp", + "trace.obfuscation_query_string_regexp" : "trace_obfuscation_query_string_regexp", + "DD_TRACE_PEER_SERVICE_DEFAULTS_ENABLED" : "trace_peer_service_defaults_enabled", + "trace.peer_service_defaults_enabled" : "trace_peer_service_defaults_enabled", + "spanComputePeerService" : "trace_peer_service_defaults_enabled", + "trace.peer.service.defaults.enabled" : "trace_peer_service_defaults_enabled", + "DD_TRACE_PEER_SERVICE_MAPPING" : "trace_peer_service_mapping", + "peerServiceMapping" : "trace_peer_service_mapping", + "trace.peer.service.mapping" : "trace_peer_service_mapping", + "trace.peer_service_mapping" : "trace_peer_service_mapping", + "sampler.rateLimit" : "trace_rate_limit", + "trace.rate.limit" : "trace_rate_limit", + "DD_TRACE_RATE_LIMIT" : "trace_rate_limit", + "DD_MAX_TRACES_PER_SECOND" : "trace_rate_limit", + "trace.rate_limit" : "trace_rate_limit", + "DD_TRACE_REMOVE_INTEGRATION_SERVICE_NAMES_ENABLED" : "trace_remove_integration_service_names_enabled", + "trace.remove_integration_service_names_enabled" : "trace_remove_integration_service_names_enabled", + "spanRemoveIntegrationFromService" : "trace_remove_integration_service_names_enabled", + "trace.remove.integration-service-names.enabled" : "trace_remove_integration_service_names_enabled", + "reportHostname" : "trace_report_hostname", + "trace.report-hostname" : "trace_report_hostname", + "trace.report_hostname" : "trace_report_hostname", + "sample_rate" : "trace_sample_rate", + "trace.sample.rate" : "trace_sample_rate", + "dd_trace_sample_rate" : "trace_sample_rate", + "sampler.sampleRate" : "trace_sample_rate", + "sampleRate" : "trace_sample_rate", + "DD_TRACE_SAMPLE_RATE" : "trace_sample_rate", + "trace.sample_rate" : "trace_sample_rate", + "spanattributeschema" : "trace_span_attribute_schema", + "DD_TRACE_SPAN_ATTRIBUTE_SCHEMA" : "trace_span_attribute_schema", + "spanAttributeSchema" : "trace_span_attribute_schema", + "trace.span.attribute.schema" : "trace_span_attribute_schema", + "startupLogs" : "trace_startup_logs_enabled", + "DD_TRACE_STARTUP_LOGS" : "trace_startup_logs_enabled", + "global_tag_version" : "version" + }, + "prefix_block_list" : [ + ], + "redaction_list" :[ + ], + "reduce_rules" : { + } +} diff --git a/packages/dd-trace/test/llmobs/writers/base.spec.js b/packages/dd-trace/test/llmobs/writers/base.spec.js index 8b971b2748a..a2880251f4c 100644 --- a/packages/dd-trace/test/llmobs/writers/base.spec.js +++ b/packages/dd-trace/test/llmobs/writers/base.spec.js @@ -138,14 +138,16 @@ describe('BaseLLMObsWriter', () => { writer.append({ foo: 'bar' }) const error = new Error('boom') + let reqUrl request.callsFake((url, options, callback) => { + reqUrl = options.url callback(error) }) writer.flush() expect(logger.error).to.have.been.calledWith( - 'Error sending 1 LLMObs undefined events to https://llmobs-intake.datadoghq.com/api/v2/llmobs: boom' + 'Error sending %d LLMObs %s events to %s: %s', 1, undefined, reqUrl, 'boom', error ) }) diff --git a/packages/dd-trace/test/log.spec.js b/packages/dd-trace/test/log.spec.js index a035c864f71..16682f97db8 100644 --- a/packages/dd-trace/test/log.spec.js +++ b/packages/dd-trace/test/log.spec.js @@ -139,6 +139,27 @@ describe('log', () => { }) }) + describe('trace', () => { + it('should not log to console by default', () => { + log.trace('trace') + + expect(console.debug).to.not.have.been.called + }) + + it('should log to console after setting log level to trace', function foo () { + log.toggle(true, 'trace') + log.trace('argument', { hello: 'world' }, { + toString: () => 'string' + }, { foo: 'bar' }) + + expect(console.debug).to.have.been.calledOnce + expect(console.debug.firstCall.args[0]).to.match( + /^Trace: Test.foo\('argument', { hello: 'world' }, string, { foo: 'bar' }\)/ + ) + expect(console.debug.firstCall.args[0].split('\n').length).to.be.gte(3) + }) + }) + describe('error', () => { it('should log to console by default', () => { log.error(error) diff --git a/packages/dd-trace/test/msgpack/encoder.spec.js b/packages/dd-trace/test/msgpack/encoder.spec.js new file mode 100644 index 00000000000..cfda0a9e7d7 --- /dev/null +++ b/packages/dd-trace/test/msgpack/encoder.spec.js @@ -0,0 +1,88 @@ +'use strict' + +require('../setup/tap') + +const { expect } = require('chai') +const msgpack = require('msgpack-lite') +const codec = msgpack.createCodec({ int64: true }) +const { MsgpackEncoder } = require('../../src/msgpack/encoder') + +function randString (length) { + return Array.from({ length }, () => { + return String.fromCharCode(Math.floor(Math.random() * 256)) + }).join('') +} + +describe('msgpack/encoder', () => { + let encoder + + beforeEach(() => { + encoder = new MsgpackEncoder() + }) + + it('should encode to msgpack', () => { + const data = [ + { first: 'test' }, + { + fixstr: 'foo', + str: randString(1000), + fixuint: 127, + fixint: -31, + uint8: 255, + uint16: 65535, + uint32: 4294967295, + uint53: 9007199254740991, + int8: -15, + int16: -32767, + int32: -2147483647, + int53: -9007199254740991, + float: 12345.6789, + biguint: BigInt('9223372036854775807'), + bigint: BigInt('-9223372036854775807'), + buffer: Buffer.from('test'), + uint8array: new Uint8Array([1, 2, 3, 4]), + uint32array: new Uint32Array([1, 2]) + } + ] + + const buffer = encoder.encode(data) + const decoded = msgpack.decode(buffer, { codec }) + + expect(decoded).to.be.an('array') + expect(decoded[0]).to.be.an('object') + expect(decoded[0]).to.have.property('first', 'test') + expect(decoded[1]).to.be.an('object') + expect(decoded[1]).to.have.property('fixstr', 'foo') + expect(decoded[1]).to.have.property('str') + expect(decoded[1].str).to.have.length(1000) + expect(decoded[1]).to.have.property('fixuint', 127) + expect(decoded[1]).to.have.property('fixint', -31) + expect(decoded[1]).to.have.property('uint8', 255) + expect(decoded[1]).to.have.property('uint16', 65535) + expect(decoded[1]).to.have.property('uint32', 4294967295) + expect(decoded[1]).to.have.property('uint53') + expect(decoded[1].uint53.toString()).to.equal('9007199254740991') + expect(decoded[1]).to.have.property('int8', -15) + expect(decoded[1]).to.have.property('int16', -32767) + expect(decoded[1]).to.have.property('int32', -2147483647) + expect(decoded[1]).to.have.property('int53') + expect(decoded[1].int53.toString()).to.equal('-9007199254740991') + expect(decoded[1]).to.have.property('float', 12345.6789) + expect(decoded[1]).to.have.property('biguint') + expect(decoded[1].biguint.toString()).to.equal('9223372036854775807') + expect(decoded[1]).to.have.property('bigint') + expect(decoded[1].bigint.toString()).to.equal('-9223372036854775807') + expect(decoded[1]).to.have.property('buffer') + expect(decoded[1].buffer.toString('utf8')).to.equal('test') + expect(decoded[1]).to.have.property('buffer') + expect(decoded[1].buffer.toString('utf8')).to.equal('test') + expect(decoded[1]).to.have.property('uint8array') + expect(decoded[1].uint8array[0]).to.equal(1) + expect(decoded[1].uint8array[1]).to.equal(2) + expect(decoded[1].uint8array[2]).to.equal(3) + expect(decoded[1].uint8array[3]).to.equal(4) + expect(decoded[1]).to.have.property('uint32array') + expect(decoded[1].uint32array[0]).to.equal(1) + expect(decoded[1].uint32array[4]).to.equal(2) + }) +}) diff --git a/packages/dd-trace/test/opentracing/propagation/text_map.spec.js b/packages/dd-trace/test/opentracing/propagation/text_map.spec.js index c6247330a69..3e4f6aed3e8 100644 --- a/packages/dd-trace/test/opentracing/propagation/text_map.spec.js +++ b/packages/dd-trace/test/opentracing/propagation/text_map.spec.js @@ -108,7 +108,7 @@ describe('TextMapPropagator', () => { const spanContext = createContext({ baggageItems }) propagator.inject(spanContext, carrier) - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len expect(carrier.baggage).to.be.equal('%22%2C%3B%5C%28%29%2F%3A%3C%3D%3E%3F%40%5B%5D%7B%7D%F0%9F%90%B6%C3%A9%E6%88%91=%22%2C%3B%5C%F0%9F%90%B6%C3%A9%E6%88%91') }) diff --git a/packages/dd-trace/test/plugins/externals.json b/packages/dd-trace/test/plugins/externals.json index 6975170e870..0f581b58bf0 100644 --- a/packages/dd-trace/test/plugins/externals.json +++ b/packages/dd-trace/test/plugins/externals.json @@ -30,6 +30,10 @@ "name": "@aws-sdk/client-s3", "versions": [">=3"] }, + { + "name": "@aws-sdk/client-dynamodb", + "versions": [">=3"] + }, { "name": "@aws-sdk/client-sfn", "versions": [">=3"] @@ -102,7 +106,7 @@ }, { "name": "express", - "versions": [">=4", ">=4.0.0 <4.3.0", ">=4.3.0"] + "versions": [">=4", ">=4.0.0 <4.3.0", ">=4.0.0 <5.0.0", ">=4.3.0 <5.0.0"] }, { "name": "body-parser", @@ -336,7 +340,7 @@ }, { "name": "express", - "versions": [">=4", ">=4.0.0 <4.3.0", ">=4.3.0"] + "versions": [">=4", ">=4.0.0 <4.3.0", ">=4.0.0 <5.0.0", ">=4.3.0 <5.0.0"] }, { "name": "body-parser", @@ -420,6 +424,10 @@ { "name": "express", "versions": [">=4"] + }, + { + "name": "sqlite3", + "versions": ["^5.0.8"] } ] } diff --git a/packages/dd-trace/test/profiling/exporters/file.spec.js b/packages/dd-trace/test/profiling/exporters/file.spec.js index bca561dce8b..36b0d257ece 100644 --- a/packages/dd-trace/test/profiling/exporters/file.spec.js +++ b/packages/dd-trace/test/profiling/exporters/file.spec.js @@ -25,9 +25,13 @@ describe('exporters/file', () => { const profiles = { test: buffer } - await exporter.export({ profiles, end: new Date('2023-02-10T21:03:05Z') }) + await exporter.export({ + profiles, + start: new Date('2023-02-10T21:02:05Z'), + end: new Date('2023-02-10T21:03:05Z') + }) - sinon.assert.calledOnce(fs.writeFile) + sinon.assert.calledTwice(fs.writeFile) sinon.assert.calledWith(fs.writeFile, 'test_worker_0_20230210T210305Z.pprof', buffer) }) @@ -37,9 +41,13 @@ describe('exporters/file', () => { const profiles = { test: buffer } - await exporter.export({ profiles, end: new Date('2023-02-10T21:03:05Z') }) + await exporter.export({ + profiles, + start: new Date('2023-02-10T21:02:05Z'), + end: new Date('2023-02-10T21:03:05Z') + }) - sinon.assert.calledOnce(fs.writeFile) + sinon.assert.calledTwice(fs.writeFile) sinon.assert.calledWith(fs.writeFile, 'myprefix_test_worker_0_20230210T210305Z.pprof', buffer) }) }) diff --git a/packages/dd-trace/test/proxy.spec.js b/packages/dd-trace/test/proxy.spec.js index 4836e99787f..dd145390245 100644 --- a/packages/dd-trace/test/proxy.spec.js +++ b/packages/dd-trace/test/proxy.spec.js @@ -520,8 +520,9 @@ describe('TracerProxy', () => { const profilerImportFailureProxy = new ProfilerImportFailureProxy() profilerImportFailureProxy.init() + sinon.assert.calledOnce(log.error) const expectedErr = sinon.match.instanceOf(Error).and(sinon.match.has('code', 'MODULE_NOT_FOUND')) - sinon.assert.calledWith(log.error, sinon.match(expectedErr)) + sinon.assert.match(log.error.firstCall.lastArg, sinon.match(expectedErr)) }) it('should start telemetry', () => { diff --git a/packages/dd-trace/test/runtime_metrics.spec.js b/packages/dd-trace/test/runtime_metrics.spec.js index f3f20464630..20ce93112ae 100644 --- a/packages/dd-trace/test/runtime_metrics.spec.js +++ b/packages/dd-trace/test/runtime_metrics.spec.js @@ -13,6 +13,7 @@ suiteDescribe('runtimeMetrics', () => { let runtimeMetrics let config let clock + let setImmediate let client let Client @@ -50,6 +51,7 @@ suiteDescribe('runtimeMetrics', () => { } } + setImmediate = globalThis.setImmediate clock = sinon.useFakeTimers() runtimeMetrics.start(config) @@ -91,71 +93,79 @@ suiteDescribe('runtimeMetrics', () => { }) }) - it('should start collecting runtimeMetrics every 10 seconds', () => { + it('should start collecting runtimeMetrics every 10 seconds', (done) => { runtimeMetrics.stop() runtimeMetrics.start(config) global.gc() - clock.tick(10000) - - expect(client.gauge).to.have.been.calledWith('runtime.node.cpu.user') - expect(client.gauge).to.have.been.calledWith('runtime.node.cpu.system') - expect(client.gauge).to.have.been.calledWith('runtime.node.cpu.total') - - expect(client.gauge).to.have.been.calledWith('runtime.node.mem.rss') - expect(client.gauge).to.have.been.calledWith('runtime.node.mem.heap_total') - expect(client.gauge).to.have.been.calledWith('runtime.node.mem.heap_used') - - expect(client.gauge).to.have.been.calledWith('runtime.node.process.uptime') - - expect(client.gauge).to.have.been.calledWith('runtime.node.heap.total_heap_size') - expect(client.gauge).to.have.been.calledWith('runtime.node.heap.total_heap_size_executable') - expect(client.gauge).to.have.been.calledWith('runtime.node.heap.total_physical_size') - expect(client.gauge).to.have.been.calledWith('runtime.node.heap.total_available_size') - expect(client.gauge).to.have.been.calledWith('runtime.node.heap.total_heap_size') - expect(client.gauge).to.have.been.calledWith('runtime.node.heap.heap_size_limit') - - expect(client.gauge).to.have.been.calledWith('runtime.node.heap.malloced_memory') - expect(client.gauge).to.have.been.calledWith('runtime.node.heap.peak_malloced_memory') - - expect(client.gauge).to.have.been.calledWith('runtime.node.event_loop.delay.max') - expect(client.gauge).to.have.been.calledWith('runtime.node.event_loop.delay.min') - expect(client.increment).to.have.been.calledWith('runtime.node.event_loop.delay.sum') - expect(client.gauge).to.have.been.calledWith('runtime.node.event_loop.delay.avg') - expect(client.gauge).to.have.been.calledWith('runtime.node.event_loop.delay.median') - expect(client.gauge).to.have.been.calledWith('runtime.node.event_loop.delay.95percentile') - expect(client.increment).to.have.been.calledWith('runtime.node.event_loop.delay.count') - - expect(client.gauge).to.have.been.calledWith('runtime.node.event_loop.utilization') - - expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.max') - expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.min') - expect(client.increment).to.have.been.calledWith('runtime.node.gc.pause.sum') - expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.avg') - expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.median') - expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.95percentile') - expect(client.increment).to.have.been.calledWith('runtime.node.gc.pause.count') - - expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.by.type.max') - expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.by.type.min') - expect(client.increment).to.have.been.calledWith('runtime.node.gc.pause.by.type.sum') - expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.by.type.avg') - expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.by.type.median') - expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.by.type.95percentile') - expect(client.increment).to.have.been.calledWith('runtime.node.gc.pause.by.type.count') - expect(client.increment).to.have.been.calledWith( - 'runtime.node.gc.pause.by.type.count', sinon.match.any, sinon.match(val => { - return val && /^gc_type:[a-z_]+$/.test(val[0]) - }) - ) - - expect(client.gauge).to.have.been.calledWith('runtime.node.heap.size.by.space') - expect(client.gauge).to.have.been.calledWith('runtime.node.heap.used_size.by.space') - expect(client.gauge).to.have.been.calledWith('runtime.node.heap.available_size.by.space') - expect(client.gauge).to.have.been.calledWith('runtime.node.heap.physical_size.by.space') + setImmediate(() => setImmediate(() => { // Wait for GC observer to trigger. + clock.tick(10000) - expect(client.flush).to.have.been.called + try { + expect(client.gauge).to.have.been.calledWith('runtime.node.cpu.user') + expect(client.gauge).to.have.been.calledWith('runtime.node.cpu.system') + expect(client.gauge).to.have.been.calledWith('runtime.node.cpu.total') + + expect(client.gauge).to.have.been.calledWith('runtime.node.mem.rss') + expect(client.gauge).to.have.been.calledWith('runtime.node.mem.heap_total') + expect(client.gauge).to.have.been.calledWith('runtime.node.mem.heap_used') + + expect(client.gauge).to.have.been.calledWith('runtime.node.process.uptime') + + expect(client.gauge).to.have.been.calledWith('runtime.node.heap.total_heap_size') + expect(client.gauge).to.have.been.calledWith('runtime.node.heap.total_heap_size_executable') + expect(client.gauge).to.have.been.calledWith('runtime.node.heap.total_physical_size') + expect(client.gauge).to.have.been.calledWith('runtime.node.heap.total_available_size') + expect(client.gauge).to.have.been.calledWith('runtime.node.heap.total_heap_size') + expect(client.gauge).to.have.been.calledWith('runtime.node.heap.heap_size_limit') + + expect(client.gauge).to.have.been.calledWith('runtime.node.heap.malloced_memory') + expect(client.gauge).to.have.been.calledWith('runtime.node.heap.peak_malloced_memory') + + expect(client.gauge).to.have.been.calledWith('runtime.node.event_loop.delay.max') + expect(client.gauge).to.have.been.calledWith('runtime.node.event_loop.delay.min') + expect(client.increment).to.have.been.calledWith('runtime.node.event_loop.delay.sum') + expect(client.gauge).to.have.been.calledWith('runtime.node.event_loop.delay.avg') + expect(client.gauge).to.have.been.calledWith('runtime.node.event_loop.delay.median') + expect(client.gauge).to.have.been.calledWith('runtime.node.event_loop.delay.95percentile') + expect(client.increment).to.have.been.calledWith('runtime.node.event_loop.delay.count') + + expect(client.gauge).to.have.been.calledWith('runtime.node.event_loop.utilization') + + expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.max') + expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.min') + expect(client.increment).to.have.been.calledWith('runtime.node.gc.pause.sum') + expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.avg') + expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.median') + expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.95percentile') + expect(client.increment).to.have.been.calledWith('runtime.node.gc.pause.count') + + expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.by.type.max') + expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.by.type.min') + expect(client.increment).to.have.been.calledWith('runtime.node.gc.pause.by.type.sum') + expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.by.type.avg') + expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.by.type.median') + expect(client.gauge).to.have.been.calledWith('runtime.node.gc.pause.by.type.95percentile') + expect(client.increment).to.have.been.calledWith('runtime.node.gc.pause.by.type.count') + expect(client.increment).to.have.been.calledWith( + 'runtime.node.gc.pause.by.type.count', sinon.match.any, sinon.match(val => { + return val && /^gc_type:[a-z_]+$/.test(val[0]) + }) + ) + + expect(client.gauge).to.have.been.calledWith('runtime.node.heap.size.by.space') + expect(client.gauge).to.have.been.calledWith('runtime.node.heap.used_size.by.space') + expect(client.gauge).to.have.been.calledWith('runtime.node.heap.available_size.by.space') + expect(client.gauge).to.have.been.calledWith('runtime.node.heap.physical_size.by.space') + + expect(client.flush).to.have.been.called + + done() + } catch (e) { + done(e) + } + })) }) }) diff --git a/packages/dd-trace/test/sampling_rule.spec.js b/packages/dd-trace/test/sampling_rule.spec.js index 49ce1153d2e..609afe385ec 100644 --- a/packages/dd-trace/test/sampling_rule.spec.js +++ b/packages/dd-trace/test/sampling_rule.spec.js @@ -120,6 +120,30 @@ describe('sampling rule', () => { expect(rule.match(spans[10])).to.equal(false) }) + it('should match with case-insensitive strings', () => { + const lowerCaseRule = new SamplingRule({ + service: 'test', + name: 'operation' + }) + + const mixedCaseRule = new SamplingRule({ + service: 'teSt', + name: 'oPeration' + }) + + expect(lowerCaseRule.match(spans[0])).to.equal(mixedCaseRule.match(spans[0])) + expect(lowerCaseRule.match(spans[1])).to.equal(mixedCaseRule.match(spans[1])) + expect(lowerCaseRule.match(spans[2])).to.equal(mixedCaseRule.match(spans[2])) + expect(lowerCaseRule.match(spans[3])).to.equal(mixedCaseRule.match(spans[3])) + expect(lowerCaseRule.match(spans[4])).to.equal(mixedCaseRule.match(spans[4])) + expect(lowerCaseRule.match(spans[5])).to.equal(mixedCaseRule.match(spans[5])) + expect(lowerCaseRule.match(spans[6])).to.equal(mixedCaseRule.match(spans[6])) + expect(lowerCaseRule.match(spans[7])).to.equal(mixedCaseRule.match(spans[7])) + expect(lowerCaseRule.match(spans[8])).to.equal(mixedCaseRule.match(spans[8])) + expect(lowerCaseRule.match(spans[9])).to.equal(mixedCaseRule.match(spans[9])) + expect(lowerCaseRule.match(spans[10])).to.equal(mixedCaseRule.match(spans[10])) + }) + it('should match with regexp', () => { rule = new SamplingRule({ service: /test/, diff --git a/packages/dd-trace/test/setup/helpers/load-inst.js b/packages/dd-trace/test/setup/helpers/load-inst.js new file mode 100644 index 00000000000..91abd8baa77 --- /dev/null +++ b/packages/dd-trace/test/setup/helpers/load-inst.js @@ -0,0 +1,62 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const proxyquire = require('proxyquire') + +function loadInstFile (file, instrumentations) { + const instrument = { + addHook (instrumentation) { + instrumentations.push(instrumentation) + } + } + + const instPath = path.join(__dirname, `../../../../datadog-instrumentations/src/${file}`) + + proxyquire.noPreserveCache()(instPath, { + './helpers/instrument': instrument, + '../helpers/instrument': instrument + }) +} + +function loadOneInst (name) { + const instrumentations = [] + + try { + loadInstFile(`${name}/server.js`, instrumentations) + loadInstFile(`${name}/client.js`, instrumentations) + } catch (e) { + try { + loadInstFile(`${name}/main.js`, instrumentations) + } catch (e) { + loadInstFile(`${name}.js`, instrumentations) + } + } + + return instrumentations +} + +function getAllInstrumentations () { + const names = fs.readdirSync(path.join(__dirname, '../../../../', 'datadog-instrumentations', 'src')) + .filter(file => file.endsWith('.js')) + .map(file => file.slice(0, -3)) + + const instrumentations = names.reduce((acc, key) => { + const name = key + let instrumentations = loadOneInst(name) + + instrumentations = instrumentations.filter(i => i.versions) + if (instrumentations.length) { + acc[key] = instrumentations + } + + return acc + }, {}) + + return instrumentations +} + +module.exports = { + getInstrumentation: loadOneInst, + getAllInstrumentations +} diff --git a/packages/dd-trace/test/setup/mocha.js b/packages/dd-trace/test/setup/mocha.js index d3520c3fe1c..53a2c95897a 100644 --- a/packages/dd-trace/test/setup/mocha.js +++ b/packages/dd-trace/test/setup/mocha.js @@ -11,6 +11,7 @@ const agent = require('../plugins/agent') const Nomenclature = require('../../src/service-naming') const { storage } = require('../../../datadog-core') const { schemaDefinitions } = require('../../src/service-naming/schemas') +const { getInstrumentation } = require('./helpers/load-inst') global.withVersions = withVersions global.withExports = withExports @@ -19,38 +20,6 @@ global.withPeerService = withPeerService const testedPlugins = agent.testedPlugins -function loadInst (plugin) { - const instrumentations = [] - - try { - loadInstFile(`${plugin}/server.js`, instrumentations) - loadInstFile(`${plugin}/client.js`, instrumentations) - } catch (e) { - try { - loadInstFile(`${plugin}/main.js`, instrumentations) - } catch (e) { - loadInstFile(`${plugin}.js`, instrumentations) - } - } - - return instrumentations -} - -function loadInstFile (file, instrumentations) { - const instrument = { - addHook (instrumentation) { - instrumentations.push(instrumentation) - } - } - - const instPath = path.join(__dirname, `../../../datadog-instrumentations/src/${file}`) - - proxyquire.noPreserveCache()(instPath, { - './helpers/instrument': instrument, - '../helpers/instrument': instrument - }) -} - function withNamingSchema ( spanProducerFn, expected, @@ -174,7 +143,7 @@ function withPeerService (tracer, pluginName, spanGenerationFn, service, service } function withVersions (plugin, modules, range, cb) { - const instrumentations = typeof plugin === 'string' ? loadInst(plugin) : [].concat(plugin) + const instrumentations = typeof plugin === 'string' ? getInstrumentation(plugin) : [].concat(plugin) const names = instrumentations.map(instrumentation => instrumentation.name) modules = [].concat(modules) diff --git a/packages/dd-trace/test/telemetry/index.spec.js b/packages/dd-trace/test/telemetry/index.spec.js index 306d7a16c30..0263f395e9f 100644 --- a/packages/dd-trace/test/telemetry/index.spec.js +++ b/packages/dd-trace/test/telemetry/index.spec.js @@ -409,7 +409,7 @@ describe('Telemetry extended heartbeat', () => { { name: 'DD_TRACE_SAMPLING_RULES', value: - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/js/max-len '[{"service":"*","sampling_rate":1},{"service":"svc*","resource":"*abc","name":"op-??","tags":{"tag-a":"ta-v*","tag-b":"tb-v?","tag-c":"tc-v"},"sample_rate":0.5}]', origin: 'code' } diff --git a/packages/dd-trace/test/telemetry/logs/index.spec.js b/packages/dd-trace/test/telemetry/logs/index.spec.js index 0d18b6e847b..e865644e960 100644 --- a/packages/dd-trace/test/telemetry/logs/index.spec.js +++ b/packages/dd-trace/test/telemetry/logs/index.spec.js @@ -145,7 +145,12 @@ describe('telemetry logs', () => { errorLog.publish({ cause: error }) expect(logCollectorAdd) - .to.be.calledOnceWith(match({ message: `${error.name}: Generic Error`, level: 'ERROR', stack_trace: stack })) + .to.be.calledOnceWith(match({ + message: 'Generic Error', + level: 'ERROR', + errorType: 'Error', + stack_trace: stack + })) }) it('should be called when an error string is published to datadog:log:error', () => { diff --git a/packages/dd-trace/test/telemetry/logs/log-collector.spec.js b/packages/dd-trace/test/telemetry/logs/log-collector.spec.js index 1cb99cef518..57600dcb441 100644 --- a/packages/dd-trace/test/telemetry/logs/log-collector.spec.js +++ b/packages/dd-trace/test/telemetry/logs/log-collector.spec.js @@ -43,9 +43,18 @@ describe('telemetry log collector', () => { expect(logCollector.add({ message: 'Error 1', level: 'DEBUG', stack_trace: `stack 1\n${ddFrame}` })).to.be.true }) + it('should not store logs with empty stack and \'Generic Error\' message', () => { + expect(logCollector.add({ + message: 'Generic Error', + level: 'ERROR', + stack_trace: 'stack 1\n/not/a/dd/frame' + }) + ).to.be.false + }) + it('should include original message and dd frames', () => { const ddFrame = `at T (${ddBasePath}packages/dd-trace/test/telemetry/logs/log_collector.spec.js:29:21)` - const stack = new Error('Error 1') + const stack = new TypeError('Error 1') .stack.replace(`Error 1${EOL}`, `Error 1${EOL}${ddFrame}${EOL}`) const ddFrames = stack @@ -54,28 +63,41 @@ describe('telemetry log collector', () => { .map(line => line.replace(ddBasePath, '')) .join(EOL) - expect(logCollector.add({ message: 'Error 1', level: 'ERROR', stack_trace: stack })).to.be.true + expect(logCollector.add({ + message: 'Error 1', + level: 'ERROR', + stack_trace: stack, + errorType: 'TypeError' + })).to.be.true expect(logCollector.hasEntry({ message: 'Error 1', level: 'ERROR', - stack_trace: `Error: Error 1${EOL}${ddFrames}` + stack_trace: `TypeError: Error 1${EOL}${ddFrames}` })).to.be.true }) - it('should include original message if first frame is not a dd frame', () => { + it('should redact stack message if first frame is not a dd frame', () => { const thirdPartyFrame = `at callFn (/this/is/not/a/dd/frame/runnable.js:366:21) at T (${ddBasePath}packages/dd-trace/test/telemetry/logs/log_collector.spec.js:29:21)` - const stack = new Error('Error 1') + const stack = new TypeError('Error 1') .stack.replace(`Error 1${EOL}`, `Error 1${EOL}${thirdPartyFrame}${EOL}`) - const ddFrames = stack - .split(EOL) - .filter(line => line.includes(ddBasePath)) - .map(line => line.replace(ddBasePath, '')) - .join(EOL) + const ddFrames = [ + 'TypeError: redacted', + ...stack + .split(EOL) + .filter(line => line.includes(ddBasePath)) + .map(line => line.replace(ddBasePath, '')) + ].join(EOL) + + expect(logCollector.add({ + message: 'Error 1', + level: 'ERROR', + stack_trace: stack, + errorType: 'TypeError' + })).to.be.true - expect(logCollector.add({ message: 'Error 1', level: 'ERROR', stack_trace: stack })).to.be.true expect(logCollector.hasEntry({ message: 'Error 1', level: 'ERROR', @@ -104,5 +126,22 @@ describe('telemetry log collector', () => { expect(logs.length).to.be.equal(4) expect(logs[3]).to.deep.eq({ message: 'Omitted 2 entries due to overflowing', level: 'ERROR' }) }) + + it('duplicated errors should send incremented count values', () => { + const err1 = { message: 'oh no', level: 'ERROR', count: 1 } + + const err2 = { message: 'foo buzz', level: 'ERROR', count: 1 } + + logCollector.add(err1) + logCollector.add(err2) + logCollector.add(err1) + logCollector.add(err2) + logCollector.add(err1) + + const drainedErrors = logCollector.drain() + expect(drainedErrors.length).to.be.equal(2) + expect(drainedErrors[0].count).to.be.equal(3) + expect(drainedErrors[1].count).to.be.equal(2) + }) }) }) diff --git a/packages/dd-trace/test/util.spec.js b/packages/dd-trace/test/util.spec.js index 40b209a96cf..f32b47c0cee 100644 --- a/packages/dd-trace/test/util.spec.js +++ b/packages/dd-trace/test/util.spec.js @@ -3,7 +3,6 @@ require('./setup/tap') const { isTrue, isFalse, globMatch } = require('../src/util') -const { generatePointerHash } = require('../src/util') const TRUES = [ 1, @@ -69,20 +68,3 @@ describe('util', () => { }) }) }) - -describe('generatePointerHash', () => { - it('should generate a valid hash for a basic S3 object', () => { - const hash = generatePointerHash(['some-bucket', 'some-key.data', 'ab12ef34']) - expect(hash).to.equal('e721375466d4116ab551213fdea08413') - }) - - it('should generate a valid hash for an S3 object with a non-ascii key', () => { - const hash1 = generatePointerHash(['some-bucket', 'some-key.你好', 'ab12ef34']) - expect(hash1).to.equal('d1333a04b9928ab462b5c6cadfa401f4') - }) - - it('should generate a valid hash for multipart-uploaded S3 object', () => { - const hash1 = generatePointerHash(['some-bucket', 'some-key.data', 'ab12ef34-5']) - expect(hash1).to.equal('2b90dffc37ebc7bc610152c3dc72af9f') - }) -}) diff --git a/scripts/install_plugin_modules.js b/scripts/install_plugin_modules.js index 682e2d3c5ad..212dc5928ed 100644 --- a/scripts/install_plugin_modules.js +++ b/scripts/install_plugin_modules.js @@ -5,10 +5,10 @@ const os = require('os') const path = require('path') const crypto = require('crypto') const semver = require('semver') -const proxyquire = require('proxyquire') const exec = require('./helpers/exec') const childProcess = require('child_process') const externals = require('../packages/dd-trace/test/plugins/externals') +const { getInstrumentation } = require('../packages/dd-trace/test/setup/helpers/load-inst') const requirePackageJsonPath = require.resolve('../packages/dd-trace/src/require-package-json') @@ -47,19 +47,7 @@ async function run () { async function assertVersions () { const internals = names - .map(key => { - const instrumentations = [] - const name = key - - try { - loadInstFile(`${name}/server.js`, instrumentations) - loadInstFile(`${name}/client.js`, instrumentations) - } catch (e) { - loadInstFile(`${name}.js`, instrumentations) - } - - return instrumentations - }) + .map(getInstrumentation) .reduce((prev, next) => prev.concat(next), []) for (const inst of internals) { @@ -117,10 +105,10 @@ function assertFolder (name, version) { } } -async function assertPackage (name, version, dependency, external) { - const dependencies = { [name]: dependency } +async function assertPackage (name, version, dependencyVersionRange, external) { + const dependencies = { [name]: dependencyVersionRange } if (deps[name]) { - await addDependencies(dependencies, name, dependency) + await addDependencies(dependencies, name, dependencyVersionRange) } const pkg = { name: [name, sha1(name).substr(0, 8), sha1(version)].filter(val => val).join('-'), @@ -151,7 +139,13 @@ async function addDependencies (dependencies, name, versionRange) { for (const dep of deps[name]) { for (const section of ['devDependencies', 'peerDependencies']) { if (pkgJson[section] && dep in pkgJson[section]) { - dependencies[dep] = pkgJson[section][dep] + if (pkgJson[section][dep].includes('||')) { + // Use the first version in the list (as npm does by default) + dependencies[dep] = pkgJson[section][dep].split('||')[0].trim() + } else { + // Only one version available so use that. + dependencies[dep] = pkgJson[section][dep] + } break } } @@ -234,18 +228,3 @@ function sha1 (str) { shasum.update(str) return shasum.digest('hex') } - -function loadInstFile (file, instrumentations) { - const instrument = { - addHook (instrumentation) { - instrumentations.push(instrumentation) - } - } - - const instPath = path.join(__dirname, `../packages/datadog-instrumentations/src/${file}`) - - proxyquire.noPreserveCache()(instPath, { - './helpers/instrument': instrument, - '../helpers/instrument': instrument - }) -} diff --git a/scripts/release/helpers/requirements.js b/scripts/release/helpers/requirements.js index e8488610051..a2da9f924bb 100644 --- a/scripts/release/helpers/requirements.js +++ b/scripts/release/helpers/requirements.js @@ -1,6 +1,6 @@ 'use strict' -/* eslint-disable max-len */ +/* eslint-disable @stylistic/js/max-len */ const { capture, fatal, run } = require('./terminal') diff --git a/scripts/verify-ci-config.js b/scripts/verify-ci-config.js new file mode 100644 index 00000000000..2e16ac0f7c3 --- /dev/null +++ b/scripts/verify-ci-config.js @@ -0,0 +1,121 @@ +'use strict' +/* eslint-disable no-console */ + +const fs = require('fs') +const path = require('path') +const util = require('util') +const yaml = require('yaml') +const semver = require('semver') +const { execSync } = require('child_process') +const Module = require('module') +const { getAllInstrumentations } = require('../packages/dd-trace/test/setup/helpers/load-inst') + +if (!Module.isBuiltin) { + Module.isBuiltin = mod => Module.builtinModules.includes(mod) +} + +const nodeMajor = Number(process.versions.node.split('.')[0]) + +const instrumentations = getAllInstrumentations() + +const versions = {} + +function checkYaml (yamlPath) { + const yamlContent = yaml.parse(fs.readFileSync(yamlPath, 'utf8')) + + const rangesPerPluginFromYaml = {} + const rangesPerPluginFromInst = {} + for (const jobName in yamlContent.jobs) { + const job = yamlContent.jobs[jobName] + if (!job.env || !job.env.PLUGINS) continue + + const pluginName = job.env.PLUGINS + if (Module.isBuiltin(pluginName)) continue + const rangesFromYaml = getRangesFromYaml(job) + if (rangesFromYaml) { + if (!rangesPerPluginFromYaml[pluginName]) { + rangesPerPluginFromYaml[pluginName] = new Set() + } + rangesFromYaml.forEach(range => rangesPerPluginFromYaml[pluginName].add(range)) + const plugin = instrumentations[pluginName] + const allRangesForPlugin = new Set(plugin.map(x => x.versions).flat()) + rangesPerPluginFromInst[pluginName] = allRangesForPlugin + } + } + for (const pluginName in rangesPerPluginFromYaml) { + const yamlRanges = Array.from(rangesPerPluginFromYaml[pluginName]) + const instRanges = Array.from(rangesPerPluginFromInst[pluginName]) + const yamlVersions = getMatchingVersions(pluginName, yamlRanges) + const instVersions = getMatchingVersions(pluginName, instRanges) + if (!util.isDeepStrictEqual(yamlVersions, instVersions)) { + const opts = { colors: true } + const colors = x => util.inspect(x, opts) + errorMsg(pluginName, 'Mismatch', ` +Valid version ranges from YAML: ${colors(yamlRanges)} +Valid version ranges from INST: ${colors(instRanges)} +${mismatching(yamlVersions, instVersions)} +Note that versions may be dependent on Node.js version. This is Node.js v${colors(nodeMajor)} + +> These don't match the same sets of versions in npm. +> +> Please check ${yamlPath} and the instrumentations +> for ${pluginName} to see that the version ranges match.`.trim()) + } + } +} + +function getRangesFromYaml (job) { + // eslint-disable-next-line no-template-curly-in-string + if (job.env && job.env.PACKAGE_VERSION_RANGE && job.env.PACKAGE_VERSION_RANGE !== '${{ matrix.range }}') { + errorMsg(job.env.PLUGINS, 'ERROR in YAML', 'You must use matrix.range instead of env.PACKAGE_VERSION_RANGE') + process.exitCode = 1 + } + if (job.strategy && job.strategy.matrix && job.strategy.matrix.range) { + const possibilities = [job.strategy.matrix] + if (job.strategy.matrix.include) { + possibilities.push(...job.strategy.matrix.include) + } + return possibilities.map(possibility => { + if (possibility.range) { + return [possibility.range].flat() + } else { + return undefined + } + }).flat() + } + + return null +} + +function getMatchingVersions (name, ranges) { + if (!versions[name]) { + versions[name] = JSON.parse(execSync('npm show ' + name + ' versions --json').toString()) + } + return versions[name].filter(version => ranges.some(range => semver.satisfies(version, range))) +} + +checkYaml(path.join(__dirname, '..', '.github', 'workflows', 'plugins.yml')) +checkYaml(path.join(__dirname, '..', '.github', 'workflows', 'appsec.yml')) + +function mismatching (yamlVersions, instVersions) { + const yamlSet = new Set(yamlVersions) + const instSet = new Set(instVersions) + + const onlyInYaml = yamlVersions.filter(v => !instSet.has(v)) + const onlyInInst = instVersions.filter(v => !yamlSet.has(v)) + + const opts = { colors: true } + return [ + `Versions only in YAML: ${util.inspect(onlyInYaml, opts)}`, + `Versions only in INST: ${util.inspect(onlyInInst, opts)}` + ].join('\n') +} + +function errorMsg (pluginName, title, message) { + console.log('===========================================') + console.log(title + ' for ' + pluginName) + console.log('-------------------------------------------') + console.log(message) + console.log('\n') + process.exitCode = 1 +} diff --git a/yarn.lock b/yarn.lock index 2e4b4c17ce5..a56218a0a45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -401,10 +401,10 @@ resolved "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz" integrity "sha1-u1BFecHK6SPmV2pPXaQ9Jfl729k= sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==" -"@datadog/libdatadog@^0.2.2": - version "0.2.2" - resolved "https://registry.yarnpkg.com/@datadog/libdatadog/-/libdatadog-0.2.2.tgz#ac02c76ac9a38250dca740727c7cdf00244ce3d3" - integrity sha512-rTWo96mEPTY5UbtGoFj8/wY0uKSViJhsPg/Z6aoFWBFXQ8b45Ix2e/yvf92AAwrhG+gPLTxEqTXh3kef2dP8Ow== +"@datadog/libdatadog@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@datadog/libdatadog/-/libdatadog-0.3.0.tgz#2fc1e2695872840bc8c356f66acf675da428d6f0" + integrity sha512-TbP8+WyXfh285T17FnLeLUOPl4SbkRYMqKgcmknID2mXHNrbt5XJgW9bnDgsrrtu31Q7FjWWw2WolgRLWyzLRA== "@datadog/native-appsec@8.3.0": version "8.3.0" @@ -413,10 +413,10 @@ dependencies: node-gyp-build "^3.9.0" -"@datadog/native-iast-rewriter@2.5.0": - version "2.5.0" - resolved "https://registry.yarnpkg.com/@datadog/native-iast-rewriter/-/native-iast-rewriter-2.5.0.tgz#b613defe86e78168f750d1f1662d4ffb3cf002e6" - integrity sha512-WRu34A3Wwp6oafX8KWNAbedtDaaJO+nzfYQht7pcJKjyC2ggfPeF7SoP+eDo9wTn4/nQwEOscSR4hkJqTRlpXQ== +"@datadog/native-iast-rewriter@2.6.1": + version "2.6.1" + resolved "https://registry.yarnpkg.com/@datadog/native-iast-rewriter/-/native-iast-rewriter-2.6.1.tgz#5e5393628c73c57dcf08256299c0e8cf71deb14f" + integrity sha512-zv7cr/MzHg560jhAnHcO7f9pLi4qaYrBEcB+Gla0xkVouYSDsp8cGXIGG4fiGdAMHdt7SpDNS6+NcEAqD/v8Ig== dependencies: lru-cache "^7.14.0" node-gyp-build "^4.5.0" @@ -428,10 +428,10 @@ dependencies: node-gyp-build "^3.9.0" -"@datadog/native-metrics@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@datadog/native-metrics/-/native-metrics-3.0.1.tgz#dc276c93785c0377a048e316f23b7c8ff3acfa84" - integrity sha512-0GuMyYyXf+Qpb/F+Fcekz58f2mO37lit9U3jMbWY/m8kac44gCPABzL5q3gWbdH+hWgqYfQoEYsdNDGSrKfwoQ== +"@datadog/native-metrics@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@datadog/native-metrics/-/native-metrics-3.1.0.tgz#c2378841accd9fdd6866d0e49bdf6e3d76e79f22" + integrity sha512-yOBi4x0OQRaGNPZ2bx9TGvDIgEdQ8fkudLTFAe7gEM1nAlvFmbE5YfpH8WenEtTSEBwojSau06m2q7axtEEmCg== dependencies: node-addon-api "^6.1.0" node-gyp-build "^3.9.0" @@ -589,11 +589,31 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@eslint/eslintrc@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.1.0.tgz#dbd3482bfd91efa663cbe7aa1f506839868207b6" + integrity sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + "@eslint/js@8.57.0": version "8.57.0" resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== +"@eslint/js@^9.11.1": + version "9.11.1" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.11.1.tgz#8bcb37436f9854b3d9a561440daf916acd940986" + integrity sha512-/qu+TWz8WwPWc7/HcIJKi+c+MOm46GdVaSlTTQcaqaL53+GsoA6MxWp5PtTx48qbSP7ylM1Kn7nhvkugfJvRSA== + "@graphql-tools/merge@^8.4.1": version "8.4.2" resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-8.4.2.tgz#95778bbe26b635e8d2f60ce9856b388f11fe8288" @@ -851,6 +871,14 @@ resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz#282046f03e886e352b2d5f5da5eb755e01457f3f" integrity sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA== +"@stylistic/eslint-plugin-js@^2.8.0": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@stylistic/eslint-plugin-js/-/eslint-plugin-js-2.8.0.tgz#f605202c75aa17692342662231f77d413d96d940" + integrity sha512-/e7pSzVMrwBd6yzSDsKHwax3TS96+pd/xSKzELaTkOuYqUhYfj/becWdfDbFSBGQD7BBBCiiE4L8L2cUfu5h+A== + dependencies: + eslint-visitor-keys "^4.0.0" + espree "^10.1.0" + "@types/body-parser@*": version "1.19.5" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" @@ -928,10 +956,10 @@ dependencies: undici-types "~5.26.4" -"@types/node@^16.18.103": - version "16.18.103" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.103.tgz#5557c7c32a766fddbec4b933b1d5c365f89b20a4" - integrity sha512-gOAcUSik1nR/CRC3BsK8kr6tbmNIOTpvb1sT+v5Nmmys+Ho8YtnIHP90wEsVK4hTcHndOqPVIlehEGEA5y31bA== +"@types/node@^16.0.0": + version "16.18.122" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.122.tgz#54948ddbe2ddef8144ee16b37f160e3f99c32397" + integrity sha512-rF6rUBS80n4oK16EW8nE75U+9fw0SSUgoPtWSvHhPXdT7itbvmS7UjB/jyM8i3AkvI6yeSM5qCwo+xN0npGDHg== "@types/prop-types@*": version "15.7.5" @@ -1012,7 +1040,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.12.0, acorn@^8.8.2, acorn@^8.9.0: version "8.12.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== @@ -2219,6 +2247,11 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== +eslint-visitor-keys@^4.0.0, eslint-visitor-keys@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz#1f785cc5e81eb7534523d85922248232077d2f8c" + integrity sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg== + eslint@^8.57.0: version "8.57.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.0.tgz#c786a6fd0e0b68941aaf624596fb987089195668" @@ -2268,6 +2301,15 @@ esm@^3.2.25: resolved "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz" integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA== +espree@^10.0.1, espree@^10.1.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.2.0.tgz#f4bcead9e05b0615c968e85f83816bc386a45df6" + integrity sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g== + dependencies: + acorn "^8.12.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.1.0" + espree@^9.6.0, espree@^9.6.1: version "9.6.1" resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" @@ -2326,10 +2368,10 @@ events@1.1.1: resolved "https://registry.npmjs.org/events/-/events-1.1.1.tgz" integrity "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ= sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==" -express@^4.17.1, express@^4.18.2: - version "4.21.1" - resolved "https://registry.yarnpkg.com/express/-/express-4.21.1.tgz#9dae5dda832f16b4eec941a4e44aa89ec481b281" - integrity sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ== +express@^4.17.1, express@^4.21.2: + version "4.21.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.21.2.tgz#cf250e48362174ead6cea4a566abef0162c1ec32" + integrity sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA== dependencies: accepts "~1.3.8" array-flatten "1.1.1" @@ -2350,7 +2392,7 @@ express@^4.17.1, express@^4.18.2: methods "~1.1.2" on-finished "2.4.1" parseurl "~1.3.3" - path-to-regexp "0.1.10" + path-to-regexp "0.1.12" proxy-addr "~2.0.7" qs "6.13.0" range-parser "~1.2.1" @@ -2668,6 +2710,16 @@ globals@^13.19.0, globals@^13.24.0: dependencies: type-fest "^0.20.2" +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + +globals@^15.10.0: + version "15.10.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-15.10.0.tgz#a7eab3886802da248ad8b6a9ccca6573ff899c9b" + integrity sha512-tqFIbz83w4Y5TCbtgjZjApohbuh7K9BxGYFm7ifwDR240tvdb7P9x+/9VvUKlmkPoiknoJtanI8UOrqxS3a7lQ== + globalthis@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" @@ -3571,7 +3623,7 @@ mocha@^9: log-symbols "4.1.0" minimatch "4.2.1" ms "2.1.3" - nanoid "3.3.1" + nanoid "3.3.8" serialize-javascript "6.0.0" strip-json-comments "3.1.1" supports-color "8.1.1" @@ -3629,10 +3681,10 @@ multer@^1.4.5-lts.1: type-is "^1.6.4" xtend "^4.0.0" -nanoid@3.3.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35" - integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw== +nanoid@3.3.8: + version "3.3.8" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" + integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== natural-compare@^1.4.0: version "1.4.0" @@ -3960,10 +4012,10 @@ path-parse@^1.0.7: resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-to-regexp@0.1.10, path-to-regexp@^0.1.10: - version "0.1.10" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.10.tgz#67e9108c5c0551b9e5326064387de4763c4d5f8b" - integrity sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w== +path-to-regexp@0.1.12, path-to-regexp@^0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" + integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== path-to-regexp@^6.2.1: version "6.3.0" @@ -4530,7 +4582,7 @@ source-map@^0.6.0, source-map@^0.6.1: source-map@^0.7.4: version "0.7.4" - resolved "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== spawn-wrap@^2.0.0: @@ -5200,6 +5252,11 @@ yaml@^1.10.2: resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yaml@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.5.0.tgz#c6165a721cf8000e91c36490a41d7be25176cf5d" + integrity sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw== + yargs-parser@20.2.4: version "20.2.4" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz"