diff --git a/.agents/skills/flaky-test-fixer/SKILL.md b/.agents/skills/flaky-test-fixer/SKILL.md new file mode 100644 index 00000000000..5a90b9148cc --- /dev/null +++ b/.agents/skills/flaky-test-fixer/SKILL.md @@ -0,0 +1,46 @@ +--- +name: flaky-test-fixer +description: >- + Use when triaging, investigating, or fixing a suspected flaky test, intermittent + failure, nondeterministic CI failure, timing race, or test-order dependency in + dd-trace-js. Classifies infrastructure and deterministic failures before the + root-cause workflow. +--- + +# Flaky test fixer + +## Classify once + +Spend one evidence pass on the failing step, the first actionable error, and whether the test process started. Do +not reproduce or search test code before this gate. + +- **Infrastructure:** the test process never ran because checkout, runner, registry, network, or credentials failed, + or independent evidence proves an externally owned network or service outage regardless of test-entry timing. State + the evidence and stop; ignore it for flaky-test work. Treat recurrence as a separate CI task only when asked. +- **Deterministic:** the same revision and inputs consistently fail because of a version, fixture, configuration, or + assertion mismatch. It is not a flake; handle it in the owning change. +- **Genuine flake:** the same test can pass and fail at the same revision with matching relevant inputs and execution + configuration, or evidence proves nondeterministic ordering, timing, or shared state. A green rerun is evidence only + when the test ran under those matching conditions in both attempts. +- **Unknown:** evidence proves none of the above. Run one targeted reproduction or history comparison; do not promote + uncertainty to “flaky,” “infrastructure,” or “unrelated.” + +## Fix the cause + +1. Reproduce through the smallest real test entry point. Stress the suspected boundary and expose ordering or state; + repeated reruns without a sharper hypothesis are not diagnosis. For a hang, inspect the last error before the + leaked handle kept the process alive. +2. Write one mechanistic sentence naming the producer, consumer, state or event, and invalid ordering or lifetime. + It must explain both the pass and failure. Do not design a fix before this sentence holds. +3. Before editing, search the repository for every test with the same violated invariant and lifecycle owner. + Inventory, count, and list each member and exclusion; state the shared failure mechanism and lifecycle or + completion owner. A range is not an enumeration. Callback versus promise does not split a cohort; similar syntax + under a different contract does not join it. +4. Design the proof, then trace success, error, retry, cleanup, and concurrent paths. Fix the narrowest canonical + owner that restores the invariant for the whole cohort without a new public/test-only surface or production work + added solely for tests. +5. Apply that fix to the complete cohort. Do not substitute retries, skips, sleeps, timeout/tolerance increases, + filtered assertions, or broader mocks for a cause. Keep unrelated mechanisms in separate changes. +6. Verify the original failure without the fix or with a deterministic regression when practical. List every changed + sibling individually in the verification plan, then run them, a targeted repeat or stress run, the complete specs, + and the required coverage/lint from `AGENTS.md`. Report commands, iteration counts, and unproved claims. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7394f0616c1..28f851466a6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -62,6 +62,8 @@ /packages/dd-trace/src/lambda/ @DataDog/dd-trace-js @DataDog/serverless-aws @DataDog/apm-serverless /packages/dd-trace/src/azure_metadata.js @DataDog/dd-trace-js @DataDog/apm-serverless /packages/dd-trace/src/serverless.js @DataDog/dd-trace-js @DataDog/apm-serverless +/packages/dd-trace/src/serverless/ @DataDog/dd-trace-js @DataDog/apm-serverless +/packages/dd-trace/src/flush.js @DataDog/dd-trace-js @DataDog/apm-serverless @DataDog/apm-sdk-capabilities-js /packages/dd-trace/test/lambda/ @DataDog/dd-trace-js @DataDog/serverless-aws @DataDog/apm-serverless /packages/dd-trace/test/azure_metadata.spec.js @DataDog/dd-trace-js @DataDog/apm-serverless /packages/dd-trace/test/serverless.spec.js @DataDog/dd-trace-js @DataDog/apm-serverless @@ -131,6 +133,7 @@ /packages/datadog-plugin-cypress/ @DataDog/dd-trace-js @DataDog/ci-app-libraries /packages/datadog-plugin-playwright/ @DataDog/dd-trace-js @DataDog/ci-app-libraries /packages/datadog-plugin-vitest/ @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-plugin-nyc/ @DataDog/dd-trace-js @DataDog/ci-app-libraries /packages/dd-trace/src/plugins/util/git.js @DataDog/dd-trace-js @DataDog/ci-app-libraries /packages/dd-trace/src/plugins/ci_plugin.js @DataDog/dd-trace-js @DataDog/ci-app-libraries /packages/dd-trace/test/ci-visibility/ @DataDog/dd-trace-js @DataDog/ci-app-libraries @@ -176,6 +179,24 @@ /packages/datadog-instrumentations/src/cypress.js @DataDog/dd-trace-js @DataDog/ci-app-libraries /packages/datadog-instrumentations/src/playwright.js @DataDog/dd-trace-js @DataDog/ci-app-libraries /packages/datadog-instrumentations/src/vitest.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/src/vitest-main.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/src/vitest-util.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/src/vitest-worker.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/src/cucumber-worker-threads.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/src/cypress-config.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/src/cypress-legacy-finalizers.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/src/playwright-browser-scripts.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/src/playwright-reporter.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/src/webdriverio.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/src/nyc.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/src/jest/coverage-backfill.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/test/vitest-main.spec.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/test/webdriverio.spec.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/test/nyc.spec.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/test/fixtures/jasmine-core.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/test/fixtures/mocha-regular-worker.js @DataDog/dd-trace-js @DataDog/ci-app-libraries +/packages/datadog-instrumentations/test/fixtures/webdriverio-* @DataDog/dd-trace-js @DataDog/ci-app-libraries /integration-tests/ci-visibility/ @DataDog/dd-trace-js @DataDog/ci-app-libraries /integration-tests/cucumber/ @DataDog/dd-trace-js @DataDog/ci-app-libraries @@ -326,43 +347,51 @@ /packages/dd-trace/test/openfeature/ @DataDog/dd-trace-js @DataDog/feature-flagging-and-experimentation-sdk # CI +/.github/actions/**/action.yml @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/.github/actions/**/action.yaml @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts /.github/actions/upload-coverage-artifact/ @DataDog/dd-trace-js @DataDog/ci-app-libraries +/.github/actions/upload-coverage-artifact/action.yml @DataDog/dd-trace-js @DataDog/ci-app-libraries @dd-octo-sts +/.github/actions/upload-coverage-artifact/action.yaml @DataDog/dd-trace-js @DataDog/ci-app-libraries @dd-octo-sts /.github/actions/upload-junit-artifacts/ @DataDog/dd-trace-js @DataDog/ci-app-libraries +/.github/actions/upload-junit-artifacts/action.yml @DataDog/dd-trace-js @DataDog/ci-app-libraries @dd-octo-sts +/.github/actions/upload-junit-artifacts/action.yaml @DataDog/dd-trace-js @DataDog/ci-app-libraries @dd-octo-sts /.github/editorconfig-checker/ @DataDog/dd-trace-js @Datadog/lang-platform-js /.github/playwright/ @DataDog/dd-trace-js @DataDog/ci-app-libraries /.github/selenium/ @DataDog/dd-trace-js @DataDog/ci-app-libraries /.github/chainguard/ @DataDog/dd-trace-js @DataDog/sdlc-security /.github/codeql_config.yml @DataDog/dd-trace-js @DataDog/sdlc-security -/.github/workflows/codeql-analysis.yml @DataDog/dd-trace-js @DataDog/sdlc-security -/.github/workflows/mirror-image.yml @DataDog/dd-trace-js @Datadog/lang-platform-js -/.github/workflows/all-green.yml @DataDog/dd-trace-js @Datadog/lang-platform-js -/.github/workflows/audit.yml @DataDog/dd-trace-js @DataDog/lang-platform-js -/.github/workflows/custom-node-version-dispatch.yml @DataDog/dd-trace-js @Datadog/lang-platform-js -/.github/workflows/dependabot-automation.yml @DataDog/dd-trace-js @DataDog/lang-platform-js @DataDog/apm-sdk-capabilities-js -/.github/workflows/flakiness.yml @DataDog/dd-trace-js @DataDog/lang-platform-js -/.github/workflows/platform.yml @DataDog/dd-trace-js @DataDog/lang-platform-js -/.github/workflows/project.yml @DataDog/dd-trace-js @Datadog/lang-platform-js -/.github/workflows/release-proposal.yml @DataDog/dd-trace-js @DataDog/lang-platform-js -/.github/workflows/release-validate.yml @DataDog/dd-trace-js @DataDog/lang-platform-js -/.github/workflows/stale.yml @DataDog/dd-trace-js @Datadog/lang-platform-js -/.github/workflows/update-3rdparty-licenses.yml @DataDog/dd-trace-js @DataDog/lang-platform-js - -/.github/workflows/apm-capabilities.yml @DataDog/dd-trace-js @DataDog/apm-sdk-capabilities-js -/.github/workflows/eslint-rules.yml @DataDog/dd-trace-js @DataDog/apm-sdk-capabilities-js -/.github/workflows/apm-integrations.yml @DataDog/dd-trace-js @DataDog/apm-idm-js -/.github/workflows/aiguard.yml @DataDog/dd-trace-js @DataDog/asm-js -/.github/workflows/appsec.yml @DataDog/dd-trace-js @DataDog/asm-js -/.github/workflows/debugger.yml @DataDog/dd-trace-js @DataDog/debugger-nodejs -/.github/workflows/instrumentation.yml @DataDog/dd-trace-js @DataDog/apm-idm-js -/.github/workflows/electron.yml @DataDog/dd-trace-js @DataDog/apm-idm-js -/.github/workflows/release.yml @DataDog/dd-trace-js @DataDog/lang-platform-js -/.github/workflows/serverless.yml @DataDog/dd-trace-js @DataDog/serverless-aws @DataDog/apm-serverless -/.github/workflows/llmobs.yml @DataDog/dd-trace-js @DataDog/ml-observability -/.github/workflows/openfeature.yml @DataDog/dd-trace-js @DataDog/feature-flagging-and-experimentation-sdk -/.github/workflows/pr-title.yml @DataDog/dd-trace-js @DataDog/lang-platform-js -/.github/workflows/profiling.yml @DataDog/dd-trace-js @DataDog/profiling-js -/.github/workflows/system-tests.yml @DataDog/dd-trace-js @DataDog/asm-js -/.github/workflows/test-optimization.yml @DataDog/dd-trace-js @DataDog/ci-app-libraries +/.github/workflows/*.yml @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/.github/workflows/*.yaml @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/.github/workflows/codeql-analysis.yml @DataDog/dd-trace-js @DataDog/sdlc-security @dd-octo-sts +/.github/workflows/mirror-image.yml @DataDog/dd-trace-js @Datadog/lang-platform-js @dd-octo-sts +/.github/workflows/all-green.yml @DataDog/dd-trace-js @Datadog/lang-platform-js @dd-octo-sts +/.github/workflows/audit.yml @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/.github/workflows/custom-node-version-dispatch.yml @DataDog/dd-trace-js @Datadog/lang-platform-js @dd-octo-sts +/.github/workflows/dependabot-automation.yml @DataDog/dd-trace-js @DataDog/lang-platform-js @DataDog/apm-sdk-capabilities-js @dd-octo-sts +/.github/workflows/flakiness.yml @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/.github/workflows/platform.yml @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/.github/workflows/project.yml @DataDog/dd-trace-js @Datadog/lang-platform-js @dd-octo-sts +/.github/workflows/release-proposal.yml @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/.github/workflows/release-validate.yml @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/.github/workflows/stale.yml @DataDog/dd-trace-js @Datadog/lang-platform-js @dd-octo-sts +/.github/workflows/update-3rdparty-licenses.yml @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts + +/.github/workflows/apm-capabilities.yml @DataDog/dd-trace-js @DataDog/apm-sdk-capabilities-js @dd-octo-sts +/.github/workflows/eslint-rules.yml @DataDog/dd-trace-js @DataDog/apm-sdk-capabilities-js @dd-octo-sts +/.github/workflows/apm-integrations.yml @DataDog/dd-trace-js @DataDog/apm-idm-js @dd-octo-sts +/.github/workflows/aiguard.yml @DataDog/dd-trace-js @DataDog/asm-js @dd-octo-sts +/.github/workflows/appsec.yml @DataDog/dd-trace-js @DataDog/asm-js @dd-octo-sts +/.github/workflows/debugger.yml @DataDog/dd-trace-js @DataDog/debugger-nodejs @dd-octo-sts +/.github/workflows/instrumentation.yml @DataDog/dd-trace-js @DataDog/apm-idm-js @dd-octo-sts +/.github/workflows/electron.yml @DataDog/dd-trace-js @DataDog/apm-idm-js @dd-octo-sts +/.github/workflows/release.yml @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/.github/workflows/serverless.yml @DataDog/dd-trace-js @DataDog/serverless-aws @DataDog/apm-serverless @dd-octo-sts +/.github/workflows/llmobs.yml @DataDog/dd-trace-js @DataDog/ml-observability @dd-octo-sts +/.github/workflows/openfeature.yml @DataDog/dd-trace-js @DataDog/feature-flagging-and-experimentation-sdk @dd-octo-sts +/.github/workflows/pr-title.yml @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/.github/workflows/profiling.yml @DataDog/dd-trace-js @DataDog/profiling-js @dd-octo-sts +/.github/workflows/system-tests.yml @DataDog/dd-trace-js @DataDog/asm-js @dd-octo-sts +/.github/workflows/test-optimization.yml @DataDog/dd-trace-js @DataDog/ci-app-libraries @dd-octo-sts # Profiling /benchmark/sirun/profiler/ @DataDog/dd-trace-js @DataDog/profiling-js @@ -380,6 +409,7 @@ # Language Platform /* @DataDog/dd-trace-js @DataDog/lang-platform-js +/AGENTS.md @DataDog/dd-trace-js @DataDog/lang-platform-js /openfeature.d.ts @DataDog/dd-trace-js @DataDog/lang-platform-js @DataDog/feature-flagging-and-experimentation-sdk /openfeature.js @DataDog/dd-trace-js @DataDog/lang-platform-js @DataDog/feature-flagging-and-experimentation-sdk @@ -512,3 +542,21 @@ /ext/tags.* @DataDog/dd-trace-js @DataDog/apm-idm-js @DataDog/apm-sdk-capabilities-js @DataDog/data-streams-monitoring /ext/types.* @DataDog/dd-trace-js @DataDog/apm-idm-js @DataDog/apm-serverless /packages/dd-trace/src/plugin_manager.js @DataDog/dd-trace-js @DataDog/lang-platform-js @DataDog/apm-idm-js + +# Automated dependency updates +/package.json @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/yarn.lock @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/docs/package.json @DataDog/dd-trace-js @DataDog/apm-idm-js @DataDog/lang-platform-js @dd-octo-sts +/docs/yarn.lock @DataDog/dd-trace-js @DataDog/apm-idm-js @DataDog/lang-platform-js @dd-octo-sts +/.github/actions/datadog-ci/package.json @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/.github/actions/datadog-ci/yarn.lock @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/vendor/package.json @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/vendor/package-lock.json @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts +/packages/dd-trace/test/plugins/versions/package.json @DataDog/dd-trace-js @DataDog/apm-idm-js @dd-octo-sts +/integration-tests/esbuild/package.json @DataDog/dd-trace-js @DataDog/apm-idm-js @dd-octo-sts +/integration-tests/appsec/iast-esbuild-esm/package.json @DataDog/dd-trace-js @DataDog/asm-js @dd-octo-sts +/integration-tests/appsec/iast-esbuild-cjs/package.json @DataDog/dd-trace-js @DataDog/asm-js @dd-octo-sts +/.github/editorconfig-checker/Dockerfile @DataDog/dd-trace-js @Datadog/lang-platform-js @dd-octo-sts +/.github/playwright/Dockerfile @DataDog/dd-trace-js @DataDog/ci-app-libraries @dd-octo-sts +/.github/selenium/Dockerfile @DataDog/dd-trace-js @DataDog/ci-app-libraries @dd-octo-sts +/benchmark/sirun/Dockerfile* @DataDog/dd-trace-js @DataDog/lang-platform-js @dd-octo-sts diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 03827cee872..ed00597fc32 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -45,7 +45,7 @@ jobs: - name: Initialize CodeQL id: init-codeql - uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: languages: ${{ matrix.language }} config-file: .github/codeql_config.yml @@ -57,7 +57,7 @@ jobs: - name: Perform CodeQL Analysis id: analyze - uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: token: ${{ github.token }} wait-for-processing: false diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index ab4779462e5..f51c3a5b300 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -60,7 +60,14 @@ jobs: - run: mkdir -p /tmp/app - run: npm i -g pnpm if: matrix.manager.name == 'pnpm' - - run: cd /tmp/app && ${{ matrix.manager.install }} /tmp/dd-trace.tgz + - name: Install packed package + shell: bash + run: | + cd /tmp/app + ${{ matrix.manager.install }} /tmp/dd-trace.tgz 2>&1 | tee /tmp/package-manager-install.log + - name: Reject Yarn build warnings + if: matrix.manager.name == 'yarn-berry' + run: "! grep -F 'YN0007:' /tmp/package-manager-install.log" - run: echo "require('dd-trace').init()" >> /tmp/app/index.js - run: node /tmp/app diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 9a35d62ea41..84bfdd410c3 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -25,7 +25,7 @@ jobs: - name: Checkout base revision uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: ${{ github.sha }} persist-credentials: false - name: Auto-rename GitHub revert title to Conventional Commit @@ -63,6 +63,8 @@ jobs: core.info(`PR title OK: ${title}`) const type = match[3] + if (!/^(?:feat|fix|perf|docs)$/.test(type)) return + const changedFiles = await github.paginate(github.rest.pulls.listFiles, { ...context.repo, pull_number: pullRequest.number, @@ -71,13 +73,17 @@ jobs: const files = [] const { appendChangedPaths, isInternalOnly } = require('./scripts/release/changelog') appendChangedPaths(files, changedFiles) - if (/^(?:feat|fix|perf|docs)$/.test(type) && isInternalOnly(files)) { + if (isInternalOnly(files)) { core.setFailed(`PR title type "${type}" is public, but every changed file is internal. ` + 'Use test, bench, ci, or chore.') } - name: Sync labels with PR title - if: steps.rename.outputs.renamed != 'true' + if: >- + steps.rename.outputs.renamed != 'true' && + (github.event.action == 'opened' || + github.event.action == 'reopened' || + (github.event.action == 'edited' && github.event.changes.title != null)) uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/release-proposal.yml b/.github/workflows/release-proposal.yml index 615e706fd39..14fc7a41370 100644 --- a/.github/workflows/release-proposal.yml +++ b/.github/workflows/release-proposal.yml @@ -36,6 +36,127 @@ jobs: done echo "release-lines=[$(IFS=,; echo "${lines[*]}")]" >> "$GITHUB_OUTPUT" + release-warning: + needs: check-branches + if: > + github.event_name == 'schedule' && + needs.check-branches.outputs.release-lines != '[]' + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: DataDog/dd-octo-sts-action@96a25462dbcb10ebf0bfd6e2ccc917d2ab235b9a # v1.0.4 + id: octo-sts + with: + scope: DataDog/dd-trace-js + policy: release-proposal + - id: release-age + env: + GH_TOKEN: ${{ steps.octo-sts.outputs.token }} + RELEASE_LINES: ${{ needs.check-branches.outputs.release-lines }} + run: | + releases=$(gh api --paginate "repos/${{ github.repository }}/releases?per_page=100" | jq -s 'add') + today=$(date -u +%F) + today_epoch=$(date -u -d "$today" +%s) + warning_lines=() + minimum_calendar_age= + maximum_business_age= + + while IFS= read -r line; do + published_at=$(jq -r --arg prefix "v${line}." ' + map(select(.draft == false and (.tag_name | startswith($prefix)))) | + max_by(.published_at).published_at // empty + ' <<< "$releases") + + if [ -z "$published_at" ]; then + echo "No published release found for v${line}.x" >&2 + exit 1 + fi + + release_date=${published_at%%T*} + release_epoch=$(date -u -d "$release_date" +%s) + calendar_age=$(((today_epoch - release_epoch) / 86400)) + cursor=$release_date + business_age=0 + + while [ "$cursor" != "$today" ]; do + cursor=$(date -u -d "$cursor + 1 day" +%F) + day_of_week=$(date -u -d "$cursor" +%u) + if ((day_of_week <= 5)); then + business_age=$((business_age + 1)) + fi + done + + if ((business_age != 5 && business_age < 8)); then + continue + fi + + warning_lines+=("v${line}") + + if [ -z "$minimum_calendar_age" ] || ((calendar_age < minimum_calendar_age)); then + minimum_calendar_age=$calendar_age + fi + + if [ -z "$maximum_business_age" ] || ((business_age > maximum_business_age)); then + maximum_business_age=$business_age + fi + + done < <(jq -r '.[]' <<< "$RELEASE_LINES") + + if ((${#warning_lines[@]} == 0)); then + echo "warn=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if ((${#warning_lines[@]} == 1)); then + release_subject="Release line ${warning_lines[0]}" + release_verb=has + release_noun=release + else + last_index=$((${#warning_lines[@]} - 1)) + release_lines=${warning_lines[0]} + for ((index = 1; index < last_index; index++)); do + release_lines+=", ${warning_lines[index]}" + done + release_subject="Release lines ${release_lines} and ${warning_lines[last_index]}" + release_verb=have + release_noun=releases + fi + + if ((maximum_business_age >= 8)); then + icon=:rotating_light: + else + icon=:warning: + fi + + { + echo "warn=true" + echo "days=$minimum_calendar_age" + echo "icon=$icon" + echo "release-noun=$release_noun" + echo "release-subject=$release_subject" + echo "release-verb=$release_verb" + } >> "$GITHUB_OUTPUT" + - uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v2.1.0 + if: steps.release-age.outputs.warn == 'true' + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + errors: true + payload: | + channel: ${{ secrets.SLACK_CHANNEL_ID }} + blocks: + - type: "section" + text: + type: "mrkdwn" + text: >- + ${{ steps.release-age.outputs.icon }} + *${{ steps.release-age.outputs.release-subject }}* + ${{ steps.release-age.outputs.release-verb }} gone at least + ${{ steps.release-age.outputs.days }} days without a release. + Consider cutting the next + ${{ steps.release-age.outputs.release-noun }} soon. + create-proposal: needs: check-branches if: needs.check-branches.outputs.release-lines != '[]' diff --git a/benchmark/e2e-test-optimization/benchmark-run.js b/benchmark/e2e-test-optimization/benchmark-run.js index 7e597b3e165..44b4acdc152 100644 --- a/benchmark/e2e-test-optimization/benchmark-run.js +++ b/benchmark/e2e-test-optimization/benchmark-run.js @@ -9,7 +9,12 @@ const API_REPOSITORY_URL = 'https://api.github.com/repos/DataDog/test-environmen const DISPATCH_WORKFLOW_URL = `${API_REPOSITORY_URL}/actions/workflows/dd-trace-js-tests.yml/dispatches` const GET_WORKFLOWS_URL = `${API_REPOSITORY_URL}/actions/runs` -const MAX_ATTEMPTS = 30 * 60 / 5 // 30 minutes, polling every 5 seconds = 360 attempts +const POLL_INTERVAL_MS = 5000 +const MAX_WORKFLOW_WAIT_MS = 30 * 60 * 1000 +const MAX_WORKFLOW_POLLS = MAX_WORKFLOW_WAIT_MS / POLL_INTERVAL_MS +const RETRY_GRACE_PERIOD_MS = 60 * 1000 +const RETRY_GRACE_PERIOD_POLLS = RETRY_GRACE_PERIOD_MS / POLL_INTERVAL_MS +const INITIAL_RUN_ATTEMPT = 1 const getResponsePreview = (body) => { return body.replace(/\s+/g, ' ').slice(0, 200) @@ -58,7 +63,8 @@ const getCommonHeaders = () => { return { 'Content-Type': 'application/json', authorization: `Bearer ${process.env.GITHUB_TOKEN}`, - Accept: 'application/vnd.github.v3+json', + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2026-03-10', 'user-agent': 'dd-trace benchmark tests', } } @@ -66,7 +72,6 @@ const getCommonHeaders = () => { const triggerWorkflow = () => { console.log(`Commit SHA under test: ${getRefToTest()} in ${getRefName()}`) return new Promise((resolve, reject) => { - // eslint-disable-next-line let response = '' const body = JSON.stringify({ ref: 'main', @@ -78,30 +83,6 @@ const triggerWorkflow = () => { method: 'POST', headers: getCommonHeaders(), }, (res) => { - res.on('data', (chunk) => { - response += chunk - }) - res.on('end', () => { - resolve(res.statusCode) - }) - }) - request.on('error', (error) => { - reject(error) - }) - request.write(body) - request.end() - }) -} - -const getWorkflowRunsInProgress = () => { - return new Promise((resolve, reject) => { - let response = '' - const request = https.request( - `${GET_WORKFLOWS_URL}?event=workflow_dispatch`, - { - headers: getCommonHeaders(), - }, - (res) => { res.on('data', (chunk) => { response += chunk }) @@ -109,7 +90,7 @@ const getWorkflowRunsInProgress = () => { try { resolve(parseGitHubJsonResponse({ body: response, - endpoint: `${GET_WORKFLOWS_URL}?event=workflow_dispatch`, + endpoint: DISPATCH_WORKFLOW_URL, res, })) } catch (e) { @@ -117,22 +98,30 @@ const getWorkflowRunsInProgress = () => { } }) }) - request.on('error', err => { - reject(err) + request.on('error', (error) => { + reject(error) }) + request.write(body) request.end() }) } -const getCurrentWorkflowJobs = (runId) => { +/** + * Gets the latest state for a workflow run. + * + * @param {number} runId + * @returns {Promise<{ conclusion: string, run_attempt: number, status: string }>} + */ +const getCurrentWorkflow = (runId) => { let body = '' return new Promise((resolve, reject) => { if (!runId) { - reject(new Error('No job run id specified')) + reject(new Error('No workflow run id specified')) return } + const endpoint = `${GET_WORKFLOWS_URL}/${runId}` const request = https.request( - `${GET_WORKFLOWS_URL}/${runId}/jobs`, + endpoint, { headers: getCommonHeaders(), }, @@ -144,7 +133,7 @@ const getCurrentWorkflowJobs = (runId) => { try { resolve(parseGitHubJsonResponse({ body, - endpoint: `${GET_WORKFLOWS_URL}/${runId}/jobs`, + endpoint, res, })) } catch (e) { @@ -159,70 +148,121 @@ const getCurrentWorkflowJobs = (runId) => { }) } -async function main () { - // Trigger JS GHA - console.log('Triggering Test Optimization test environment workflow.') - const httpResponseCode = await triggerWorkflow() - console.log('GitHub API response code:', httpResponseCode) +/** + * Waits for the current workflow attempt to finish. + * + * @param {number} runId + * @param {string} workflowUrl + * @param {number} workflowDeadline + * @param {number} [minimumRunAttempt] + * @returns {Promise<{ conclusion: string, run_attempt: number }>} + */ +async function waitForWorkflowCompletion ( + runId, + workflowUrl, + workflowDeadline, + minimumRunAttempt = INITIAL_RUN_ATTEMPT +) { + for (let poll = 0; poll < MAX_WORKFLOW_POLLS; poll++) { + if (Date.now() > workflowDeadline) { + break + } - if (httpResponseCode !== 204) { - throw new Error('Could not trigger workflow') + let currentWorkflow + try { + currentWorkflow = await getCurrentWorkflow(runId) + } catch (e) { + console.error('Workflow check failed (%s). Retry in 5 seconds.', e.message) + } + + if (currentWorkflow) { + const { conclusion, run_attempt: runAttempt, status } = currentWorkflow + if (runAttempt >= minimumRunAttempt && status === 'completed') { + return { conclusion, run_attempt: runAttempt } + } + + console.log( + `Workflow ${workflowUrl} is not finished yet. [Poll ${poll + 1}/${MAX_WORKFLOW_POLLS}]` + ) + } + + const waitMs = Math.min(POLL_INTERVAL_MS, workflowDeadline - Date.now()) + if (waitMs <= 0) { + break + } + await setTimeout(waitMs) } - // Give some time for GH to process the request - await setTimeout(15000) + throw new Error( + `Timeout: Workflow did not finish within 30 minutes. Check ${workflowUrl} for more details.` + ) +} - // Get the run ID from the workflow we just triggered - const workflowsInProgress = await getWorkflowRunsInProgress() - const { total_count: numWorkflows, workflow_runs: workflows } = workflowsInProgress - if (numWorkflows === 0) { - throw new Error('Could not find the triggered workflow') +/** + * Waits for GitHub to create the single automatic retry allowed for a failed workflow. + * + * @param {number} runId + * @param {number} failedRunAttempt + * @param {number} workflowDeadline + * @returns {Promise} + */ +async function waitForWorkflowRetry (runId, failedRunAttempt, workflowDeadline) { + const retryDeadline = Math.min(Date.now() + RETRY_GRACE_PERIOD_MS, workflowDeadline) + for (let poll = 0; poll <= RETRY_GRACE_PERIOD_POLLS; poll++) { + if (Date.now() > retryDeadline) { + break + } + + try { + const { run_attempt: currentRunAttempt } = await getCurrentWorkflow(runId) + if (currentRunAttempt > failedRunAttempt) { + return currentRunAttempt + } + } catch (e) { + console.error('Workflow retry check failed (%s). Retry in 5 seconds.', e.message) + } + const waitMs = Math.min(POLL_INTERVAL_MS, retryDeadline - Date.now()) + if (poll < RETRY_GRACE_PERIOD_POLLS && waitMs > 0) { + await setTimeout(waitMs) + } } - // Pick the first one (most recently triggered one) - const [triggeredWorkflow] = workflows + return undefined +} + +async function main () { + // Trigger JS GHA + console.log('Triggering Test Optimization test environment workflow.') + const triggeredWorkflow = await triggerWorkflow() console.log('Triggered workflow:', triggeredWorkflow) - const { id: runId } = triggeredWorkflow || {} + const { workflow_run_id: runId } = triggeredWorkflow + if (!runId) { + throw new Error('Triggered workflow response did not include a run id') + } - console.log(`Workflow URL: https://github.com/DataDog/test-environment/actions/runs/${runId}`) + const workflowUrl = `https://github.com/DataDog/test-environment/actions/runs/${runId}` + console.log(`Workflow URL: ${workflowUrl}`) // Wait an initial 1 minute, because we're sure it won't finish earlier await setTimeout(60000) - // Poll every 5 seconds until we have a finished status, up to 30 minutes - for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - let currentWorkflow - try { - currentWorkflow = await getCurrentWorkflowJobs(runId) - } catch (e) { - console.error('Workflow check failed (%s). Retry in 5 seconds.', e.message) - await setTimeout(5000) - continue - } - const { jobs } = currentWorkflow - if (!jobs) { - console.error('Workflow check returned unknown object %o. Retry in 5 seconds.', currentWorkflow) - await setTimeout(5000) - continue - } - const hasAnyJobFailed = jobs - .some(({ status, conclusion }) => status === 'completed' && conclusion !== 'success') - const hasEveryJobPassed = jobs.every( - ({ status, conclusion }) => status === 'completed' && conclusion === 'success' - ) - if (hasAnyJobFailed) { - throw new Error(`Performance overhead test failed.\n Check https://github.com/DataDog/test-environment/actions/runs/${runId} for more details.`) - } else if (hasEveryJobPassed) { - console.log('Performance overhead test successful.') - break - } else { - console.log(`Workflow https://github.com/DataDog/test-environment/actions/runs/${runId} is not finished yet. [Attempt ${attempt + 1}/${MAX_ATTEMPTS}]`) - } - if (attempt === MAX_ATTEMPTS - 1) { - throw new Error(`Timeout: Workflow did not finish within 30 minutes. Check https://github.com/DataDog/test-environment/actions/runs/${runId} for more details.`) + const workflowDeadline = Date.now() + MAX_WORKFLOW_WAIT_MS + let currentWorkflow = await waitForWorkflowCompletion(runId, workflowUrl, workflowDeadline) + if (currentWorkflow.conclusion !== 'success' && currentWorkflow.run_attempt === INITIAL_RUN_ATTEMPT) { + console.log('Workflow attempt %d failed. Waiting up to 1 minute for an automatic retry.', + currentWorkflow.run_attempt) + const retryRunAttempt = await waitForWorkflowRetry(runId, currentWorkflow.run_attempt, workflowDeadline) + if (retryRunAttempt !== undefined) { + currentWorkflow = await waitForWorkflowCompletion(runId, workflowUrl, workflowDeadline, retryRunAttempt) } - await setTimeout(5000) + } + + if (currentWorkflow.conclusion === 'success') { + console.log('Performance overhead test successful.') + } else { + const workflowAttemptUrl = `${workflowUrl}/attempts/${currentWorkflow.run_attempt}` + throw new Error(`Performance overhead test failed.\n Check ${workflowAttemptUrl} for more details.`) } } diff --git a/benchmark/e2e-test-optimization/benchmark-run.spec.js b/benchmark/e2e-test-optimization/benchmark-run.spec.js new file mode 100644 index 00000000000..36b8c338994 --- /dev/null +++ b/benchmark/e2e-test-optimization/benchmark-run.spec.js @@ -0,0 +1,379 @@ +'use strict' + +const assert = require('node:assert/strict') +const { EventEmitter } = require('node:events') + +const { afterEach, describe, it } = require('mocha') +const proxyquire = require('proxyquire') +const sinon = require('sinon') + +/** + * @typedef {object} ResponseData + * @property {string} body + * @property {number} statusCode + */ + +/** + * @typedef {object} RequestRecord + * @property {string} body + * @property {{ headers: Record }} options + * @property {string} url + */ + +/** + * @callback RequestStub + * @param {string} url + * @param {{ headers: Record }} options + * @param {(response: EventEmitter & { + * headers: Record, + * statusCode: number + * }) => void} callback + * @returns {EventEmitter & { + * end: () => void, + * write: (chunk: string) => void + * }} + */ + +describe('test optimization end-to-end benchmark runner', () => { + const originalEnvironment = { + githubToken: process.env.GITHUB_TOKEN, + refName: process.env.TEST_ENVIRONMENT_REF_NAME, + refToTest: process.env.TEST_ENVIRONMENT_REF_TO_TEST, + } + const originalExitCode = process.exitCode + + afterEach(() => { + restoreEnvironmentVariable('GITHUB_TOKEN', originalEnvironment.githubToken) + restoreEnvironmentVariable('TEST_ENVIRONMENT_REF_NAME', originalEnvironment.refName) + restoreEnvironmentVariable('TEST_ENVIRONMENT_REF_TO_TEST', originalEnvironment.refToTest) + process.exitCode = originalExitCode + sinon.restore() + }) + + it('polls the workflow run returned by the dispatch request until it succeeds', async () => { + process.env.GITHUB_TOKEN = 'token' + process.env.TEST_ENVIRONMENT_REF_NAME = 'feature-branch' + process.env.TEST_ENVIRONMENT_REF_TO_TEST = 'abc123' + + const responses = [ + createDispatchResponse(), + createWorkflowResponse(undefined, 1, 'in_progress'), + createWorkflowResponse('success', 1), + ] + const requests = [] + const completion = waitForSuccessfulCompletion() + + proxyquire('./benchmark-run', { + https: { + request: createRequestStub(responses, requests), + }, + 'timers/promises': { + setTimeout: () => Promise.resolve(), + }, + }) + + await completion + + assert.strictEqual(requests.length, 3) + assert.strictEqual(requests[0].url, + 'https://api.github.com/repos/DataDog/test-environment/actions/workflows/dd-trace-js-tests.yml/dispatches') + assert.strictEqual(requests[0].options.headers['X-GitHub-Api-Version'], '2026-03-10') + assert.deepStrictEqual(JSON.parse(requests[0].body), { + inputs: { sha: 'abc123' }, + ref: 'main', + }) + assert.strictEqual(requests[1].url, + 'https://api.github.com/repos/DataDog/test-environment/actions/runs/12345') + assert.strictEqual(requests[2].url, + 'https://api.github.com/repos/DataDog/test-environment/actions/runs/12345') + }) + + it('accepts a successful automatic retry after the first attempt fails', async () => { + process.env.GITHUB_TOKEN = 'token' + process.env.TEST_ENVIRONMENT_REF_TO_TEST = 'abc123' + + const responses = [ + createDispatchResponse(), + createWorkflowResponse('failure', 1), + createWorkflowResponse(undefined, 2, 'in_progress'), + createWorkflowResponse('failure', 1), + createWorkflowResponse('success', 2), + ] + const requests = [] + const completion = waitForSuccessfulCompletion() + + proxyquire('./benchmark-run', { + https: { + request: createRequestStub(responses, requests), + }, + 'timers/promises': { + setTimeout: () => Promise.resolve(), + }, + }) + + await completion + + assert.strictEqual(requests.length, 5) + assert.deepStrictEqual(requests.slice(1).map(({ url }) => url), [ + 'https://api.github.com/repos/DataDog/test-environment/actions/runs/12345', + 'https://api.github.com/repos/DataDog/test-environment/actions/runs/12345', + 'https://api.github.com/repos/DataDog/test-environment/actions/runs/12345', + 'https://api.github.com/repos/DataDog/test-environment/actions/runs/12345', + ]) + }) + + it('reports a failed retry using the retry-specific URL', async () => { + process.env.GITHUB_TOKEN = 'token' + process.env.TEST_ENVIRONMENT_REF_TO_TEST = 'abc123' + + const responses = [ + createDispatchResponse(), + createWorkflowResponse('failure', 1), + createWorkflowResponse(undefined, 2, 'in_progress'), + createWorkflowResponse('failure', 2), + ] + const requests = [] + const completion = waitForFailedCompletion() + + proxyquire('./benchmark-run', { + https: { + request: createRequestStub(responses, requests), + }, + 'timers/promises': { + setTimeout: () => Promise.resolve(), + }, + }) + + const error = await completion + + assert.strictEqual(error.message, + 'Performance overhead test failed.\n' + + ' Check https://github.com/DataDog/test-environment/actions/runs/12345/attempts/2 for more details.') + assert.strictEqual(requests.length, 4) + }) + + it('does not wait for another retry when the second attempt already failed', async () => { + process.env.GITHUB_TOKEN = 'token' + process.env.TEST_ENVIRONMENT_REF_TO_TEST = 'abc123' + + const responses = [ + createDispatchResponse(), + createWorkflowResponse('failure', 2), + ] + const requests = [] + const completion = waitForFailedCompletion() + + proxyquire('./benchmark-run', { + https: { + request: createRequestStub(responses, requests), + }, + 'timers/promises': { + setTimeout: () => Promise.resolve(), + }, + }) + + const error = await completion + + assert.strictEqual(error.message, + 'Performance overhead test failed.\n' + + ' Check https://github.com/DataDog/test-environment/actions/runs/12345/attempts/2 for more details.') + assert.strictEqual(requests.length, 2) + }) + + it('accepts a retry that starts at the end of the grace period', async () => { + process.env.GITHUB_TOKEN = 'token' + process.env.TEST_ENVIRONMENT_REF_TO_TEST = 'abc123' + + const failedAttemptResponse = createWorkflowResponse('failure', 1) + const responses = [ + createDispatchResponse(), + failedAttemptResponse, + ...Array.from({ length: 12 }, () => failedAttemptResponse), + createWorkflowResponse(undefined, 2, 'in_progress'), + createWorkflowResponse('success', 2), + ] + const requests = [] + const completion = waitForSuccessfulCompletion() + + proxyquire('./benchmark-run', { + https: { + request: createRequestStub(responses, requests), + }, + 'timers/promises': { + setTimeout: () => Promise.resolve(), + }, + }) + + await completion + + assert.strictEqual(requests.length, 16) + }) + + it('reports the first failure when no retry starts within the grace period', async () => { + process.env.GITHUB_TOKEN = 'token' + process.env.TEST_ENVIRONMENT_REF_TO_TEST = 'abc123' + + const failedAttemptResponse = createWorkflowResponse('failure', 1) + const responses = [ + createDispatchResponse(), + failedAttemptResponse, + ...Array.from({ length: 13 }, () => failedAttemptResponse), + ] + const requests = [] + const completion = waitForFailedCompletion() + + proxyquire('./benchmark-run', { + https: { + request: createRequestStub(responses, requests), + }, + 'timers/promises': { + setTimeout: () => Promise.resolve(), + }, + }) + + const error = await completion + + assert.strictEqual(error.message, + 'Performance overhead test failed.\n' + + ' Check https://github.com/DataDog/test-environment/actions/runs/12345/attempts/1 for more details.') + assert.strictEqual(requests.length, 15) + }) + + it('uses the original workflow deadline while waiting for a retry', async () => { + process.env.GITHUB_TOKEN = 'token' + process.env.TEST_ENVIRONMENT_REF_TO_TEST = 'abc123' + + const workflowTimeoutMs = 30 * 60 * 1000 + sinon.stub(Date, 'now') + .onCall(0).returns(0) + .onCall(1).returns(0) + .onCall(2).returns(workflowTimeoutMs - 1000) + .onCall(3).returns(workflowTimeoutMs - 1000) + .onCall(4).returns(workflowTimeoutMs + 1) + + const responses = [ + createDispatchResponse(), + createWorkflowResponse('failure', 1), + createWorkflowResponse(undefined, 2, 'in_progress'), + ] + const requests = [] + const completion = waitForFailedCompletion() + + proxyquire('./benchmark-run', { + https: { + request: createRequestStub(responses, requests), + }, + 'timers/promises': { + setTimeout: () => Promise.resolve(), + }, + }) + + const error = await completion + + assert.strictEqual(error.message, + 'Timeout: Workflow did not finish within 30 minutes. ' + + 'Check https://github.com/DataDog/test-environment/actions/runs/12345 for more details.') + assert.strictEqual(requests.length, 3) + }) +}) + +/** + * @returns {ResponseData} + */ +function createDispatchResponse () { + return { + body: JSON.stringify({ + workflow_run_id: 12345, + }), + statusCode: 200, + } +} + +/** + * @param {string|undefined} conclusion + * @param {number} runAttempt + * @param {string} [status] + * @returns {ResponseData} + */ +function createWorkflowResponse (conclusion, runAttempt, status = 'completed') { + return { + body: JSON.stringify({ + conclusion, + run_attempt: runAttempt, + status, + }), + statusCode: 200, + } +} + +/** + * @param {ResponseData[]} responses + * @param {RequestRecord[]} requests + * @returns {RequestStub} + */ +function createRequestStub (responses, requests) { + return (url, options, callback) => { + const request = new EventEmitter() + let body = '' + + request.write = chunk => { + body += chunk + } + request.end = () => { + requests.push({ body, options, url }) + const responseData = responses.shift() + const response = new EventEmitter() + response.headers = { 'content-type': 'application/json; charset=utf-8' } + response.statusCode = responseData.statusCode + callback(response) + process.nextTick(() => { + response.emit('data', responseData.body) + response.emit('end') + }) + } + return request + } +} + +/** + * @returns {Promise} + */ +function waitForSuccessfulCompletion () { + return new Promise((resolve, reject) => { + sinon.stub(console, 'log').callsFake(message => { + if (message === 'Performance overhead test successful.') { + resolve() + } + }) + sinon.stub(console, 'error').callsFake(error => { + reject(error) + }) + }) +} + +/** + * @returns {Promise} + */ +function waitForFailedCompletion () { + return new Promise((resolve) => { + sinon.stub(console, 'log') + sinon.stub(console, 'error').callsFake(error => { + if (error instanceof Error) { + resolve(error) + } + }) + }) +} + +/** + * @param {string} name + * @param {string|undefined} value + * @returns {void} + */ +function restoreEnvironmentVariable (name, value) { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } +} diff --git a/benchmark/sirun/propagation/index.js b/benchmark/sirun/propagation/index.js index 5a5b9df830c..da6899c9cc7 100644 --- a/benchmark/sirun/propagation/index.js +++ b/benchmark/sirun/propagation/index.js @@ -19,11 +19,11 @@ const propagator = new TextMapPropagator({ extract: ['datadog', 'tracecontext', 'baggage'], inject: ['datadog', 'tracecontext', 'baggage'], }, - legacyBaggageEnabled: false, + legacyBaggageEnabled: true, baggageMaxItems: 64, baggageMaxBytes: 8192, DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH: 512, - tracePropagationExtractFirst: false, + DD_TRACE_PROPAGATION_EXTRACT_FIRST: false, DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT: 'continue', baggageTagKeys: ['user.id', 'session.id', 'account.id'], }) diff --git a/benchmark/sirun/propagation/meta.json b/benchmark/sirun/propagation/meta.json index 9c8cfe20f7e..4bf53050952 100644 --- a/benchmark/sirun/propagation/meta.json +++ b/benchmark/sirun/propagation/meta.json @@ -13,9 +13,12 @@ } }, "inject": { + "run": "node --predictable-gc-schedule index.js", + "run_with_affinity": "bash -c \"taskset -c $CPU_AFFINITY node --predictable-gc-schedule index.js\"", + "iterations": 10, "env": { "VARIANT": "inject", - "OPERATIONS": "300000" + "OPERATIONS": "3000000" } }, "extract-baggage-percent": { diff --git a/ci/test-optimization-validation/framework-adapters/cypress.js b/ci/test-optimization-validation/framework-adapters/cypress.js index 345b05c5ca1..7cdd4e507d9 100644 --- a/ci/test-optimization-validation/framework-adapters/cypress.js +++ b/ci/test-optimization-validation/framework-adapters/cypress.js @@ -40,20 +40,18 @@ function getTestExtension (filename) { * @returns {string} canonical generated Cypress source */ function getGeneratedTestContent ({ scenarioId, testName }) { - const lines = [] + let content = '' if (scenarioId === 'atr-fail-once') { - lines.push('let attempt = 0', '') + content = 'let attempt = 0\n\n' } - lines.push( - "describe('dd-test-optimization-validation', () => {", - ` it(${JSON.stringify(testName)}, () => {`, - scenarioId === 'atr-fail-once' - ? ' expect(attempt++).to.equal(1)' - : ' expect(true).to.equal(true)', - ' })', + content += "describe('dd-test-optimization-validation', () => {\n" + + ` it(${JSON.stringify(testName)}, () => {\n` + + (scenarioId === 'atr-fail-once' + ? ' expect(attempt++).to.equal(1)\n' + : ' expect(true).to.equal(true)\n') + + ' })\n' + '})' - ) - return lines.join('\n') + return content } /** diff --git a/ci/test-optimization-validation/scenarios/ci-wiring.js b/ci/test-optimization-validation/scenarios/ci-wiring.js index 02982aea6aa..f72851eb05b 100644 --- a/ci/test-optimization-validation/scenarios/ci-wiring.js +++ b/ci/test-optimization-validation/scenarios/ci-wiring.js @@ -184,12 +184,13 @@ function runCiWiring ({ manifest, framework, projectFileSources }) { } if (resolution.status !== 'confirmed') { - const visibleFacts = [] + let visibleFacts = '' if (initialization.status === 'missing') { - visibleFacts.push('The selected job has no visible dd-trace/ci/init preload.') + visibleFacts = 'The selected job has no visible dd-trace/ci/init preload.' } if (ciFacts.transport.status === 'missing') { - visibleFacts.push('The recorded review found no visible Datadog Agent or agentless reporting transport.') + if (visibleFacts) visibleFacts += ' ' + visibleFacts += 'The recorded review found no visible Datadog Agent or agentless reporting transport.' } const recommendation = /working directory/i.test(resolution.reason) ? 'Keep the CI job\'s actual working directory. Resolve the repository-root wrapper to the selected test ' + @@ -199,7 +200,7 @@ function runCiWiring ({ manifest, framework, projectFileSources }) { return getIncomplete( framework, 'The CI audit remains incomplete because the selected command could not be resolved to the ' + - `${framework.framework} runner: ${resolution.reason}. ${visibleFacts.join(' ')}`.trim(), + `${framework.framework} runner: ${resolution.reason}. ${visibleFacts}`.trim(), { ...evidence, recommendation, diff --git a/docs/API.md b/docs/API.md index 7c600eafa51..0a3e31f37ff 100644 --- a/docs/API.md +++ b/docs/API.md @@ -6,6 +6,20 @@ This is the API documentation for the Datadog JavaScript Tracer. If you are just The module exported by this library is an instance of the [Tracer](./interfaces/tracer.html) class. +

LLM Observability Experiments

+ +LLM Observability Experiments use a project name separate from the ML app name. Configure the default Experiments project when initializing the tracer: + +```javascript +const tracer = require('dd-trace').init({ + llmobs: { + projectName: 'experiments-project' + } +}) +``` + +The equivalent environment variable is `DD_LLMOBS_PROJECT_NAME`. If no project name is configured, Experiments uses `default-project`. The `mlApp` and `service` settings are not used as Experiments project-name fallbacks. Dataset and experiment operations can override the default with an operation-level `projectName` option, for example `experiments.createDataset(name, { projectName: 'other-project' })` or `experiments.experiment({ projectName: 'other-project', ... })`. +

Automatic Instrumentation

APM provides out-of-the-box instrumentation for many popular frameworks and libraries by using a plugin system. By default, all built-in plugins are enabled. Disabling plugins can cause unexpected side effects, so it is highly recommended to leave them enabled. @@ -21,11 +35,13 @@ tracer.use('pg', { }) ``` -The `langchain` and `modelcontextprotocol-sdk` integrations accept an `llmobs` option. Setting it to `false` stops LLM Observability span capture for that integration only — APM spans and distributed trace context propagation are unaffected. This is useful when another enabled integration already captures the same operation and the input/output payloads would otherwise be stored twice: +LLM Observability integrations accept an `llmobs` option. Setting it to `false` stops LLM Observability span capture for that integration only — APM spans and distributed trace context propagation are unaffected. This is useful when another enabled integration already captures the same operation and the input/output payloads would otherwise be stored twice. + +The option is supported by `ai`, `anthropic`, `aws-sdk` (Bedrock Runtime only), `claude-agent-sdk`, `google-cloud-vertexai`, `google-genai`, `langchain`, `langgraph`, `modelcontextprotocol-sdk`, `openai`, and `openai-agents`. ```javascript -// Keep APM tracing for MCP, but let LangChain own the LLM Observability spans. -tracer.use('modelcontextprotocol-sdk', { +// Keep APM tracing for OpenAI, but let another integration own the LLM Observability spans. +tracer.use('openai', { llmobs: false }) ``` @@ -274,6 +290,25 @@ async function handle () { Any error from the awaited handler will automatically be added to the span. +

Recording handled exceptions

+ +Use `span.recordException()` to add a handled exception as an event without marking the span as failed. + +```javascript +tracer.trace('checkout', span => { + try { + authorizePayment() + } catch (error) { + span.recordException(error, { + handled: true, + 'payment.provider': 'example', + }) + } +}) +``` + +If the exception leaves the traced callback, `tracer.trace()` records it as a span error automatically. +

tracer.wrap(name[, options], fn)

This method works very similarly to `tracer.trace()` except it wraps a function so that `tracer.trace()` is called automatically every time the function is called. This makes it easier to patch entire functions that have already been defined, or that are returned from code that cannot be edited easily. diff --git a/docs/test.ts b/docs/test.ts index bf55268856e..49e80a27844 100644 --- a/docs/test.ts +++ b/docs/test.ts @@ -294,12 +294,16 @@ const openSearchOptions: plugins.opensearch = { }; tracer.use('ai', true) +tracer.use('ai', { llmobs: false }) tracer.use('amqp10'); tracer.use('amqplib'); tracer.use('anthropic'); +tracer.use('anthropic', { llmobs: false }); tracer.use('claude-agent-sdk'); +tracer.use('claude-agent-sdk', { llmobs: false }); tracer.use('avsc'); tracer.use('aws-sdk'); +tracer.use('aws-sdk', { llmobs: false }); tracer.use('aws-sdk', awsSdkOptions); tracer.use('aws-sdk', awsSdkServiceFunctionOptions); tracer.use('azure-cosmos'); @@ -331,7 +335,9 @@ tracer.use('fetch'); tracer.use('fetch', httpClientOptions); tracer.use('google-cloud-pubsub'); tracer.use('google-cloud-vertexai'); +tracer.use('google-cloud-vertexai', { llmobs: false }); tracer.use('google-genai'); +tracer.use('google-genai', { llmobs: false }); tracer.use('graphql'); tracer.use('graphql', graphqlOptions); tracer.use('graphql', { variables: ['foo', 'bar'] }); @@ -376,6 +382,7 @@ tracer.use('langchain'); tracer.use('langchain', { llmobs: false }); tracer.use('mariadb', { service: () => `my-custom-mariadb` }) tracer.use('langgraph'); +tracer.use('langgraph', { llmobs: false }); tracer.use('memcached'); tracer.use('microgateway-core'); tracer.use('microgateway-core', httpServerOptions); @@ -395,6 +402,8 @@ tracer.use('net'); tracer.use('next'); tracer.use('next', nextOptions); tracer.use('openai-agents'); +tracer.use('openai-agents', { llmobs: false }); +tracer.use('openai', { llmobs: false }); tracer.use('opensearch'); tracer.use('opensearch', openSearchOptions); tracer.use('oracledb'); @@ -450,6 +459,13 @@ span = tracer.startSpan('test', { }); span = tracer.startSpan('test', { childOf: null }) span = tracer.startSpan('test', { integrationName: 'testIntegration' }) +span.recordException(new Error('payment declined'), { + handled: true, + attempt: 1, + stages: ['authorize', 'capture'] +}) +// @ts-expect-error Span event attribute arrays must be homogeneous. +span.recordException(new Error('payment declined'), { stages: ['authorize', 1] }) tracer.trace('test', () => { }) tracer.trace('test', { tags: { foo: 'bar' } }, () => { }) @@ -783,6 +799,7 @@ tracer.init({ endpoint: 'http://localhost', maxMessagesLength: 22, maxContentSize: 1024, + redactionEnabled: true, timeout: 1000 } } @@ -796,6 +813,14 @@ aiguard.evaluate([ result.action && result.reason && result.tags }) +aiguard.evaluate([{ + role: 'user', + content: [ + { type: 'input_text', text: 'Describe this image' }, + { type: 'input_image', image_url: { url: 'https://example.com/image.png' } }, + ], +}]) + aiguard.evaluate([ { role: 'assistant', @@ -807,7 +832,7 @@ aiguard.evaluate([ ], } ]).then(result => { - result.action && result.reason && result.tags && result.tagProbabilities && result.sds + result.action && result.reason && result.tags && result.tagProbabilities && result.sds && result.messages }) aiguard.evaluate([ @@ -815,3 +840,10 @@ aiguard.evaluate([ ]).then(result => { result.action && result.reason && result.tags && result.tagProbabilities && result.sds }) + +aiguard.evaluate([ + { role: 'user', content: 'My SSN is 123-45-6789' }, +]).then(result => { + const replacements: ddTrace.aiguard.RedactionReplacement[] = result.redactionReplacements + replacements.map(({ path, replacement }) => `${path}=${replacement}`) +}) diff --git a/eslint-rules/eslint-carrier-fields.mjs b/eslint-rules/eslint-carrier-fields.mjs index 3e8bae9860c..ec36ec2bb7c 100644 --- a/eslint-rules/eslint-carrier-fields.mjs +++ b/eslint-rules/eslint-carrier-fields.mjs @@ -6,6 +6,38 @@ const carrierSource = readFileSync(new URL('../packages/dd-trace/src/carrier.js' const { legacyBaggagePrefix, propagationHeaders } = parseCarrierModel(carrierSource) +/** + * @typedef {object} CarrierWrite + * @property {'write'} type + * @property {import('eslint').Scope.Variable} variable + * @property {import('estree').Node} expression + * @property {boolean} preserve + */ + +/** + * @typedef {object} CarrierCheck + * @property {'check' | 'return'} type + * @property {import('estree').Node} expression + * @property {import('estree').Node} node + */ + +/** + * @typedef {object} CarrierCall + * @property {'call'} type + * @property {import('estree').CallExpression} node + */ + +/** @typedef {CarrierWrite | CarrierCheck | CarrierCall} CarrierEvent */ + +/** + * @typedef {object} CodePathFrame + * @property {import('estree').Node} node + * @property {Set} currentSegments + * @property {Set} segments + * @property {Map} events + * @property {Map>} outputs + */ + /** * @param {import('estree').Identifier | import('estree').Expression | import('estree').Super} node * @returns {boolean} @@ -113,6 +145,9 @@ export default { const sourceCode = context.sourceCode const carrierFunctions = new Set() const carrierModuleIdentifiers = new Set() + const carrierReturningFunctions = new Set() + const codePathFrames = [] + const completedCodePathFrames = [] const strictCarrierIdentifiers = context.options[0]?.strictCarrierIdentifiers === true /** @@ -124,6 +159,248 @@ export default { (node.type === 'Identifier' && carrierModuleIdentifiers.has(node.name)) } + /** + * @param {import('estree').Node} node + * @returns {import('eslint').Scope.Variable | undefined} + */ + function findVariable (node) { + if (node.type !== 'Identifier') return + + let scope = sourceCode.getScope(node) + while (scope) { + const variable = scope.set.get(node.name) + if (variable) return variable + scope = scope.upper + } + } + + /** + * @param {import('estree').Node} node + * @param {Set} taintedVariables + * @param {Set} [seenVariables] + * @returns {boolean} + */ + function isCarrierReference (node, taintedVariables, seenVariables) { + if (isCarrierIdentifier(node)) return true + + if (node.type === 'LogicalExpression') { + return isCarrierReference(node.left, taintedVariables, seenVariables) || + isCarrierReference(node.right, taintedVariables, seenVariables) + } + if (node.type === 'ConditionalExpression') { + return isCarrierReference(node.consequent, taintedVariables, seenVariables) || + isCarrierReference(node.alternate, taintedVariables, seenVariables) + } + if (node.type === 'CallExpression') { + const localFunction = findLocalFunction(node.callee) + return localFunction !== undefined && carrierReturningFunctions.has(localFunction) + } + const variable = findVariable(node) + if (variable === undefined) return false + if (taintedVariables.has(variable)) return true + if (seenVariables?.has(variable) || variable.defs.length !== 1) return false + + const [definition] = variable.defs + if (definition.type !== 'Variable' || definition.parent.kind !== 'const' || + definition.node.id.type !== 'Identifier' || !definition.node.init) return false + + seenVariables ??= new Set() + seenVariables.add(variable) + return isCarrierReference(definition.node.init, taintedVariables, seenVariables) + } + + /** + * @param {CarrierEvent} event + * @returns {void} + */ + function recordCodePathEvent (event) { + const codePathFrame = codePathFrames[codePathFrames.length - 1] + for (const segment of codePathFrame.currentSegments) { + let events = codePathFrame.events.get(segment) + if (!events) { + events = [] + codePathFrame.events.set(segment, events) + } + events.push(event) + } + } + + /** + * @param {import('estree').Node} expression + * @param {import('estree').Node} node + * @returns {void} + */ + function recordCarrierCheck (expression, node) { + if (strictCarrierIdentifiers) recordCodePathEvent({ type: 'check', expression, node }) + } + + /** + * @param {CarrierWrite} event + * @param {Set} taintedVariables + * @returns {void} + */ + function applyCarrierWrite (event, taintedVariables) { + const tainted = (event.preserve && taintedVariables.has(event.variable)) || + isCarrierReference(event.expression, taintedVariables) + if (tainted) { + taintedVariables.add(event.variable) + } else { + taintedVariables.delete(event.variable) + } + } + + /** + * @param {import('eslint').CodePathSegment} segment + * @param {Map>} outputs + * @returns {Set} + */ + function getCodePathInput (segment, outputs) { + const taintedVariables = new Set() + const previousSegments = segment.reachable ? segment.prevSegments : segment.allPrevSegments + for (const previous of previousSegments) { + const output = outputs.get(previous) + if (!output) continue + for (const variable of output) taintedVariables.add(variable) + } + return taintedVariables + } + + /** + * @param {import('estree').CallExpression} node + * @param {Set} taintedVariables + * @returns {void} + */ + function checkCarrierCall (node, taintedVariables) { + if (node.callee.type === 'Identifier' && + (carrierFunctions.has(node.callee.name) || + isCheckedLocalCarrierFunction(node.callee, node.arguments, taintedVariables))) return + + for (const argument of node.arguments) { + if (argument.type !== 'SpreadElement' && isCarrierReference(argument, taintedVariables)) { + report(argument, 'noDirectCarrierAccess') + } + } + } + + /** + * @param {CodePathFrame} frame + * @returns {void} + */ + function computeCodePathOutputs (frame) { + const { outputs } = frame + let changed + do { + changed = false + for (const segment of frame.segments) { + const taintedVariables = getCodePathInput(segment, outputs) + for (const event of frame.events.get(segment) || []) { + if (event.type === 'write') applyCarrierWrite(event, taintedVariables) + } + // Outputs grow monotonically, so unchanged cardinality means unchanged membership. + if (outputs.get(segment)?.size !== taintedVariables.size) { + outputs.set(segment, taintedVariables) + changed = true + } + } + } while (changed) + } + + /** + * @param {CodePathFrame} frame + * @returns {void} + */ + function recordCarrierReturningFunctions (frame) { + if (carrierReturningFunctions.has(frame.node)) return + + for (const segment of frame.segments) { + const taintedVariables = getCodePathInput(segment, frame.outputs) + for (const event of frame.events.get(segment) || []) { + if (event.type === 'write') { + applyCarrierWrite(event, taintedVariables) + } else if (event.type === 'return' && isCarrierReference(event.expression, taintedVariables)) { + carrierReturningFunctions.add(frame.node) + return + } + } + } + } + + /** + * @param {CodePathFrame} frame + * @returns {void} + */ + function checkCodePath (frame) { + for (const segment of frame.segments) { + const taintedVariables = getCodePathInput(segment, frame.outputs) + for (const event of frame.events.get(segment) || []) { + if (event.type === 'write') { + applyCarrierWrite(event, taintedVariables) + } else if (event.type === 'check' && isCarrierReference(event.expression, taintedVariables)) { + report(event.node, 'noDirectCarrierAccess') + } else if (event.type === 'call') { + checkCarrierCall(event.node, taintedVariables) + } + } + } + } + + function analyzeCodePaths () { + for (const frame of completedCodePathFrames) computeCodePathOutputs(frame) + + let returningFunctionCount + do { + returningFunctionCount = carrierReturningFunctions.size + for (const frame of completedCodePathFrames) recordCarrierReturningFunctions(frame) + if (carrierReturningFunctions.size !== returningFunctionCount) { + for (const frame of completedCodePathFrames) computeCodePathOutputs(frame) + } + } while (carrierReturningFunctions.size !== returningFunctionCount) + + for (const frame of completedCodePathFrames) checkCodePath(frame) + } + + /** + * @param {import('estree').Node} node + * @returns {import('estree').FunctionDeclaration | import('estree').FunctionExpression | + * import('estree').ArrowFunctionExpression | undefined} + */ + function findLocalFunction (node) { + const variable = findVariable(node) + if (variable === undefined || variable.defs.length !== 1) return + for (const reference of variable.references) { + if (!reference.init && reference.isWrite()) return + } + + const [definition] = variable.defs + if (definition.type === 'FunctionName') return definition.node + + const { init } = definition.node + if (init?.type === 'FunctionExpression' || init?.type === 'ArrowFunctionExpression') return init + } + + /** + * Local functions are checked by this same rule, so carriers can + * safely pass through carrier-named parameters unless the binding is reassigned. + * + * @param {import('estree').Identifier} callee + * @param {Array} callArguments + * @param {Set} taintedVariables + * @returns {boolean} + */ + function isCheckedLocalCarrierFunction (callee, callArguments, taintedVariables) { + const localFunction = findLocalFunction(callee) + if (localFunction === undefined || carrierReturningFunctions.has(localFunction)) return false + + const parameters = localFunction.params + for (let index = 0; index < callArguments.length; index++) { + const argument = callArguments[index] + if (argument.type === 'SpreadElement' || !isCarrierReference(argument, taintedVariables)) continue + const parameter = parameters[index] + if (parameter?.type !== 'Identifier' || !isCarrierIdentifier(parameter)) return false + } + return true + } + /** * @param {import('estree').Node} node * @param {Set} [seen] @@ -210,26 +487,24 @@ export default { * @returns {boolean} */ function checkObjectPattern (pattern, target) { - let reported = false + let handled = false for (const property of pattern.properties) { if (property.type === 'RestElement') { - if (strictCarrierIdentifiers && isCarrierIdentifier(target)) { - report(property, 'noDirectCarrierAccess') - reported = true - } + recordCarrierCheck(target, property) + handled = strictCarrierIdentifiers continue } const name = getPropertyName(property, resolveString) if (name && isManagedHeaderAccess(name, target)) { report(property, 'useCarrierField') - reported = true - } else if (strictCarrierIdentifiers && isCarrierIdentifier(target)) { - report(property, 'noDirectCarrierAccess') - reported = true + handled = true + } else if (strictCarrierIdentifiers) { + recordCarrierCheck(target, property) + handled = true } } - return reported + return handled } /** @@ -242,18 +517,75 @@ export default { } return { + /** + * @param {import('eslint').CodePath} codePath + * @param {import('estree').Node} node + * @returns {void} + */ + onCodePathStart (codePath, node) { + codePathFrames.push({ + node, + currentSegments: new Set(), + segments: new Set([codePath.initialSegment]), + events: new Map(), + outputs: new Map(), + }) + }, + + onCodePathEnd () { + const frame = codePathFrames.pop() + if (!strictCarrierIdentifiers) return + completedCodePathFrames.push(frame) + if (frame.node.type === 'Program') analyzeCodePaths() + }, + + /** + * @param {import('eslint').CodePathSegment} segment + * @returns {void} + */ + onCodePathSegmentStart (segment) { + const codePathFrame = codePathFrames[codePathFrames.length - 1] + codePathFrame.currentSegments.add(segment) + codePathFrame.segments.add(segment) + }, + + /** + * @param {import('eslint').CodePathSegment} segment + * @returns {void} + */ + onCodePathSegmentEnd (segment) { + codePathFrames[codePathFrames.length - 1].currentSegments.delete(segment) + }, + + /** + * @param {import('eslint').CodePathSegment} segment + * @returns {void} + */ + onUnreachableCodePathSegmentStart (segment) { + const codePathFrame = codePathFrames[codePathFrames.length - 1] + codePathFrame.currentSegments.add(segment) + codePathFrame.segments.add(segment) + }, + + /** + * @param {import('eslint').CodePathSegment} segment + * @returns {void} + */ + onUnreachableCodePathSegmentEnd (segment) { + codePathFrames[codePathFrames.length - 1].currentSegments.delete(segment) + }, + /** * @param {import('estree').MemberExpression} node * @returns {void} */ - MemberExpression (node) { + 'MemberExpression:exit' (node) { const name = getMemberName(node, resolveString) - const carrierObject = isCarrierIdentifier(node.object) if (name && isManagedHeaderAccess(name, node.object)) { report(node, 'useCarrierField') - } else if (strictCarrierIdentifiers && carrierObject) { - report(node, 'noDirectCarrierAccess') + } else { + recordCarrierCheck(node.object, node) } }, @@ -272,14 +604,14 @@ export default { * @param {import('estree').BinaryExpression} node * @returns {void} */ - BinaryExpression (node) { + 'BinaryExpression:exit' (node) { if (node.operator !== 'in') return const name = resolveString(node.left) if (name && isManagedHeaderAccess(name, node.right)) { report(node, 'useCarrierField') - } else if (strictCarrierIdentifiers && isCarrierIdentifier(node.right)) { - report(node, 'noDirectCarrierAccess') + } else { + recordCarrierCheck(node.right, node) } }, @@ -287,7 +619,7 @@ export default { * @param {import('estree').CallExpression} node * @returns {void} */ - CallExpression (node) { + 'CallExpression:exit' (node) { const callsCarrierModuleMember = node.callee.type === 'MemberExpression' && isCarrierModuleReference(node.callee.object) if (callsCarrierModuleMember) { @@ -300,42 +632,43 @@ export default { const name = resolveString(reflectiveAccess.key) if (name && isManagedHeaderAccess(name, reflectiveAccess.target)) { report(node, 'useCarrierField') - } else if (strictCarrierIdentifiers && isCarrierIdentifier(reflectiveAccess.target)) { - report(node, 'noDirectCarrierAccess') + } else { + recordCarrierCheck(reflectiveAccess.target, node) } return } if (!strictCarrierIdentifiers || node.callee.type === 'Super') return if (node.callee.type === 'MemberExpression' && node.callee.object.type === 'ThisExpression') return - if (node.callee.type === 'Identifier' && carrierFunctions.has(node.callee.name)) return - - for (const argument of node.arguments) { - if (argument.type !== 'SpreadElement' && isCarrierIdentifier(argument)) { - report(argument, 'noDirectCarrierAccess') - } - } + recordCodePathEvent({ type: 'call', node }) }, /** * @param {import('estree').SpreadElement} node * @returns {void} */ - SpreadElement (node) { - if (strictCarrierIdentifiers && isCarrierIdentifier(node.argument)) { - report(node, 'noDirectCarrierAccess') - } + 'SpreadElement:exit' (node) { + recordCarrierCheck(node.argument, node) }, /** * @param {import('estree').AssignmentExpression} node * @returns {void} */ - AssignmentExpression (node) { - if (node.left.type !== 'ObjectPattern') return - const reported = checkObjectPattern(node.left, node.right) - if (!reported && strictCarrierIdentifiers && isCarrierIdentifier(node.right)) { - report(node, 'noDirectCarrierAccess') + 'AssignmentExpression:exit' (node) { + if (node.left.type === 'ObjectPattern') { + if (!checkObjectPattern(node.left, node.right)) recordCarrierCheck(node.right, node) + return + } + + const variable = findVariable(node.left) + if (variable) { + recordCodePathEvent({ + type: 'write', + variable, + expression: node.right, + preserve: node.operator === '||=' || node.operator === '??=', + }) } }, @@ -343,7 +676,7 @@ export default { * @param {import('estree').VariableDeclarator} node * @returns {void} */ - VariableDeclarator (node) { + 'VariableDeclarator:exit' (node) { if (node.id.type === 'Identifier' && node.init && isCarrierModuleRequire(node.init)) { carrierModuleIdentifiers.add(node.id.name) } @@ -361,12 +694,43 @@ export default { } recordCarrierFunctions(node) if (node.id.type === 'ObjectPattern' && node.init) { - const reported = checkObjectPattern(node.id, node.init) - if (!reported && strictCarrierIdentifiers && isCarrierIdentifier(node.init)) { - report(node, 'noDirectCarrierAccess') + if (!checkObjectPattern(node.id, node.init)) recordCarrierCheck(node.init, node) + } else if (node.id.type === 'Identifier' && node.init) { + const variable = findVariable(node.id) + if (variable) { + recordCodePathEvent({ type: 'write', variable, expression: node.init, preserve: false }) } } }, + + /** + * @param {import('estree').ArrowFunctionExpression} node + * @returns {void} + */ + 'ArrowFunctionExpression:exit' (node) { + if (node.body.type !== 'BlockStatement') { + recordCodePathEvent({ type: 'return', expression: node.body, node }) + } + }, + + /** + * @param {import('estree').ReturnStatement} node + * @returns {void} + */ + 'ReturnStatement:exit' (node) { + if (node.argument) recordCodePathEvent({ type: 'return', expression: node.argument, node }) + }, + + /** + * @param {import('estree').UpdateExpression} node + * @returns {void} + */ + 'UpdateExpression:exit' (node) { + const variable = findVariable(node.argument) + if (variable) { + recordCodePathEvent({ type: 'write', variable, expression: node, preserve: false }) + } + }, } }, } diff --git a/eslint-rules/eslint-carrier-fields.test.mjs b/eslint-rules/eslint-carrier-fields.test.mjs index 25c02c60eb6..32d22a5081e 100644 --- a/eslint-rules/eslint-carrier-fields.test.mjs +++ b/eslint-rules/eslint-carrier-fields.test.mjs @@ -27,10 +27,77 @@ ruleTester.run('eslint-carrier-fields', rule, { code: 'function extract (carrier) { return this._extractDatadogContext(carrier) }', options: [{ strictCarrierIdentifiers: true }], }, + { + code: 'const { readDatadogTraceId } = require("../carrier"); ' + + 'function extractDatadog (carrier) { return readDatadogTraceId(carrier) } extractDatadog(carrier)', + options: [{ strictCarrierIdentifiers: true }], + }, + { + code: 'const { readDatadogTraceId } = require("../carrier"); ' + + 'const extractDatadog = carrier => readDatadogTraceId(carrier); extractDatadog(carrier)', + options: [{ strictCarrierIdentifiers: true }], + }, + { + code: 'const { readDatadogTraceId } = require("../carrier"); ' + + 'function extractDatadog (carrier) { function identity (carrier) { return carrier } ' + + 'return readDatadogTraceId(carrier) } extractDatadog(carrier)', + options: [{ strictCarrierIdentifiers: true }], + }, { code: 'function extract (carrier) { if (carrier === null) return }', options: [{ strictCarrierIdentifiers: true }], }, + { + code: 'let value = carrier; value = input; readSingleton(value, key)', + options: [{ strictCarrierIdentifiers: true }], + }, + { + code: 'let value = carrier; if (condition) { value = input } else { value = {} } readSingleton(value, key)', + options: [{ strictCarrierIdentifiers: true }], + }, + { + code: 'let value = carrier; do { value = input } while (condition); readSingleton(value, key)', + options: [{ strictCarrierIdentifiers: true }], + }, + { + code: 'function extractDatadog (carrier) { let value = carrier; value = input; ' + + 'return readSingleton(value, key) } extractDatadog(carrier)', + options: [{ strictCarrierIdentifiers: true }], + }, + { + code: 'function extractDatadog (carrier) { let value = carrier; value = input; return value } ' + + 'extractDatadog(carrier)', + options: [{ strictCarrierIdentifiers: true }], + }, + { + code: 'function extract (carrier) { let value = carrier; const read = () => readSingleton(value, key); ' + + 'value = input; return read }', + options: [{ strictCarrierIdentifiers: true }], + }, + { + code: 'function extract (carrier) { const value = carrier; ' + + 'return () => { const value = input; return readSingleton(value, key) } }', + options: [{ strictCarrierIdentifiers: true }], + }, + { + code: 'const first = second; const second = first; readSingleton(first, key)', + options: [{ strictCarrierIdentifiers: true }], + }, + { + code: 'function outer () { function first () { return second() } ' + + 'function second () { return first() } sink(first()) }', + options: [{ strictCarrierIdentifiers: true }], + }, + { + code: 'function outer (carrier) { let get = () => carrier; get = () => input; sink(get()) }', + options: [{ strictCarrierIdentifiers: true }], + }, + { + code: 'let first = carrier; first += 1; let second = carrier; second++; ' + + 'let third = carrier; third &&= input; ' + + 'readSingleton(first, key); readSingleton(second, key); readSingleton(third, key)', + options: [{ strictCarrierIdentifiers: true }], + }, { code: 'carrier ??= {}', options: [{ strictCarrierIdentifiers: true }] }, { code: 'function inject (carrier) { return carrier }', options: [{ strictCarrierIdentifiers: true }] }, { code: 'channel.publish({ carrier })', options: [{ strictCarrierIdentifiers: true }] }, @@ -195,6 +262,186 @@ ruleTester.run('eslint-carrier-fields', rule, { options: [{ strictCarrierIdentifiers: true }], errors: [{ messageId: 'noDirectCarrierAccess' }], }, + { + code: 'function extractDatadog (carrier) { return carrier[key] } extractDatadog(carrier)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'function identity (carrier) { return carrier } const value = identity(carrier); readSingleton(value, key)', + options: [{ strictCarrierIdentifiers: true }], + errors: [ + { messageId: 'noDirectCarrierAccess', line: 1, column: 71, endColumn: 78 }, + { messageId: 'noDirectCarrierAccess', line: 1, column: 95, endColumn: 100 }, + ], + }, + { + code: 'function caller (carrier) { sink(identity(carrier)) } ' + + 'function identity (carrier) { return carrier } caller(carrier)', + options: [{ strictCarrierIdentifiers: true }], + errors: [ + { messageId: 'noDirectCarrierAccess', line: 1, column: 34, endColumn: 51 }, + { messageId: 'noDirectCarrierAccess', line: 1, column: 43, endColumn: 50 }, + ], + }, + { + code: 'function identity (carrier) { return carrier } ' + + 'function caller (carrier) { sink(identity(carrier)) } caller(carrier)', + options: [{ strictCarrierIdentifiers: true }], + errors: [ + { messageId: 'noDirectCarrierAccess', line: 1, column: 81, endColumn: 98 }, + { messageId: 'noDirectCarrierAccess', line: 1, column: 90, endColumn: 97 }, + ], + }, + { + code: 'function identity (carrier) { if (condition) return carrier } identity(carrier)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'function extractDatadog (carrier) {} extractDatadog = readSingleton; extractDatadog(carrier)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'function extractDatadog (value) { return value[key] } extractDatadog(carrier)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'function extractDatadog (carrier) { const value = carrier; return readSingleton(value, key) } ' + + 'extractDatadog(carrier)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'function extractDatadog (carrier) { let value; value = carrier; return readSingleton(value, key) } ' + + 'extractDatadog(carrier)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'function extract (carrier) { const value = carrier; return () => readSingleton(value, key) }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'function extract (carrier) { const value = carrier; return () => value[key] }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'function extract (carrier) { const first = carrier; const second = first; ' + + 'return () => readSingleton(second, key) }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'function outer (carrier) { function get () { return carrier } sink(get()) }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess', line: 1, column: 68, endColumn: 73 }], + }, + { + code: 'function outer (carrier) { function get () { return carrier } ' + + 'const value = get(); sink(value) }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess', line: 1, column: 89, endColumn: 94 }], + }, + { + code: 'function outer (carrier) { function first () { return second() } ' + + 'function second () { return carrier } sink(first()) }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess', line: 1, column: 109, endColumn: 116 }], + }, + { + code: 'function outer (carrier) { function second () { return carrier } ' + + 'function first () { return second() } sink(first()) }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess', line: 1, column: 109, endColumn: 116 }], + }, + { + code: 'function outer (carrier, condition) { function get () { return carrier } ' + + 'let value = get(); if (condition) sink(value) }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess', line: 1, column: 113, endColumn: 118 }], + }, + { + code: 'function outer (carrier) { const get = () => carrier; sink(get()) }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess', line: 1, column: 60, endColumn: 65 }], + }, + { + code: 'function outer (carrier) { let get = () => carrier; sink(get()) }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess', line: 1, column: 58, endColumn: 63 }], + }, + { + code: 'function outer (carrier) { const get = function () { return carrier }; sink(get()) }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess', line: 1, column: 77, endColumn: 82 }], + }, + { + code: 'function outer (carrier, condition) { function first () { return condition ? carrier : second() } ' + + 'function second () { return first() } sink(second()) }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess', line: 1, column: 142, endColumn: 150 }], + }, + { + code: 'let value = carrier; if (condition) value = input; readSingleton(value, key)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'let value = carrier; while (condition) value = input; readSingleton(value, key)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'let value = input; if (condition) value = carrier; readSingleton(value, key)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'let value = carrier; value ??= input; readSingleton(value, key)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'function extract (carrier) { return; const value = carrier; readSingleton(value, key) }', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'function identity (carrier) { let value = carrier; if (condition) value = input; return value } ' + + 'identity(carrier)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'const value = input || carrier; readSingleton(value, key)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'const value = condition ? carrier : input; readSingleton(value, key)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'const value = condition ? input : carrier; readSingleton(value, key)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'const extractDatadog = () => {}; extractDatadog(carrier)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, + { + code: 'function extractDatadog (carrier) {} extractDatadog(...carrier)', + options: [{ strictCarrierIdentifiers: true }], + errors: [{ messageId: 'noDirectCarrierAccess' }], + }, { code: 'header in carrier', options: [{ strictCarrierIdentifiers: true }], diff --git a/eslint-rules/eslint-no-unnecessary-array-join.mjs b/eslint-rules/eslint-no-unnecessary-array-join.mjs new file mode 100644 index 00000000000..2f58547f4e7 --- /dev/null +++ b/eslint-rules/eslint-no-unnecessary-array-join.mjs @@ -0,0 +1,198 @@ +export default { + meta: { + type: 'suggestion', + docs: { + description: 'Prefer building strings directly instead of collecting string fragments in an array', + recommended: true, + }, + schema: [], + messages: { + buildStringDirectly: + 'Build "{{name}}" directly as a string instead of collecting string fragments in an array before joining.', + }, + }, + + /** + * @param {import('eslint').Rule.RuleContext} context + */ + create (context) { + const { sourceCode } = context + + return { + /** + * @param {import('estree').VariableDeclarator} node + */ + VariableDeclarator (node) { + if ( + node.id.type !== 'Identifier' || + node.init?.type !== 'ArrayExpression' || + node.init.elements.length !== 0 + ) { + return + } + + const [variable] = sourceCode.getDeclaredVariables(node) + if (variable.defs.length !== 1) return + + const owner = getFunctionOwner(node) + let joinCall + const pushCalls = [] + + for (const reference of variable.references) { + const identifier = reference.identifier + if (identifier === node.id) continue + if (getFunctionOwner(identifier) !== owner) return + + const member = identifier.parent + if ( + member.type !== 'MemberExpression' || + member.object !== identifier || + member.computed || + member.optional || + member.property.type !== 'Identifier' + ) { + return + } + + const call = member.parent + if (call.type !== 'CallExpression' || call.callee !== member || call.optional) return + + if (member.property.name === 'push') { + if ( + call.parent.type !== 'ExpressionStatement' || + call.arguments.length === 0 || + call.arguments.some(argument => + argument.type === 'SpreadElement' || isKnownNonStringExpression(argument, sourceCode)) + ) { + return + } + + pushCalls.push(call) + continue + } + + if (member.property.name === 'join') { + if ( + joinCall || + call.arguments.length > 1 || + !isStaticSeparator(call.arguments[0]) + ) { + return + } + + joinCall = call + continue + } + + return + } + + if (!joinCall || pushCalls.length === 0) return + for (const pushCall of pushCalls) { + if (pushCall.range[0] > joinCall.range[0]) return + } + + context.report({ + node: joinCall, + messageId: 'buildStringDirectly', + data: { name: node.id.name }, + }) + }, + } + }, +} + +/** + * @param {import('estree').Node} node + */ +function getFunctionOwner (node) { + let current = node.parent + + while (current && current.type !== 'Program') { + if ( + current.type === 'ArrowFunctionExpression' || + current.type === 'FunctionDeclaration' || + current.type === 'FunctionExpression' + ) { + return current + } + + current = current.parent + } + + return current +} + +/** + * @param {import('estree').Expression | import('estree').SpreadElement | undefined} node + * @returns {boolean} + */ +function isStaticSeparator (node) { + if (node === undefined) return true + if (node.type === 'Literal') return typeof node.value === 'string' + return node.type === 'TemplateLiteral' && node.expressions.length === 0 +} + +/** + * @param {import('estree').Expression} node + * @param {import('eslint').SourceCode} sourceCode + * @param {Set} [seen] + * @returns {boolean} + */ +function isKnownNonStringExpression (node, sourceCode, seen = new Set()) { + if (node.type === 'Literal') return typeof node.value !== 'string' + + if (node.type === 'BinaryExpression') { + return node.operator !== '+' + } + + if (node.type === 'ConditionalExpression') { + return isKnownNonStringExpression(node.consequent, sourceCode, seen) || + isKnownNonStringExpression(node.alternate, sourceCode, seen) + } + + if (node.type === 'LogicalExpression') { + return isKnownNonStringExpression(node.left, sourceCode, seen) || + isKnownNonStringExpression(node.right, sourceCode, seen) + } + + if (node.type === 'SequenceExpression') { + return isKnownNonStringExpression(node.expressions[node.expressions.length - 1], sourceCode, seen) + } + + if (node.type === 'UnaryExpression') return node.operator !== 'typeof' + if (node.type === 'UpdateExpression') return true + + if ( + node.type === 'ArrayExpression' || + node.type === 'ArrowFunctionExpression' || + node.type === 'ClassExpression' || + node.type === 'FunctionExpression' || + node.type === 'NewExpression' || + node.type === 'ObjectExpression' + ) { + return true + } + + if (node.type === 'Identifier' && !seen.has(node)) { + seen.add(node) + let scope = sourceCode.getScope(node) + + while (scope) { + const variable = scope.set.get(node.name) + const definition = variable?.defs.find(definition => + definition.type === 'Variable' && + definition.node.init && + definition.node.parent.kind === 'const' + ) + + if (definition) return isKnownNonStringExpression(definition.node.init, sourceCode, seen) + if (variable) return node.name === 'undefined' && variable.defs.length === 0 + scope = scope.upper + } + + return node.name === 'undefined' + } + + return false +} diff --git a/eslint-rules/eslint-no-unnecessary-array-join.test.mjs b/eslint-rules/eslint-no-unnecessary-array-join.test.mjs new file mode 100644 index 00000000000..794cd649aca --- /dev/null +++ b/eslint-rules/eslint-no-unnecessary-array-join.test.mjs @@ -0,0 +1,141 @@ +import { RuleTester } from 'eslint' + +import rule from './eslint-no-unnecessary-array-join.mjs' + +const ruleTester = new RuleTester({ + languageOptions: { ecmaVersion: 2022 }, +}) + +ruleTester.run('eslint-no-unnecessary-array-join', /** @type {import('eslint').Rule.RuleModule} */ (rule), { + valid: [ + "const parts = ['a']; parts.join('')", + "const parts = []; parts.push('a'); consume(parts); parts.join('')", + "const parts = []; parts.push('a'); parts.join(separator)", + "const parts = []; parts.push('a'); parts.join(`$" + '{separator}`)', + "const parts = []; parts.push('a'); parts.join(''); parts.join('')", + "const parts = []; parts.push('a')", + "const parts = []; parts.join('')", + "const parts = []; parts.join(''); parts.push('a')", + "const parts = []; parts.push('a'); parts.pop(); parts.join('')", + "const parts = []; if (parts.push('a')) consume(); parts.join('')", + "const parts = []; parts['push']('a'); parts.join('')", + "const parts = []; parts.push?.('a'); parts.join('')", + "const parts = []; parts.push(...values); parts.join('')", + "const parts = []; parts.push(); parts.join('')", + 'const parts = []; parts.push(null); parts.join()', + "const parts = []; parts.push(undefined); parts.join('')", + "const parts = []; parts.push(42); parts.join('')", + "const parts = []; parts.push({}); parts.join('')", + "const parts = []; parts.push([]); parts.join('')", + "const parts = []; parts.push(new Date()); parts.join('')", + "const parts = []; parts.push(() => 'value'); parts.join('')", + "const parts = []; parts.push(enabled ? 'enabled' : null); parts.join('')", + "const parts = []; parts.push(value * 2); parts.join('')", + "let value = 1; const parts = []; parts.push(value++); parts.join('')", + ` + const parts = [] + const value = { toString () { return state } } + parts.push(value) + state = 'changed' + parts.join('') + `, + "let parts = []; parts.push('a'); parts = []; parts.join('')", + "var parts = []; var parts; parts.push('a'); parts.join('')", + "const parts = []; parts[0] = 'a'; parts.join('')", + ` + function build () { + const parts = [] + function append () { + parts.push('a') + } + append() + return parts.join('') + } + `, + ], + invalid: [ + { + code: "const parts = []; parts.push('a'); parts.join()", + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: "const parts = []; parts.push('a'); parts.join(',')", + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: "const parts = []; parts.push('a'); parts.join(``)", + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: "const parts = []; parts.push('a' || 'b'); parts.join('')", + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: "const parts = []; parts.push(value); parts.join('')", + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: "const value = 'a'; const parts = []; parts.push(value); parts.join('')", + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: "const parts = []; parts.push(value.text); parts.join('')", + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: "const parts = []; parts.push(JSON.stringify(value)); parts.join('')", + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: "const parts = []; parts.push(enabled ? 'enabled' : value); parts.join('')", + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: "const parts = []; parts.push('enabled' && value); parts.join('')", + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: "const parts = []; parts.push((consume(), value)); parts.join('')", + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: "const parts = []; parts.push(typeof value); parts.join('')", + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: ` + function build (values) { + const parts = [] + for (const value of values) { + parts.push(\`\${value}\`) + } + return parts.join('') + } + `, + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: ` + function build (value, enabled) { + const parts = [] + if (enabled) parts.push('prefix:', value + '') + parts.push(enabled ? 'enabled' : 'disabled') + return parts.join('') + } + `, + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + { + code: ` + const parts = [] + { + const parts = [] + parts.push(value) + } + parts.push((consume(), 'value')) + parts.join('') + `, + errors: [{ messageId: 'buildStringDirectly', data: { name: 'parts' } }], + }, + ], +}) diff --git a/eslint-rules/eslint-require-agent-stop.mjs b/eslint-rules/eslint-require-agent-stop.mjs new file mode 100644 index 00000000000..0e6ae332f24 --- /dev/null +++ b/eslint-rules/eslint-require-agent-stop.mjs @@ -0,0 +1,623 @@ +const FAKE_AGENT_CONSTRUCTORS = new Set(['FakeAgent', 'FakeCiVisIntake']) +const PROMISE_ADOPTERS = new Set(['resolve']) +const PROMISE_AGGREGATES = new Set(['all', 'allSettled']) +const PROMISE_CHAIN_METHODS = new Set(['catch', 'finally', 'then']) + +/** + * @typedef { + * import('estree').FunctionDeclaration | + * import('estree').FunctionExpression | + * import('estree').ArrowFunctionExpression + * } TeardownCallback + */ + +/** + * @typedef {object} ValueReference + * @property {import('eslint').Scope.Variable} variable + * @property {string} memberPath + */ + +/** @typedef {Map>} ValueReferences */ + +/** + * @typedef {object} AssignedValueReference + * @property {ValueReference} valueReference + * @property {import('estree').VariableDeclarator | import('estree').AssignmentExpression} assignment + */ + +/** + * @typedef {object} FlowEvent + * @property {'settle' | 'write'} type + * @property {ValueReference} valueReference + * @property {import('estree').Node} node + * @property {number} position + */ + +/** + * @typedef {object} CodePathState + * @property {CodePathState | undefined} upper + * @property {Set} currentSegments + */ + +/** + * @typedef {object} StopCall + * @property {TeardownCallback} callback + * @property {import('estree').CallExpression} node + * @property {ValueReference} valueReference + * @property {Set} segments + */ + +/** + * @param {import('estree').Identifier} node + * @param {import('eslint').SourceCode} sourceCode + * @returns {import('eslint').Scope.Variable | undefined} + */ +function getVariable (node, sourceCode) { + let scope = sourceCode.getScope(node) + while (scope) { + const variable = scope.set.get(node.name) + if (variable) return variable + + scope = scope.upper + } +} + +/** + * @param {import('estree').Node} node + * @param {import('eslint').SourceCode} sourceCode + * @returns {ValueReference | undefined} + */ +function getValueReference (node, sourceCode) { + if (node.type === 'ChainExpression') { + return getValueReference(node.expression, sourceCode) + } + + if (node.type === 'Identifier') { + const variable = getVariable(node, sourceCode) + return variable && { variable, memberPath: '' } + } + + if (node.type !== 'MemberExpression' || node.computed || node.property.type !== 'Identifier') { + return undefined + } + + const reference = getValueReference(node.object, sourceCode) + if (reference) { + reference.memberPath += `.${node.property.name}` + } + return reference +} + +/** + * @param {ValueReferences} references + * @param {ValueReference} reference + */ +function addValueReference (references, reference) { + let memberPaths = references.get(reference.variable) + if (!memberPaths) { + memberPaths = new Set() + references.set(reference.variable, memberPaths) + } + memberPaths.add(reference.memberPath) +} + +/** + * @param {ValueReferences} references + * @param {ValueReference} reference + * @returns {boolean} + */ +function hasValueReference (references, reference) { + return references.get(reference.variable)?.has(reference.memberPath) === true +} + +/** + * @param {ValueReference} left + * @param {ValueReference} right + * @returns {boolean} + */ +function isSameValueReference (left, right) { + return left.variable === right.variable && left.memberPath === right.memberPath +} + +/** + * @param {ValueReference} write + * @param {ValueReference} value + * @returns {boolean} + */ +function writesValueReference (write, value) { + return write.variable === value.variable && + (write.memberPath === value.memberPath || + write.memberPath === '' || + value.memberPath.startsWith(`${write.memberPath}.`)) +} + +/** + * @param {import('estree').Node} node + * @returns {boolean} + */ +function isFakeAgent (node) { + if (node.type === 'AwaitExpression' || node.type === 'ChainExpression') { + return isFakeAgent(node.argument ?? node.expression) + } + + if (node.type === 'NewExpression') { + return node.callee.type === 'Identifier' && FAKE_AGENT_CONSTRUCTORS.has(node.callee.name) + } + + return node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + !node.callee.computed && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'start' && + isFakeAgent(node.callee.object) +} + +/** + * @param {import('estree').Node} node + * @returns {boolean} + */ +function isFunction (node) { + return node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' +} + +/** + * @param {import('estree').Node} node + * @returns {boolean} + */ +function isTeardownHook (node) { + return node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + (node.callee.name === 'after' || node.callee.name === 'afterEach') +} + +/** + * @param {import('estree').Node} node + * @returns {TeardownCallback | undefined} + */ +function getEnclosingCallback (node) { + let currentNode = node + while (currentNode.parent) { + currentNode = currentNode.parent + if (!isFunction(currentNode)) continue + + return /** @type {TeardownCallback} */ (currentNode) + } +} + +/** + * @param {import('estree').Node} node + * @param {import('eslint').SourceCode} sourceCode + * @param {Set} [seen] + * @returns {TeardownCallback | undefined} + */ +function resolveCallback (node, sourceCode, seen = new Set()) { + if (isFunction(node)) { + return /** @type {TeardownCallback} */ (node) + } + + if (node.type !== 'Identifier') return undefined + + const variable = getVariable(node, sourceCode) + if (!variable || seen.has(variable)) return undefined + + for (const reference of variable.references) { + if (reference.isWrite() && !reference.init) return undefined + } + + seen.add(variable) + if (variable.defs.length !== 1) return undefined + + const [definition] = variable.defs + if (isFunction(definition.node)) { + return /** @type {TeardownCallback} */ (definition.node) + } + if (definition.node.type === 'VariableDeclarator' && definition.node.init) { + return resolveCallback(definition.node.init, sourceCode, seen) + } +} + +/** + * @param {import('estree').CallExpression} node + * @param {Set} methodNames + * @param {import('estree').Node} argument + * @returns {boolean} + */ +function isPromiseStaticCall (node, methodNames, argument) { + return node.callee.type === 'MemberExpression' && + !node.callee.computed && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Promise' && + node.callee.property.type === 'Identifier' && + methodNames.has(node.callee.property.name) && + node.arguments[0] === argument +} + +/** + * @param {import('estree').Node} node + * @returns {import('estree').Node | undefined} + */ +function getResultParent (node) { + const parentNode = node.parent + if (parentNode.type === 'ChainExpression' && parentNode.expression === node) { + return parentNode + } + + if ( + parentNode.type === 'ConditionalExpression' && + (parentNode.consequent === node || parentNode.alternate === node) + ) { + return parentNode + } + + if (parentNode.type === 'LogicalExpression') { + if (parentNode.right === node || (parentNode.left === node && parentNode.operator !== '&&')) { + return parentNode + } + return undefined + } + + if ( + parentNode.type === 'SequenceExpression' && + parentNode.expressions[parentNode.expressions.length - 1] === node + ) { + return parentNode + } + + if (parentNode.type === 'AssignmentExpression' && parentNode.right === node) { + return parentNode + } + + if (parentNode.type === 'ArrayExpression' && parentNode.elements.includes(node)) { + const callNode = parentNode.parent + if (callNode.type === 'CallExpression' && isPromiseStaticCall(callNode, PROMISE_AGGREGATES, parentNode)) { + return callNode + } + return undefined + } + + if ( + parentNode.type === 'CallExpression' && + isPromiseStaticCall(parentNode, PROMISE_ADOPTERS, node) + ) { + return parentNode + } + + if ( + parentNode.type === 'MemberExpression' && + parentNode.object === node && + !parentNode.computed && + parentNode.property.type === 'Identifier' && + PROMISE_CHAIN_METHODS.has(parentNode.property.name) && + parentNode.parent.type === 'CallExpression' && + parentNode.parent.callee === parentNode + ) { + return parentNode.parent + } +} + +/** + * @param {import('estree').Node} node + * @param {TeardownCallback} callback + * @returns {boolean} + */ +function isSettled (node, callback) { + let currentNode = node + while (currentNode.parent !== callback) { + const parentNode = currentNode.parent + if ( + (parentNode.type === 'AwaitExpression' || parentNode.type === 'ReturnStatement') && + parentNode.argument === currentNode + ) { + return true + } + + const resultParent = getResultParent(currentNode) + if (!resultParent) return false + + currentNode = resultParent + } + + return callback.expression && callback.body === currentNode +} + +/** + * @param {import('estree').CallExpression} node + * @param {import('eslint').SourceCode} sourceCode + * @returns {AssignedValueReference | undefined} + */ +function getAssignedValueReference (node, sourceCode) { + let currentNode = node + while (currentNode.parent) { + const parentNode = currentNode.parent + + if (parentNode.type === 'VariableDeclarator' && parentNode.init === currentNode) { + const valueReference = getValueReference(parentNode.id, sourceCode) + return valueReference && { valueReference, assignment: parentNode } + } + + if (parentNode.type === 'AssignmentExpression' && parentNode.right === currentNode) { + const valueReference = getValueReference(parentNode.left, sourceCode) + return valueReference && { valueReference, assignment: parentNode } + } + + const resultParent = getResultParent(currentNode) + if (!resultParent) return undefined + + currentNode = resultParent + } +} + +/** + * @param {Map} events + * @param {import('eslint').Rule.CodePathSegment} segment + * @returns {FlowEvent[]} + */ +function getFlowEvents (events, segment) { + return events.get(segment) ?? [] +} + +/** + * @param {StopCall} stopCall + * @param {AssignedValueReference} assignedValue + * @param {Map} events + * @returns {boolean} + */ +function isAssignedValueSettled (stopCall, assignedValue, events) { + const { assignment, valueReference } = assignedValue + + /** + * @param {import('eslint').Rule.CodePathSegment} segment + * @param {boolean} initial + * @param {boolean} skipSourceWrite + * @param {Map} visiting + * @param {Map>} memo + * @returns {boolean} + */ + function settlesOnEveryPath (segment, initial, skipSourceWrite, visiting, memo) { + const state = (initial ? 2 : 0) | (skipSourceWrite ? 1 : 0) + const segmentMemo = memo.get(segment) + if (segmentMemo?.has(state)) return segmentMemo.get(state) + + const visitingState = visiting.get(segment) ?? 0 + if ((visitingState & (1 << state)) !== 0) return true + visiting.set(segment, visitingState | (1 << state)) + + /** @type {boolean | undefined} */ + let settled + for (const event of getFlowEvents(events, segment)) { + if (initial && event.position <= stopCall.node.range[1]) continue + + if (event.type === 'settle' && isSameValueReference(event.valueReference, valueReference)) { + settled = true + break + } + + if (event.type === 'write' && writesValueReference(event.valueReference, valueReference)) { + if (skipSourceWrite && event.node === assignment) { + skipSourceWrite = false + continue + } + settled = false + break + } + } + + if (settled === undefined) { + let hasNextSegment = false + settled = true + for (const nextSegment of segment.nextSegments) { + hasNextSegment = true + if (!settlesOnEveryPath(nextSegment, false, skipSourceWrite, visiting, memo)) { + settled = false + break + } + } + if (!hasNextSegment) settled = false + } + + visiting.set(segment, visiting.get(segment) & ~(1 << state)) + let mutableSegmentMemo = segmentMemo + if (!mutableSegmentMemo) { + mutableSegmentMemo = new Map() + memo.set(segment, mutableSegmentMemo) + } + mutableSegmentMemo.set(state, settled) + return settled + } + + for (const segment of stopCall.segments) { + if (!settlesOnEveryPath(segment, true, true, new Map(), new Map())) return false + } + return stopCall.segments.size > 0 +} + +export default { + meta: { + type: 'problem', + docs: { + description: 'Require fake-agent teardown hooks to settle stop promises.', + }, + schema: [], + messages: { + requireSettledStop: 'FakeAgent.stop() must be awaited or returned from a Mocha teardown hook.', + }, + }, + + /** + * @param {import('eslint').Rule.RuleContext} context + * @returns {import('eslint').Rule.RuleListener} + */ + create (context) { + const { sourceCode } = context + const fakeAgentValues = new Map() + const flowEvents = new Map() + const stopCalls = [] + const teardownCallbacks = new Set() + /** @type {CodePathState | undefined} */ + let codePathState + + /** + * @param {import('estree').Node} node + * @param {'settle' | 'write'} type + * @param {ValueReference} valueReference + */ + function recordFlowEvent (node, type, valueReference) { + const currentSegments = /** @type {CodePathState} */ (codePathState).currentSegments + + for (const segment of currentSegments) { + let events = flowEvents.get(segment) + if (!events) { + events = [] + flowEvents.set(segment, events) + } + events.push({ type, valueReference, node, position: node.range[1] }) + } + } + + /** + * @param {import('estree').Node} node + */ + function recordSettledValue (node) { + const callback = getEnclosingCallback(node) + if (!callback || !isSettled(node, callback)) return + + const valueReference = getValueReference(node, sourceCode) + if (valueReference) { + recordFlowEvent(node, 'settle', valueReference) + } + } + + return { + onCodePathStart () { + codePathState = { + upper: codePathState, + currentSegments: new Set(), + } + }, + onCodePathEnd () { + codePathState = codePathState?.upper + }, + /** + * @param {import('eslint').Rule.CodePathSegment} segment + */ + onCodePathSegmentStart (segment) { + codePathState?.currentSegments.add(segment) + }, + /** + * @param {import('eslint').Rule.CodePathSegment} segment + */ + onCodePathSegmentEnd (segment) { + codePathState?.currentSegments.delete(segment) + }, + /** + * @param {import('estree').VariableDeclarator} node + */ + VariableDeclarator (node) { + const valueReference = getValueReference(node.id, sourceCode) + if (valueReference && node.init && isFakeAgent(node.init)) { + addValueReference(fakeAgentValues, valueReference) + } + }, + /** + * @param {import('estree').VariableDeclarator} node + */ + 'VariableDeclarator:exit' (node) { + if (!node.init) return + + const valueReference = getValueReference(node.id, sourceCode) + if (valueReference) { + recordFlowEvent(node, 'write', valueReference) + } + }, + /** + * @param {import('estree').AssignmentExpression} node + */ + AssignmentExpression (node) { + const valueReference = getValueReference(node.left, sourceCode) + if (valueReference && isFakeAgent(node.right)) { + addValueReference(fakeAgentValues, valueReference) + } + }, + /** + * @param {import('estree').AssignmentExpression} node + */ + 'AssignmentExpression:exit' (node) { + const valueReference = getValueReference(node.left, sourceCode) + if (valueReference) { + recordFlowEvent(node, 'write', valueReference) + } + }, + /** + * @param {import('estree').Identifier} node + */ + Identifier (node) { + if ( + node.parent.type === 'MemberExpression' && + node.parent.property === node && + !node.parent.computed + ) return + + recordSettledValue(node) + }, + /** + * @param {import('estree').MemberExpression} node + */ + MemberExpression (node) { + recordSettledValue(node) + }, + /** + * @param {import('estree').CallExpression} node + */ + 'CallExpression:exit' (node) { + if (isTeardownHook(node)) { + const callbackNode = node.arguments[node.arguments.length - 1] + if (callbackNode && callbackNode.type !== 'SpreadElement') { + const callback = resolveCallback(callbackNode, sourceCode) + if (callback) teardownCallbacks.add(callback) + } + } + + if ( + node.callee.type !== 'MemberExpression' || + node.callee.computed || + node.callee.property.type !== 'Identifier' || + node.callee.property.name !== 'stop' + ) { + return + } + + const callback = getEnclosingCallback(node) + const valueReference = getValueReference(node.callee.object, sourceCode) + if (callback && valueReference && codePathState) { + stopCalls.push({ + callback, + node, + valueReference, + segments: new Set(codePathState.currentSegments), + }) + } + }, + 'Program:exit' () { + for (const events of flowEvents.values()) { + events.sort((left, right) => left.position - right.position) + } + + for (const stopCall of stopCalls) { + const { callback, node, valueReference } = stopCall + if (!teardownCallbacks.has(callback)) continue + if (!hasValueReference(fakeAgentValues, valueReference) || isSettled(node, callback)) continue + + const assignedValue = getAssignedValueReference(node, sourceCode) + if (assignedValue && isAssignedValueSettled(stopCall, assignedValue, flowEvents)) continue + + context.report({ + node, + messageId: 'requireSettledStop', + }) + } + }, + } + }, +} diff --git a/eslint-rules/eslint-require-agent-stop.test.mjs b/eslint-rules/eslint-require-agent-stop.test.mjs new file mode 100644 index 00000000000..a1bc93638d3 --- /dev/null +++ b/eslint-rules/eslint-require-agent-stop.test.mjs @@ -0,0 +1,346 @@ +import { RuleTester } from 'eslint' + +import rule from './eslint-require-agent-stop.mjs' + +const ruleTester = new RuleTester({ + languageOptions: { ecmaVersion: 2022 }, +}) + +ruleTester.run('eslint-require-agent-stop', /** @type {import('eslint').Rule.RuleModule} */ (rule), { + valid: [ + 'after()', + 'afterEach()', + `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + afterEach(() => agent.stop())`, + `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + afterEach(async () => { await agent.stop() })`, + `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + async function cleanup () { await agent.stop() } + afterEach(cleanup)`, + `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + afterEach(cleanup) + async function cleanup () { await agent.stop() }`, + `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + async function cleanup () { await agent.stop() } + const teardown = cleanup + afterEach(teardown)`, + `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + const cleanup = () => agent.stop() + afterEach('cleanup', cleanup)`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + const cleanup = async function () { + const agentStopped = agent.stop() + await agentStopped + } + after(cleanup)`, + 'afterEach(cleanup)', + `const cleanup = createCleanup() + afterEach(cleanup)`, + `const cleanup = cleanup + afterEach(cleanup)`, + `let agent + const cleanup = () => { agent.stop() } + cleanup = () => {} + afterEach(cleanup)`, + `var cleanup = () => {} + var cleanup = () => {} + afterEach(cleanup)`, + `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + afterEach('cleanup', () => agent.stop())`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + after(async () => { + const agentStopped = agent?.stop() + await Promise.all([agentStopped]) + })`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + after(async () => { + const agentStopped = agent.stop() + await Promise.allSettled([agentStopped]) + })`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + after(async () => { + const agentStopped = shouldStop ? agent.stop() : undefined + await Promise.resolve(agentStopped) + })`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + after(() => agent.stop().finally(() => {}))`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + after(async () => { await (shouldStop && agent.stop()) })`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + after(async () => { await (agent.stop() || Promise.resolve()) })`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + after(async () => { await (cleanup(), agent.stop()) })`, + `let agent, agentStopped + before(async () => { agent = await new FakeAgent().start() }) + after(async () => { + agentStopped = agent.stop() + await agentStopped + })`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + after(async () => { + let agentStopped = agent.stop() + await agentStopped + agentStopped = stopProc(proc) + })`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + after(async () => { + const agentStopped = agent.stop() + if (shouldStop) { + await agentStopped + } else { + await agentStopped + } + })`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + after(async () => { + const agentStopped = agent.stop() + if (shouldStop) { + stopProc(firstProc) + } else { + stopProc(secondProc) + } + await agentStopped + })`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + after(async () => { + for (const proc of processes) { + const agentStopped = agent.stop() + await agentStopped + stopProc(proc) + } + })`, + `let agent + before(async () => { agent = await new FakeAgent().start() }) + after(async () => { + const agentStopped = agent.stop() + while (isStopping()) { + await stopProc(proc) + } + await agentStopped + })`, + `const state = {} + before(() => { state.agent = new FakeAgent() }) + after(() => { return state.agent.stop() })`, + `describe('fake agent', () => { + let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + afterEach(async () => { await agent.stop() }) + }) + describe('other agent', () => { + const agent = { stop () {} } + afterEach(() => { agent.stop() }) + })`, + `describe('fake agent', () => { + const state = {} + beforeEach(async () => { state.agent = await new FakeAgent().start() }) + afterEach(async () => { await state.agent.stop() }) + }) + describe('other agent', () => { + const state = { agent: { stop () {} } } + afterEach(() => { state.agent.stop() }) + })`, + `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + afterEach(() => () => agent.stop())`, + `const { agent } = state + afterEach(() => { agent.stop() })`, + 'afterEach(() => broker.stop())', + ], + invalid: [ + { + code: `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + afterEach(() => { agent.stop() })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + function cleanup () { agent.stop() } + afterEach(cleanup)`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + afterEach(cleanup) + function cleanup () { agent.stop() }`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + const cleanup = () => { agent.stop() } + afterEach('cleanup', cleanup)`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(async () => { agent = await new FakeAgent().start() }) + const cleanup = function () { agent.stop() } + after(cleanup)`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + beforeEach(async () => { agent = await new FakeAgent().start() }) + afterEach('cleanup', () => { agent.stop() })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `const state = {} + before(() => { state.agent = new FakeAgent() }) + after(() => { state.agent.stop() })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(() => { const stopped = agent.stop() })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(async () => { await [agent.stop()] })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(() => [agent.stop()])`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(() => { return { stopped: agent.stop() } })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(async () => { + const agentStopped = agent.stop() + await [agentStopped] + })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(async () => { + let agentStopped = agent.stop() + agentStopped = stopProc(proc) + await agentStopped + })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(async () => { + const state = {} + state.agentStopped = agent.stop() + state.agentStopped = stopProc(proc) + await state.agentStopped + })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(async () => { + let state = {} + state.agentStopped = agent.stop() + state = {} + await state.agentStopped + })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(async () => { + const state = { cleanup: {} } + state.cleanup.agentStopped = agent.stop() + state.cleanup = {} + await state.cleanup.agentStopped + })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(async () => { + const agentStopped = agent.stop() + if (shouldStop) await agentStopped + })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(async () => { + let agentStopped + while (shouldStop) { + agentStopped = agent.stop() + } + await agentStopped + })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(async () => { + let agentStopped + await agentStopped + agentStopped = agent.stop() + })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let agent + before(() => { agent = new FakeAgent() }) + after(async () => { await (agent.stop() && true) })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `let receiver + before(() => { receiver = new FakeCiVisIntake() }) + after(() => { receiver.stop() })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `const agent = new FakeAgent() + after(() => { agent.stop() })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + { + code: `const state = {} + before(() => { state.agent = new FakeAgent() }) + after(() => { (state?.agent).stop() })`, + errors: [{ messageId: 'requireSettledStop' }], + }, + ], +}) diff --git a/eslint.config.mjs b/eslint.config.mjs index 2b7910595db..b58fcece5cb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,10 +22,12 @@ import eslintEnvAliases from './eslint-rules/eslint-env-aliases.mjs' import eslintLogPrintfStyle from './eslint-rules/eslint-log-printf-style.mjs' import eslintNoPrivateTagsAccess from './eslint-rules/eslint-no-private-tags-access.mjs' import eslintNoProcessEnvDisable from './eslint-rules/eslint-no-process-env-disable.mjs' +import eslintNoUnnecessaryArrayJoin from './eslint-rules/eslint-no-unnecessary-array-join.mjs' import eslintNonPrefixEnvNames from './eslint-rules/eslint-non-prefix-env-names.mjs' import eslintPreferAssertMatch from './eslint-rules/eslint-prefer-assert-match.mjs' import eslintPreferSetServiceName from './eslint-rules/eslint-prefer-set-service-name.mjs' import eslintProcessEnv from './eslint-rules/eslint-process-env.mjs' +import eslintRequireAgentStop from './eslint-rules/eslint-require-agent-stop.mjs' import eslintRequireBooleanAssertMessage from './eslint-rules/eslint-require-boolean-assert-message.mjs' import eslintRequireExportExists from './eslint-rules/eslint-require-export-exists.mjs' import eslintSafeTypeOfObject from './eslint-rules/eslint-safe-typeof-object.mjs' @@ -469,11 +471,13 @@ export default [ 'eslint-config-names-sync': eslintConfigNamesSync, 'eslint-non-prefix-env-names': eslintNonPrefixEnvNames, 'eslint-no-process-env-disable': eslintNoProcessEnvDisable, + 'eslint-no-unnecessary-array-join': eslintNoUnnecessaryArrayJoin, 'eslint-prefer-assert-match': eslintPreferAssertMatch, 'eslint-prefer-set-service-name': eslintPreferSetServiceName, 'eslint-safe-typeof-object': eslintSafeTypeOfObject, 'eslint-log-printf-style': eslintLogPrintfStyle, 'eslint-no-private-tags-access': eslintNoPrivateTagsAccess, + 'eslint-require-agent-stop': eslintRequireAgentStop, 'eslint-require-boolean-assert-message': eslintRequireBooleanAssertMessage, 'eslint-require-export-exists': eslintRequireExportExists, 'eslint-timer-unref': eslintTimerUnref, @@ -671,6 +675,7 @@ export default [ 'eslint-rules/eslint-env-aliases': 'error', 'eslint-rules/eslint-log-printf-style': 'error', 'eslint-rules/eslint-non-prefix-env-names': 'error', + 'eslint-rules/eslint-no-unnecessary-array-join': 'error', 'eslint-rules/eslint-prefer-set-service-name': 'error', 'eslint-rules/eslint-timer-unref': 'error', @@ -987,6 +992,7 @@ export default [ }, rules: { 'eslint-rules/eslint-prefer-assert-match': 'error', + 'eslint-rules/eslint-require-agent-stop': 'error', // TODO: Re-enable this rule once we have a way to fix the false positives or have Node.js report better errors. 'eslint-rules/eslint-require-boolean-assert-message': 'off', 'mocha/consistent-spacing-between-blocks': 'off', diff --git a/index.d.ts b/index.d.ts index c85ad11e45d..62fc054c974 100644 --- a/index.d.ts +++ b/index.d.ts @@ -356,6 +356,16 @@ declare namespace tracer { links?: { context: SpanContext, attributes?: Object }[] } + export interface Exception { + message: string; + name?: string; + stack?: string; + } + + export type SpanEventAttributeValue = + string | number | boolean | Array | Array | Array; + export type SpanEventAttributes = Record; + /** * Span represents a logical unit of work as part of a broader Trace. * Examples of span might include remote procedure calls or a in-process @@ -366,6 +376,14 @@ declare namespace tracer { export interface Span extends opentracing.Span { context (): SpanContext; + /** + * Records an exception as a span event without marking the span as failed. + * + * @param exception The exception to record. + * @param attributes Additional attributes for the exception event. + */ + recordException (exception: Exception, attributes?: SpanEventAttributes): void; + /** * Adds a single link to the span. * @@ -789,6 +807,13 @@ declare namespace tracer { * Programmatic configuration takes precedence over the environment variables listed above. */ maxMessagesLength?: number, + /** + * Whether AI Guard applies backend-provided sensitive-data redaction replacements. + * @default true + * @env DD_AI_GUARD_REDACTION_ENABLED + * Programmatic configuration takes precedence over the environment variables listed above. + */ + redactionEnabled?: boolean, /** * Max size of the content property set in the meta-struct * @env DD_AI_GUARD_MAX_CONTENT_SIZE @@ -1706,6 +1731,25 @@ declare namespace tracer { }; } + /** + * A structured content part in an AI Guard message. + */ + export interface ContentPart { + type: string; + text?: string; + image_url?: { url: string }; + } + + /** + * A conversational message whose content is represented by structured parts. + */ + export interface ContentPartsMessage { + role: string; + content: ContentPart[]; + tool_call_id?: string; + tool_calls?: ToolCall[]; + } + /** * A standard conversational message exchanged with a Large Language Model (LLM). */ @@ -1777,10 +1821,25 @@ declare namespace tracer { export type Message = | TextMessage + | ContentPartsMessage | AssistantTextMessage | AssistantToolCallMessage | ToolMessage; + /** + * A sensitive data replacement the AI Guard service determined for the evaluated conversation. + */ + export interface RedactionReplacement { + /** + * Location of the replaced value within the evaluated conversation (e.g. `messages[0].content`). + */ + path: string; + /** + * The value that replaces the sensitive data found at `path`. + */ + replacement: string; + } + /** * The result returned by AI Guard after evaluating a conversation. */ @@ -1808,6 +1867,16 @@ declare namespace tracer { * Sensitive Data Scanner findings from the evaluation. */ sds: Object[]; + /** + * The evaluated conversation, redacted when required by the AI Guard service. + * This may contain sensitive data when redaction is disabled or no replacement was applied. + */ + messages: Message[]; + /** + * The replacements the AI Guard service determined for the evaluated conversation, reported whether or not + * the tracer applied them. Empty when the service determined no replacement. + */ + redactionReplacements: RedactionReplacement[]; } /** @@ -2189,7 +2258,7 @@ declare namespace tracer { * This plugin automatically instruments the * [Vercel AI SDK](https://ai-sdk.dev/docs/introduction) module. */ - interface ai extends Instrumentation {} + interface ai extends Instrumentation, LLMObsIntegration {} /** * This plugin automatically instruments the @@ -2207,13 +2276,13 @@ declare namespace tracer { * This plugin automatically instruments the * [anthropic](https://www.npmjs.com/package/@anthropic-ai/sdk) module. */ - interface anthropic extends Instrumentation {} + interface anthropic extends Instrumentation, LLMObsIntegration {} /** * This plugin automatically instruments the * [@anthropic-ai/claude-agent-sdk](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) module. */ - interface claude_agent_sdk extends Instrumentation {} + interface claude_agent_sdk extends Instrumentation, LLMObsIntegration {} /** * Currently this plugin automatically instruments @@ -2274,7 +2343,7 @@ declare namespace tracer { * This plugin automatically instruments the * [aws-sdk](https://github.com/aws/aws-sdk-js) module. */ - interface aws_sdk extends Instrumentation { + interface aws_sdk extends Instrumentation, LLMObsIntegration { /** * The service name to be used for this plugin. When a function is used it is called with the AWS * request parameters (e.g. `{ TableName }` for DynamoDB, `{ Bucket }` for S3) and its return value @@ -2503,13 +2572,13 @@ declare namespace tracer { * This plugin automatically instruments the * [@google-cloud/vertexai](https://github.com/googleapis/nodejs-vertexai) module. */ - interface google_cloud_vertexai extends Integration {} + interface google_cloud_vertexai extends Integration, LLMObsIntegration {} /** * This plugin automatically instruments the * [@google-genai](https://github.com/googleapis/js-genai) module. */ - interface google_genai extends Integration {} + interface google_genai extends Integration, LLMObsIntegration {} /** @hidden */ interface ExecutionArgs { @@ -2831,7 +2900,7 @@ declare namespace tracer { * This plugin automatically instruments the * [langgraph](https://github.com/npmjs/package/langgraph) library. */ - interface langgraph extends Instrumentation {} + interface langgraph extends Instrumentation, LLMObsIntegration {} /** * This plugin automatically instruments the @@ -2991,13 +3060,13 @@ declare namespace tracer { * [DogStatsD](https://docs.datadoghq.com/developers/dogstatsd/?tab=hostagent#setup) * in the agent. */ - interface openai extends Instrumentation {} + interface openai extends Instrumentation, LLMObsIntegration {} /** * This plugin automatically instruments the * [@openai/agents](https://www.npmjs.com/package/@openai/agents) library. */ - interface openai_agents extends Instrumentation {} + interface openai_agents extends Instrumentation, LLMObsIntegration {} /** * This plugin automatically instruments the @@ -3610,7 +3679,7 @@ declare namespace tracer { * * @deprecated Enabling LLM Observability via `llmobs.enable()` is deprecated and will be removed in dd-trace@7.0.0. Please instantiate LLM Observability via DD_LLMOBS_ENABLED or `tracer.init({ llmobs: ...options })`. */ - enable (options: LLMObsEnableOptions): void, + enable (options: LLMObsRuntimeEnableOptions): void, /** * Disable LLM Observability tracing. @@ -3798,21 +3867,35 @@ declare namespace tracer { metadata?: Array> ) => any | Promise + interface DatasetRecord { + id: string | null + input: JSONType + expectedOutput: JSONType + metadata: Record + tags: string[] + } + + interface DatasetRecordNew { + id?: string + inputData: JSONType + expectedOutput?: JSONType + metadata?: Record + tags?: string[] + } + interface CreateDatasetOptions { + /** Override the configured project for this dataset. */ + projectName?: string description?: string - records?: Array<{ - id?: string, - inputData: JSONType, - expectedOutput?: JSONType, - metadata?: Record, - tags?: string[] - }> + records?: DatasetRecordNew[] } interface ExperimentOptions { name: string dataset: Dataset task: ExperimentTask + /** Override the configured project for this experiment. */ + projectName?: string /** Evaluators keyed by metric label, or named functions. */ evaluators?: Record | ExperimentEvaluator[] /** Summary evaluators keyed by metric label, or named functions. */ @@ -3820,6 +3903,8 @@ declare namespace tracer { description?: string config?: Record tags?: Record + /** Number of full experiment runs to execute. Default 1. */ + runs?: number } interface ExperimentRunOptions { @@ -3829,9 +3914,13 @@ declare namespace tracer { retryDelay?: (attempt: number) => number /** Reject on the first task/evaluator error instead of capturing it. Default false. */ throwOnErrors?: boolean + /** Maximum number of task/evaluator executions to process concurrently. Default 10. */ + concurrency?: number } interface PullDatasetOptions { + /** Override the configured project for this dataset pull. */ + projectName?: string /** Dataset version to pull. Defaults to latest. */ version?: number /** Wait until at least this many records are readable (absorbs write lag). */ @@ -3860,17 +3949,21 @@ declare namespace tracer { interface ExperimentRun { runId: string + /** 1-based run iteration. */ runIteration: number + /** Whether this run had a task, row-evaluator, or summary-evaluator error. */ + hasError: boolean rows: ExperimentResultRow[] summaryEvaluations: Record } interface ExperimentResult { experimentId: string + /** Rows from the first run, kept as a compatibility alias. */ rows: ExperimentResultRow[] - /** Single-run summary evaluator results. */ + /** Summary evaluator results from the first run, kept as a compatibility alias. */ summaryEvaluations: Record - /** Experiment runs. P0 Node experiments currently return one run. */ + /** All experiment runs. */ runs: ExperimentRun[] /** Dashboard URL for the experiment. */ url: string @@ -3963,6 +4056,8 @@ declare namespace tracer { metadata?: Record, tags?: string[] ): Dataset + /** Add multiple records to the dataset. */ + addRecords (records: DatasetRecordNew[]): Dataset /** Update fields on an existing dataset record. */ update (index: number, fields: { input?: JSONType @@ -3983,15 +4078,11 @@ declare namespace tracer { description (): string id (): string | null projectId (): string | null + /** Project associated with the client used to create or pull this dataset. */ + projectName (): string | null | undefined version (): number | null latestVersion (): number | null - records (): Array<{ - id: string | null, - input: JSONType, - expectedOutput: JSONType, - metadata: Record, - tags: string[] - }> + records (): DatasetRecord[] /** Return the tags used to filter this dataset. */ filterTags (): string[] /** Dashboard URL for the dataset, or null until pushed. */ @@ -4507,6 +4598,13 @@ declare namespace tracer { * Options for enabling LLM Observability tracing. */ interface LLMObsEnableOptions { + /** + * The name of the LLM Observability project used for experiments. + * @env DD_LLMOBS_PROJECT_NAME + * Programmatic configuration takes precedence over the environment variables listed above. + */ + projectName?: string, + /** * The name of your ML application. * @env DD_LLMOBS_ML_APP @@ -4531,6 +4629,9 @@ declare namespace tracer { sampleRate?: number, } + /** Options accepted by the deprecated runtime `llmobs.enable()` method. */ + type LLMObsRuntimeEnableOptions = Omit + /** @hidden */ type spanKind = 'agent' | 'workflow' | 'task' | 'tool' | 'retrieval' | 'embedding' | 'llm' } diff --git a/index.d.v5.ts b/index.d.v5.ts index 593f377d425..888bc82237d 100644 --- a/index.d.v5.ts +++ b/index.d.v5.ts @@ -358,6 +358,16 @@ declare namespace tracer { links?: { context: SpanContext, attributes?: Object }[] } + export interface Exception { + message: string; + name?: string; + stack?: string; + } + + export type SpanEventAttributeValue = + string | number | boolean | Array | Array | Array; + export type SpanEventAttributes = Record; + /** * Span represents a logical unit of work as part of a broader Trace. * Examples of span might include remote procedure calls or a in-process @@ -368,6 +378,14 @@ declare namespace tracer { export interface Span extends opentracing.Span { context (): SpanContext; + /** + * Records an exception as a span event without marking the span as failed. + * + * @param exception The exception to record. + * @param attributes Additional attributes for the exception event. + */ + recordException (exception: Exception, attributes?: SpanEventAttributes): void; + /** * Causally links another span to the current span * @@ -859,6 +877,13 @@ declare namespace tracer { * Programmatic configuration takes precedence over the environment variables listed above. */ maxMessagesLength?: number, + /** + * Whether AI Guard applies backend-provided sensitive-data redaction replacements. + * @default true + * @env DD_AI_GUARD_REDACTION_ENABLED + * Programmatic configuration takes precedence over the environment variables listed above. + */ + redactionEnabled?: boolean, /** * Max size of the content property set in the meta-struct * @env DD_AI_GUARD_MAX_CONTENT_SIZE @@ -1818,6 +1843,25 @@ declare namespace tracer { }; } + /** + * A structured content part in an AI Guard message. + */ + export interface ContentPart { + type: string; + text?: string; + image_url?: { url: string }; + } + + /** + * A conversational message whose content is represented by structured parts. + */ + export interface ContentPartsMessage { + role: string; + content: ContentPart[]; + tool_call_id?: string; + tool_calls?: ToolCall[]; + } + /** * A standard conversational message exchanged with a Large Language Model (LLM). */ @@ -1889,10 +1933,25 @@ declare namespace tracer { export type Message = | TextMessage + | ContentPartsMessage | AssistantTextMessage | AssistantToolCallMessage | ToolMessage; + /** + * A sensitive data replacement the AI Guard service determined for the evaluated conversation. + */ + export interface RedactionReplacement { + /** + * Location of the replaced value within the evaluated conversation (e.g. `messages[0].content`). + */ + path: string; + /** + * The value that replaces the sensitive data found at `path`. + */ + replacement: string; + } + /** * The result returned by AI Guard after evaluating a conversation. */ @@ -1920,6 +1979,16 @@ declare namespace tracer { * Sensitive Data Scanner findings from the evaluation. */ sds: Object[]; + /** + * The evaluated conversation, redacted when required by the AI Guard service. + * This may contain sensitive data when redaction is disabled or no replacement was applied. + */ + messages: Message[]; + /** + * The replacements the AI Guard service determined for the evaluated conversation, reported whether or not + * the tracer applied them. Empty when the service determined no replacement. + */ + redactionReplacements: RedactionReplacement[]; } /** @@ -2317,7 +2386,7 @@ declare namespace tracer { * This plugin automatically instruments the * [Vercel AI SDK](https://ai-sdk.dev/docs/introduction) module. */ - interface ai extends Instrumentation {} + interface ai extends Instrumentation, LLMObsIntegration {} /** * This plugin automatically instruments the @@ -2335,13 +2404,13 @@ declare namespace tracer { * This plugin automatically instruments the * [@anthropic-ai/claude-agent-sdk](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) module. */ - interface claude_agent_sdk extends Instrumentation {} + interface claude_agent_sdk extends Instrumentation, LLMObsIntegration {} /** * This plugin automatically instruments the * [anthropic](https://www.npmjs.com/package/@anthropic-ai/sdk) module. */ - interface anthropic extends Instrumentation {} + interface anthropic extends Instrumentation, LLMObsIntegration {} /** * Currently this plugin automatically instruments @@ -2402,7 +2471,7 @@ declare namespace tracer { * This plugin automatically instruments the * [aws-sdk](https://github.com/aws/aws-sdk-js) module. */ - interface aws_sdk extends Instrumentation { + interface aws_sdk extends Instrumentation, LLMObsIntegration { /** * The service name to be used for this plugin. When a function is used it is called with the AWS * request parameters (e.g. `{ TableName }` for DynamoDB, `{ Bucket }` for S3) and its return value @@ -2637,13 +2706,13 @@ declare namespace tracer { * This plugin automatically instruments the * [@google-cloud/vertexai](https://github.com/googleapis/nodejs-vertexai) module. */ - interface google_cloud_vertexai extends Integration {} + interface google_cloud_vertexai extends Integration, LLMObsIntegration {} /** * This plugin automatically instruments the * [@google-genai](https://github.com/googleapis/js-genai) module. */ - interface google_genai extends Integration {} + interface google_genai extends Integration, LLMObsIntegration {} /** @hidden */ interface ExecutionArgs { @@ -3001,7 +3070,7 @@ declare namespace tracer { * This plugin automatically instruments the * [langgraph](https://github.com/npmjs/package/langgraph) library. */ - interface langgraph extends Instrumentation {} + interface langgraph extends Instrumentation, LLMObsIntegration {} /** * This plugin automatically instruments the @@ -3161,13 +3230,13 @@ declare namespace tracer { * [DogStatsD](https://docs.datadoghq.com/developers/dogstatsd/?tab=hostagent#setup) * in the agent. */ - interface openai extends Instrumentation {} + interface openai extends Instrumentation, LLMObsIntegration {} /** * This plugin automatically instruments the * [@openai/agents](https://www.npmjs.com/package/@openai/agents) library. */ - interface openai_agents extends Instrumentation {} + interface openai_agents extends Instrumentation, LLMObsIntegration {} /** * This plugin automatically instruments the @@ -3816,7 +3885,7 @@ declare namespace tracer { * * @deprecated Enabling LLM Observability via `llmobs.enable()` is deprecated and will be removed in dd-trace@7.0.0. Please instantiate LLM Observability via DD_LLMOBS_ENABLED or `tracer.init({ llmobs: ...options })`. */ - enable (options: LLMObsEnableOptions): void, + enable (options: LLMObsRuntimeEnableOptions): void, /** * Disable LLM Observability tracing. @@ -3995,21 +4064,35 @@ declare namespace tracer { metadata?: Array> ) => any | Promise + interface DatasetRecord { + id: string | null + input: JSONType + expectedOutput: JSONType + metadata: Record + tags: string[] + } + + interface DatasetRecordNew { + id?: string + inputData: JSONType + expectedOutput?: JSONType + metadata?: Record + tags?: string[] + } + interface CreateDatasetOptions { + /** Override the configured project for this dataset. */ + projectName?: string description?: string - records?: Array<{ - id?: string, - inputData: JSONType, - expectedOutput?: JSONType, - metadata?: Record, - tags?: string[] - }> + records?: DatasetRecordNew[] } interface ExperimentOptions { name: string dataset: Dataset task: ExperimentTask + /** Override the configured project for this experiment. */ + projectName?: string /** Evaluators keyed by metric label, or named functions. */ evaluators?: Record | ExperimentEvaluator[] /** Summary evaluators keyed by metric label, or named functions. */ @@ -4017,6 +4100,8 @@ declare namespace tracer { description?: string config?: Record tags?: Record + /** Number of full experiment runs to execute. Default 1. */ + runs?: number } interface ExperimentRunOptions { @@ -4026,9 +4111,13 @@ declare namespace tracer { retryDelay?: (attempt: number) => number /** Reject on the first task/evaluator error instead of capturing it. Default false. */ throwOnErrors?: boolean + /** Maximum number of task/evaluator executions to process concurrently. Default 10. */ + concurrency?: number } interface PullDatasetOptions { + /** Override the configured project for this dataset pull. */ + projectName?: string /** Dataset version to pull. Defaults to latest. */ version?: number /** Wait until at least this many records are readable (absorbs write lag). */ @@ -4057,17 +4146,21 @@ declare namespace tracer { interface ExperimentRun { runId: string + /** 1-based run iteration. */ runIteration: number + /** Whether this run had a task, row-evaluator, or summary-evaluator error. */ + hasError: boolean rows: ExperimentResultRow[] summaryEvaluations: Record } interface ExperimentResult { experimentId: string + /** Rows from the first run, kept as a compatibility alias. */ rows: ExperimentResultRow[] - /** Single-run summary evaluator results. */ + /** Summary evaluator results from the first run, kept as a compatibility alias. */ summaryEvaluations: Record - /** Experiment runs. P0 Node experiments currently return one run. */ + /** All experiment runs. */ runs: ExperimentRun[] /** Dashboard URL for the experiment. */ url: string @@ -4160,6 +4253,8 @@ declare namespace tracer { metadata?: Record, tags?: string[] ): Dataset + /** Add multiple records to the dataset. */ + addRecords (records: DatasetRecordNew[]): Dataset /** Update fields on an existing dataset record. */ update (index: number, fields: { input?: JSONType @@ -4180,15 +4275,11 @@ declare namespace tracer { description (): string id (): string | null projectId (): string | null + /** Project associated with the client used to create or pull this dataset. */ + projectName (): string | null | undefined version (): number | null latestVersion (): number | null - records (): Array<{ - id: string | null, - input: JSONType, - expectedOutput: JSONType, - metadata: Record, - tags: string[] - }> + records (): DatasetRecord[] /** Return the tags used to filter this dataset. */ filterTags (): string[] /** Dashboard URL for the dataset, or null until pushed. */ @@ -4706,6 +4797,13 @@ declare namespace tracer { * Options for enabling LLM Observability tracing. */ interface LLMObsEnableOptions { + /** + * The name of the LLM Observability project used for experiments. + * @env DD_LLMOBS_PROJECT_NAME + * Programmatic configuration takes precedence over the environment variables listed above. + */ + projectName?: string, + /** * The name of your ML application. * @env DD_LLMOBS_ML_APP @@ -4729,6 +4827,10 @@ declare namespace tracer { */ sampleRate?: number, } + + /** Options accepted by the deprecated runtime `llmobs.enable()` method. */ + type LLMObsRuntimeEnableOptions = Omit + /** @hidden */ type spanKind = 'agent' | 'workflow' | 'task' | 'tool' | 'retrieval' | 'embedding' | 'llm' } diff --git a/integration-tests/ci-visibility/automatic-log-submission-cucumber/support/logger.js b/integration-tests/ci-visibility/automatic-log-submission-cucumber/support/logger.js index f235dd12402..4d8df0a525d 100644 --- a/integration-tests/ci-visibility/automatic-log-submission-cucumber/support/logger.js +++ b/integration-tests/ci-visibility/automatic-log-submission-cucumber/support/logger.js @@ -1,12 +1,3 @@ 'use strict' -const { createLogger, format, transports } = require('winston') - -module.exports = createLogger({ - level: 'info', - exitOnError: false, - format: format.json(), - transports: [ - new transports.Console(), - ], -}) +module.exports = require('../../automatic-log-submission/logger') diff --git a/integration-tests/ci-visibility/automatic-log-submission-cucumber/support/steps.js b/integration-tests/ci-visibility/automatic-log-submission-cucumber/support/steps.js index 4cfbd9e7288..cc173e0f237 100644 --- a/integration-tests/ci-visibility/automatic-log-submission-cucumber/support/steps.js +++ b/integration-tests/ci-visibility/automatic-log-submission-cucumber/support/steps.js @@ -12,5 +12,5 @@ Then('I should have made a log', async function () { }) When('we run a test', async function () { - logger.log('info', 'Hello simple log!') + logger.info('Hello simple log!') }) diff --git a/integration-tests/ci-visibility/automatic-log-submission-cucumber/support/sum.js b/integration-tests/ci-visibility/automatic-log-submission-cucumber/support/sum.js index f9836281a39..a343188cba5 100644 --- a/integration-tests/ci-visibility/automatic-log-submission-cucumber/support/sum.js +++ b/integration-tests/ci-visibility/automatic-log-submission-cucumber/support/sum.js @@ -3,6 +3,6 @@ const logger = require('./logger') module.exports = function (a, b) { - logger.log('info', 'sum function being called') + logger.info('sum function being called') return a + b } diff --git a/integration-tests/ci-visibility/automatic-log-submission-playwright-multiple-groups/first-test.js b/integration-tests/ci-visibility/automatic-log-submission-playwright-multiple-groups/first-test.js new file mode 100644 index 00000000000..3a4522d16f7 --- /dev/null +++ b/integration-tests/ci-visibility/automatic-log-submission-playwright-multiple-groups/first-test.js @@ -0,0 +1,10 @@ +'use strict' + +const { test, expect } = require('@playwright/test') +const logger = require('../automatic-log-submission/logger') + +test('first group', async () => { + logger.info('first group log') + const response = await fetch(`${process.env.LOG_SUBMISSION_CONTROL_URL}/wait-for-first-log`) + expect(response.ok).toBe(true) +}) diff --git a/integration-tests/ci-visibility/automatic-log-submission-playwright-multiple-groups/second-test.js b/integration-tests/ci-visibility/automatic-log-submission-playwright-multiple-groups/second-test.js new file mode 100644 index 00000000000..ed8bcb727f7 --- /dev/null +++ b/integration-tests/ci-visibility/automatic-log-submission-playwright-multiple-groups/second-test.js @@ -0,0 +1,10 @@ +'use strict' + +const { test, expect } = require('@playwright/test') +const logger = require('../automatic-log-submission/logger') + +test('second group', async () => { + logger.info('second group log') + const response = await fetch(`${process.env.LOG_SUBMISSION_CONTROL_URL}/second-group-started`) + expect(response.ok).toBe(true) +}) diff --git a/integration-tests/ci-visibility/automatic-log-submission-playwright/automatic-log-submission-test.js b/integration-tests/ci-visibility/automatic-log-submission-playwright/automatic-log-submission-test.js index 864ce722ca6..9f13bed28cc 100644 --- a/integration-tests/ci-visibility/automatic-log-submission-playwright/automatic-log-submission-test.js +++ b/integration-tests/ci-visibility/automatic-log-submission-playwright/automatic-log-submission-test.js @@ -11,7 +11,7 @@ test.beforeEach(async ({ page }) => { test.describe('playwright', () => { test('should be able to log to the console', async ({ page }) => { await test.step('log to the console', async () => { - logger.log('info', 'Hello simple log!') + logger.info('Hello simple log!') }) expect(sum(1, 2)).toEqual(3) diff --git a/integration-tests/ci-visibility/automatic-log-submission-playwright/logger.js b/integration-tests/ci-visibility/automatic-log-submission-playwright/logger.js index f235dd12402..1ae793d9ab1 100644 --- a/integration-tests/ci-visibility/automatic-log-submission-playwright/logger.js +++ b/integration-tests/ci-visibility/automatic-log-submission-playwright/logger.js @@ -1,12 +1,3 @@ 'use strict' -const { createLogger, format, transports } = require('winston') - -module.exports = createLogger({ - level: 'info', - exitOnError: false, - format: format.json(), - transports: [ - new transports.Console(), - ], -}) +module.exports = require('../automatic-log-submission/logger') diff --git a/integration-tests/ci-visibility/automatic-log-submission-playwright/sum.js b/integration-tests/ci-visibility/automatic-log-submission-playwright/sum.js index f9836281a39..a343188cba5 100644 --- a/integration-tests/ci-visibility/automatic-log-submission-playwright/sum.js +++ b/integration-tests/ci-visibility/automatic-log-submission-playwright/sum.js @@ -3,6 +3,6 @@ const logger = require('./logger') module.exports = function (a, b) { - logger.log('info', 'sum function being called') + logger.info('sum function being called') return a + b } diff --git a/integration-tests/ci-visibility/automatic-log-submission-vitest/config.mjs b/integration-tests/ci-visibility/automatic-log-submission-vitest/config.mjs new file mode 100644 index 00000000000..cb0744e8bd1 --- /dev/null +++ b/integration-tests/ci-visibility/automatic-log-submission-vitest/config.mjs @@ -0,0 +1,7 @@ +export default { + test: { + disableConsoleIntercept: true, + include: ['ci-visibility/automatic-log-submission-vitest/test.mjs'], + pool: 'forks', + }, +} diff --git a/integration-tests/ci-visibility/automatic-log-submission-vitest/test.mjs b/integration-tests/ci-visibility/automatic-log-submission-vitest/test.mjs new file mode 100644 index 00000000000..224acba75cf --- /dev/null +++ b/integration-tests/ci-visibility/automatic-log-submission-vitest/test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' + +import { describe, it } from 'vitest' + +const require = createRequire(import.meta.url) +const logger = require('../automatic-log-submission/logger') +const sum = require('../automatic-log-submission/sum') + +describe('test', () => { + it('should return true', () => { + logger.info('Hello simple log!') + + assert.strictEqual(sum(1, 2), 3) + }) +}) diff --git a/integration-tests/ci-visibility/automatic-log-submission.spec.js b/integration-tests/ci-visibility/automatic-log-submission.spec.js index 9619eb3f6f6..5f80d811ab6 100644 --- a/integration-tests/ci-visibility/automatic-log-submission.spec.js +++ b/integration-tests/ci-visibility/automatic-log-submission.spec.js @@ -3,6 +3,7 @@ const assert = require('assert') const { exec } = require('child_process') const { once } = require('events') +const http = require('http') const { sandboxCwd, @@ -19,6 +20,7 @@ const webAppServer = require('./web-app-server') const isLatestCucumberSupported = NODE_MAJOR === 22 || NODE_MAJOR === 24 || NODE_MAJOR >= 26 const playwrightDependency = `@playwright/test@${getLatestPlaywrightSpecifier()}` +const vitestDependency = NODE_MAJOR <= 18 ? 'vitest@3.2.6' : 'vitest' describe('test optimization automatic log submission', () => { let cwd, receiver, childProcess, webAppPort @@ -27,7 +29,10 @@ describe('test optimization automatic log submission', () => { useSandbox([ 'mocha', ...(isLatestCucumberSupported ? ['@cucumber/cucumber'] : []), + 'bunyan', 'jest', + 'pino', + vitestDependency, 'winston', playwrightDependency, ], true) @@ -65,19 +70,40 @@ describe('test optimization automatic log submission', () => { const testFrameworks = [ { name: 'mocha', - command: 'mocha ./ci-visibility/automatic-log-submission/automatic-log-submission-test.js', + command: './node_modules/.bin/mocha ./ci-visibility/automatic-log-submission/automatic-log-submission-test.js', + loggerNames: ['winston', 'bunyan', 'pino'], + }, + { + name: 'vitest', + command: './node_modules/.bin/vitest run --config ./ci-visibility/automatic-log-submission-vitest/config.mjs', + loggerNames: ['winston', 'bunyan', 'pino'], + getExtraEnvVars: () => ({ + NODE_OPTIONS: '--import dd-trace/register.js -r dd-trace/ci/init', + }), }, { name: 'jest', command: 'node ./node_modules/jest/bin/jest --config ./ci-visibility/automatic-log-submission/config-jest.js', + loggerNames: ['winston', 'bunyan', 'pino'], + }, + { + name: 'jest ESM', + command: 'node --experimental-vm-modules ./node_modules/jest/bin/jest ' + + '--config ./ci-visibility/automatic-log-submission/config-jest.js', + loggerNames: ['winston', 'bunyan', 'pino'], + getExtraEnvVars: () => ({ + TEST_MODULE_TYPE: 'esm', + }), }, { name: 'cucumber', command: './node_modules/.bin/cucumber-js ci-visibility/automatic-log-submission-cucumber/*.feature', + loggerNames: ['winston', 'bunyan', 'pino'], }, { name: 'playwright', command: './node_modules/.bin/playwright test -c playwright.config.js', + loggerNames: ['winston', 'bunyan', 'pino'], getExtraEnvVars: () => ({ PW_BASE_URL: `http://localhost:${webAppPort}`, TEST_DIR: 'ci-visibility/automatic-log-submission-playwright', @@ -86,10 +112,20 @@ describe('test optimization automatic log submission', () => { }, ] - testFrameworks.forEach(({ name, command, getExtraEnvVars = () => ({}) }) => { + const loggers = { + bunyan: { level: 30, messageKey: 'msg' }, + pino: { level: 30, messageKey: 'msg' }, + winston: { level: 'info', messageKey: 'message' }, + } + + testFrameworks.flatMap(framework => { + return (framework.loggerNames || ['winston']).map(loggerName => ({ ...framework, loggerName })) + }).forEach(({ name, command, getExtraEnvVars = () => ({}), loggerName }) => { if (!isLatestCucumberSupported && name === 'cucumber') return - context(`with ${name}`, () => { + const { level: expectedLevel, messageKey } = loggers[loggerName] + + context(`with ${loggerName} and ${name}`, () => { it('can automatically submit logs', async () => { let logIds = {} let testIds = {} @@ -98,23 +134,29 @@ describe('test optimization automatic log submission', () => { .gatherPayloadsMaxTimeout(({ url }) => url.includes('/api/v2/logs'), payloads => { payloads.forEach(({ headers }) => { assert.equal(headers['dd-api-key'], '1') + assert.equal(headers['content-type'], 'application/json') }) + assert.equal(payloads.length, 1) const logMessages = payloads.flatMap(({ logMessage }) => logMessage) const [url] = payloads.flatMap(({ url }) => url) - assert.equal(url, '/api/v2/logs?ddsource=winston&service=my-service') + assert.equal(url, `/api/v2/logs?ddsource=${loggerName}&service=my-service`) assert.equal(logMessages.length, 2) logMessages.forEach(({ dd, level }) => { - assert.equal(level, 'info') + assert.equal(level, expectedLevel) assert.equal(dd.service, 'my-service') assert.deepStrictEqual(['service', 'span_id', 'trace_id'], Object.keys(dd).sort()) }) - assertObjectContains(logMessages.map(({ message }) => message), [ + assertObjectContains(logMessages.map(logMessage => logMessage[messageKey]), [ 'Hello simple log!', 'sum function being called', ]) + if (loggerName === 'winston' && (name === 'mocha' || name.startsWith('jest'))) { + const circularLog = logMessages.find(({ message }) => message === 'Hello simple log!') + assert.equal(circularLog.circular.self, '[Circular]') + } logIds = { logSpanId: logMessages[0].dd.span_id, @@ -142,6 +184,7 @@ describe('test optimization automatic log submission', () => { DD_AGENTLESS_LOG_SUBMISSION_URL: `http://localhost:${receiver.port}`, DD_API_KEY: '1', DD_SERVICE: 'my-service', + TEST_LOGGER: loggerName, ...getExtraEnvVars(), }, } @@ -183,6 +226,7 @@ describe('test optimization automatic log submission', () => { ...getCiVisAgentlessConfig(receiver.port), DD_AGENTLESS_LOG_SUBMISSION_URL: `http://localhost:${receiver.port}`, DD_SERVICE: 'my-service', + TEST_LOGGER: loggerName, ...getExtraEnvVars(), }, } @@ -224,6 +268,7 @@ describe('test optimization automatic log submission', () => { DD_TRACE_DEBUG: '1', DD_TRACE_LOG_LEVEL: 'warn', DD_API_KEY: '', + TEST_LOGGER: loggerName, ...getExtraEnvVars(), }, } @@ -247,4 +292,101 @@ describe('test optimization automatic log submission', () => { }) }) }) + + context('with bunyan and multiple playwright test groups', () => { + it('waits for pending requests only when the worker exits', async () => { + const logMessages = [] + let firstLogResponse + let firstLogRequestAborted = false + let waitingTestResponse + + const respond = (response) => { + if (response.destroyed || response.writableEnded) return + + response.writeHead(200) + response.end('OK') + } + const logsServer = http.createServer((request, response) => { + if (request.method === 'GET' && request.url === '/wait-for-first-log') { + if (firstLogResponse) respond(response) + else waitingTestResponse = () => respond(response) + return + } + if (request.method === 'GET' && request.url === '/second-group-started') { + firstLogResponse() + respond(response) + return + } + if (request.method !== 'POST' || !request.url.startsWith('/api/v2/logs')) { + response.writeHead(404) + response.end() + return + } + + let body = '' + request.setEncoding('utf8') + request.on('data', chunk => { + body += chunk + }) + request.on('end', () => { + logMessages.push(...JSON.parse(body)) + if (firstLogResponse) { + respond(response) + return + } + + response.once('close', () => { + if (!response.writableEnded) firstLogRequestAborted = true + }) + firstLogResponse = () => respond(response) + waitingTestResponse?.() + }) + }) + await new Promise((resolve, reject) => { + logsServer.once('error', reject) + logsServer.listen(0, resolve) + }) + + try { + const { port } = logsServer.address() + childProcess = exec('./node_modules/.bin/playwright test -c playwright.config.js', { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + DD_AGENTLESS_LOG_SUBMISSION_ENABLED: '1', + DD_AGENTLESS_LOG_SUBMISSION_URL: `http://localhost:${port}`, + DD_API_KEY: '1', + DD_SERVICE: 'my-service', + LOG_SUBMISSION_CONTROL_URL: `http://localhost:${port}`, + PLAYWRIGHT_WORKERS: '1', + TEST_DIR: 'ci-visibility/automatic-log-submission-playwright-multiple-groups', + TEST_LOGGER: 'bunyan', + }, + }) + childProcess.stdout?.on('data', (chunk) => { + testOutput += chunk.toString() + }) + childProcess.stderr?.on('data', (chunk) => { + testOutput += chunk.toString() + }) + + await Promise.all([ + once(childProcess, 'exit'), + once(childProcess.stdout, 'end'), + once(childProcess.stderr, 'end'), + ]) + } finally { + firstLogResponse?.() + waitingTestResponse?.() + await new Promise(resolve => logsServer.close(resolve)) + } + + assert.equal(childProcess.exitCode, 0, testOutput) + assert.equal(firstLogRequestAborted, false) + assert.deepStrictEqual(logMessages.map(({ msg }) => msg).sort(), [ + 'first group log', + 'second group log', + ]) + }) + }) }) diff --git a/integration-tests/ci-visibility/automatic-log-submission/automatic-log-submission-esm-test.mjs b/integration-tests/ci-visibility/automatic-log-submission/automatic-log-submission-esm-test.mjs new file mode 100644 index 00000000000..cbaf487cb68 --- /dev/null +++ b/integration-tests/ci-visibility/automatic-log-submission/automatic-log-submission-esm-test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' + +import { describe, it } from '@jest/globals' +import bunyan from 'bunyan' +import pino from 'pino' +import winston from 'winston' + +const loggerName = process.env.TEST_LOGGER +const logger = loggerName === 'bunyan' + ? bunyan.createLogger({ name: 'test-logger' }) + : loggerName === 'pino' + ? pino({ level: 'info' }) + : winston.createLogger({ + level: 'info', + exitOnError: false, + format: winston.format.json(), + transports: [ + new winston.transports.Console(), + ], + }) + +describe('test', () => { + it('should return true', () => { + if (loggerName === 'winston') { + const circular = {} + circular.self = circular + logger.log('info', 'Hello simple log!', { circular }) + } else { + logger.info('Hello simple log!') + } + + logger.info('sum function being called') + assert.strictEqual(true, true) + }) +}) diff --git a/integration-tests/ci-visibility/automatic-log-submission/automatic-log-submission-test.js b/integration-tests/ci-visibility/automatic-log-submission/automatic-log-submission-test.js index 82317d1d328..a1ff590dade 100644 --- a/integration-tests/ci-visibility/automatic-log-submission/automatic-log-submission-test.js +++ b/integration-tests/ci-visibility/automatic-log-submission/automatic-log-submission-test.js @@ -6,7 +6,13 @@ const logger = require('./logger') const sum = require('./sum') describe('test', () => { it('should return true', () => { - logger.log('info', 'Hello simple log!') + if (process.env.TEST_LOGGER === 'winston') { + const circular = {} + circular.self = circular + logger.log('info', 'Hello simple log!', { circular }) + } else { + logger.info('Hello simple log!') + } assert.strictEqual(true, true) assert.strictEqual(sum(1, 2), 3) diff --git a/integration-tests/ci-visibility/automatic-log-submission/config-jest.js b/integration-tests/ci-visibility/automatic-log-submission/config-jest.js index 3060b00b6a5..2915afd1cc7 100644 --- a/integration-tests/ci-visibility/automatic-log-submission/config-jest.js +++ b/integration-tests/ci-visibility/automatic-log-submission/config-jest.js @@ -5,7 +5,9 @@ module.exports = { testPathIgnorePatterns: ['/node_modules/'], cache: false, testMatch: [ - '**/ci-visibility/automatic-log-submission/automatic-log-submission-*', + process.env.TEST_MODULE_TYPE === 'esm' + ? '**/ci-visibility/automatic-log-submission/automatic-log-submission-esm-test.mjs' + : '**/ci-visibility/automatic-log-submission/automatic-log-submission-test.js', ], testRunner: 'jest-circus/runner', testEnvironment: 'node', diff --git a/integration-tests/ci-visibility/automatic-log-submission/logger.js b/integration-tests/ci-visibility/automatic-log-submission/logger.js index f235dd12402..f73e672992d 100644 --- a/integration-tests/ci-visibility/automatic-log-submission/logger.js +++ b/integration-tests/ci-visibility/automatic-log-submission/logger.js @@ -1,12 +1,21 @@ 'use strict' -const { createLogger, format, transports } = require('winston') +let logger -module.exports = createLogger({ - level: 'info', - exitOnError: false, - format: format.json(), - transports: [ - new transports.Console(), - ], -}) +if (process.env.TEST_LOGGER === 'bunyan') { + logger = require('bunyan').createLogger({ name: 'test-logger' }) +} else if (process.env.TEST_LOGGER === 'pino') { + logger = require('pino')({ level: 'info' }) +} else { + const { createLogger, format, transports } = require('winston') + logger = createLogger({ + level: 'info', + exitOnError: false, + format: format.json(), + transports: [ + new transports.Console(), + ], + }) +} + +module.exports = logger diff --git a/integration-tests/ci-visibility/automatic-log-submission/sum.js b/integration-tests/ci-visibility/automatic-log-submission/sum.js index f9836281a39..a343188cba5 100644 --- a/integration-tests/ci-visibility/automatic-log-submission/sum.js +++ b/integration-tests/ci-visibility/automatic-log-submission/sum.js @@ -3,6 +3,6 @@ const logger = require('./logger') module.exports = function (a, b) { - logger.log('info', 'sum function being called') + logger.info('sum function being called') return a + b } diff --git a/integration-tests/ci-visibility/jest-mock-bypass-require/bunyan-mock-test.js b/integration-tests/ci-visibility/jest-mock-bypass-require/bunyan-mock-test.js new file mode 100644 index 00000000000..fb87f2932bf --- /dev/null +++ b/integration-tests/ci-visibility/jest-mock-bypass-require/bunyan-mock-test.js @@ -0,0 +1,18 @@ +'use strict' + +const bunyan = require('bunyan') + +jest.mock('bunyan', () => ({ + createLogger: jest.fn(() => ({ + info: jest.fn(), + })), +})) + +describe('bunyan mock test', () => { + it('uses the Bunyan mock', () => { + const logger = bunyan.createLogger() + logger.info('test') + expect(bunyan.createLogger).toHaveBeenCalledTimes(1) + expect(logger.info).toHaveBeenCalledTimes(1) + }) +}) diff --git a/integration-tests/ci-visibility/jest-mock-bypass-require/esm-linked-logger-test.mjs b/integration-tests/ci-visibility/jest-mock-bypass-require/esm-linked-logger-test.mjs new file mode 100644 index 00000000000..efd54b9441c --- /dev/null +++ b/integration-tests/ci-visibility/jest-mock-bypass-require/esm-linked-logger-test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' + +import { describe, it } from '@jest/globals' +import winston from 'winston' + +const logger = winston.createLogger({ + level: 'info', + format: winston.format.json(), + transports: [ + new winston.transports.Console(), + ], +}) + +describe('linked Winston ESM import', () => { + it('submits logs', () => { + logger.info('linked logger') + assert.strictEqual(true, true) + }) +}) diff --git a/integration-tests/ci-visibility/jest-mock-bypass-require/esm-mapped-logger-test.mjs b/integration-tests/ci-visibility/jest-mock-bypass-require/esm-mapped-logger-test.mjs new file mode 100644 index 00000000000..b05d7831652 --- /dev/null +++ b/integration-tests/ci-visibility/jest-mock-bypass-require/esm-mapped-logger-test.mjs @@ -0,0 +1,8 @@ +import { describe, expect, it } from '@jest/globals' +import loggerModule from 'winston' + +describe('Winston ESM resolution', () => { + it('uses the mapped logger', () => { + expect(loggerModule).toEqual({ mapped: true }) + }) +}) diff --git a/integration-tests/ci-visibility/jest-mock-bypass-require/esm-mock-test.mjs b/integration-tests/ci-visibility/jest-mock-bypass-require/esm-mock-test.mjs new file mode 100644 index 00000000000..76bfbd2bd00 --- /dev/null +++ b/integration-tests/ci-visibility/jest-mock-bypass-require/esm-mock-test.mjs @@ -0,0 +1,20 @@ +import { describe, expect, it, jest } from '@jest/globals' + +const loggerName = process.env.TEST_LOGGER +const logger = { info: jest.fn() } +const createLogger = jest.fn(() => logger) +const defaultExport = loggerName === 'pino' ? createLogger : { createLogger } + +jest.unstable_mockModule(loggerName, () => ({ default: defaultExport })) + +const { default: loggerModule } = await import(loggerName) + +describe(`${loggerName} ESM mock test`, () => { + it('uses the logger mock', () => { + const mockedLogger = loggerName === 'pino' ? loggerModule() : loggerModule.createLogger() + mockedLogger.info('test') + + expect(createLogger).toHaveBeenCalledTimes(1) + expect(logger.info).toHaveBeenCalledTimes(1) + }) +}) diff --git a/integration-tests/ci-visibility/jest-mock-bypass-require/esm-unrelated-cjs-test.mjs b/integration-tests/ci-visibility/jest-mock-bypass-require/esm-unrelated-cjs-test.mjs new file mode 100644 index 00000000000..955c9769c99 --- /dev/null +++ b/integration-tests/ci-visibility/jest-mock-bypass-require/esm-unrelated-cjs-test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict' + +import { describe, it } from '@jest/globals' +import mappedLogger from './mapped-logger.js' + +describe('unrelated CommonJS ESM import', () => { + it('loads through Jest', () => { + assert.deepStrictEqual(mappedLogger, { mapped: true }) + }) +}) diff --git a/integration-tests/ci-visibility/jest-mock-bypass-require/logger-resolver.js b/integration-tests/ci-visibility/jest-mock-bypass-require/logger-resolver.js new file mode 100644 index 00000000000..d3142e2d4ae --- /dev/null +++ b/integration-tests/ci-visibility/jest-mock-bypass-require/logger-resolver.js @@ -0,0 +1,15 @@ +'use strict' + +const path = require('node:path') + +/** + * @param {string} request + * @param {{ defaultResolver: (request: string, options: object) => string }} options + * @returns {string} + */ +module.exports = function loggerResolver (request, options) { + if (request === process.env.TEST_LOGGER) { + return path.join(__dirname, 'mapped-logger.js') + } + return options.defaultResolver(request, options) +} diff --git a/integration-tests/ci-visibility/jest-mock-bypass-require/mapped-logger-test.js b/integration-tests/ci-visibility/jest-mock-bypass-require/mapped-logger-test.js new file mode 100644 index 00000000000..51a4a58e3fe --- /dev/null +++ b/integration-tests/ci-visibility/jest-mock-bypass-require/mapped-logger-test.js @@ -0,0 +1,9 @@ +'use strict' + +const loggerModule = require(process.env.TEST_LOGGER) + +describe(`${process.env.TEST_LOGGER} mapped logger test`, () => { + it('uses the mapped logger', () => { + expect(loggerModule).toEqual({ mapped: true }) + }) +}) diff --git a/integration-tests/ci-visibility/jest-mock-bypass-require/mapped-logger.js b/integration-tests/ci-visibility/jest-mock-bypass-require/mapped-logger.js new file mode 100644 index 00000000000..21d789fcdbf --- /dev/null +++ b/integration-tests/ci-visibility/jest-mock-bypass-require/mapped-logger.js @@ -0,0 +1,3 @@ +'use strict' + +module.exports = { mapped: true } diff --git a/integration-tests/ci-visibility/jest-mock-bypass-require/pino-mock-test.js b/integration-tests/ci-visibility/jest-mock-bypass-require/pino-mock-test.js new file mode 100644 index 00000000000..7f04dd10013 --- /dev/null +++ b/integration-tests/ci-visibility/jest-mock-bypass-require/pino-mock-test.js @@ -0,0 +1,16 @@ +'use strict' + +const pino = require('pino') + +jest.mock('pino', () => jest.fn(() => ({ + info: jest.fn(), +}))) + +describe('pino mock test', () => { + it('uses the Pino mock', () => { + const logger = pino() + logger.info('test') + expect(pino).toHaveBeenCalledTimes(1) + expect(logger.info).toHaveBeenCalledTimes(1) + }) +}) diff --git a/integration-tests/ci-visibility/jest-mock-bypass-require/test-sequencer.js b/integration-tests/ci-visibility/jest-mock-bypass-require/test-sequencer.js new file mode 100644 index 00000000000..402e068ee78 --- /dev/null +++ b/integration-tests/ci-visibility/jest-mock-bypass-require/test-sequencer.js @@ -0,0 +1,13 @@ +'use strict' + +const Sequencer = require('@jest/test-sequencer').default + +module.exports = class TestSequencer extends Sequencer { + /** + * @param {Array<{ path: string }>} tests + * @returns {Array<{ path: string }>} + */ + sort (tests) { + return tests.sort((a, b) => a.path.localeCompare(b.path)) + } +} diff --git a/integration-tests/ci-visibility/jest-mock-bypass-require/track-logger-resolution.js b/integration-tests/ci-visibility/jest-mock-bypass-require/track-logger-resolution.js new file mode 100644 index 00000000000..3cd85db442f --- /dev/null +++ b/integration-tests/ci-visibility/jest-mock-bypass-require/track-logger-resolution.js @@ -0,0 +1,18 @@ +'use strict' + +const Module = require('node:module') + +const loggerNames = new Set(['bunyan', 'pino', 'winston']) +const resolveFilename = Module._resolveFilename + +/** + * @param {string} request + * @param {{ filename?: string }} [parent] + * @returns {string} + */ +Module._resolveFilename = function (request, parent) { + if (loggerNames.has(request) && parent?.filename?.endsWith('esm-unrelated-cjs-test.mjs')) { + process.stderr.write(`[unexpected logger resolution] ${request}\n`) + } + return resolveFilename.apply(this, arguments) +} diff --git a/integration-tests/ci-visibility/jest-mock-bypass-require/z-real-logger-test.js b/integration-tests/ci-visibility/jest-mock-bypass-require/z-real-logger-test.js new file mode 100644 index 00000000000..43c6ff7cee2 --- /dev/null +++ b/integration-tests/ci-visibility/jest-mock-bypass-require/z-real-logger-test.js @@ -0,0 +1,12 @@ +'use strict' + +const loggerName = process.env.TEST_LOGGER +const logger = loggerName === 'pino' + ? require('pino')() + : require('bunyan').createLogger({ name: 'test-logger' }) + +describe(`${loggerName} real logger test`, () => { + it('uses the real logger after another suite mocks it', () => { + logger.info('real logger after mock') + }) +}) diff --git a/integration-tests/ci-visibility/jest-plugin-tests/jest-test.js b/integration-tests/ci-visibility/jest-plugin-tests/jest-test.js index 5829f90ca14..d76acdceb04 100644 --- a/integration-tests/ci-visibility/jest-plugin-tests/jest-test.js +++ b/integration-tests/ci-visibility/jest-plugin-tests/jest-test.js @@ -95,6 +95,7 @@ describe('jest-test-suite', () => { it('fails', () => { assert.deepStrictEqual(true, false) }) + // The callback keeps the Jest test open until the scheduled error is attributed to it. // eslint-disable-next-line mocha/handle-done-callback it('does not crash with missing stack', (done) => { setTimeout(() => { @@ -104,6 +105,7 @@ describe('jest-test-suite', () => { }, 100) }) + // This skipped case is fixture input for Test Optimization status reporting. it.skip('skips', () => { assert.deepStrictEqual(100, 100) }) diff --git a/integration-tests/ci-visibility/mocha-plugin-tests/skip-describe.js b/integration-tests/ci-visibility/mocha-plugin-tests/skip-describe.js index 0229001a5aa..c36eac97ca6 100644 --- a/integration-tests/ci-visibility/mocha-plugin-tests/skip-describe.js +++ b/integration-tests/ci-visibility/mocha-plugin-tests/skip-describe.js @@ -3,6 +3,7 @@ const assert = require('assert') describe('mocha-test-skip-describe', () => { before(function () { + // This suite-level skip is fixture input for Test Optimization status reporting. this.skip() }) diff --git a/integration-tests/ci-visibility/mocha-plugin-tests/skipping-with-after-each.js b/integration-tests/ci-visibility/mocha-plugin-tests/skipping-with-after-each.js index a08e3dff1ba..0293d213e8b 100644 --- a/integration-tests/ci-visibility/mocha-plugin-tests/skipping-with-after-each.js +++ b/integration-tests/ci-visibility/mocha-plugin-tests/skipping-with-after-each.js @@ -6,5 +6,6 @@ describe('mocha-reporter-pending-after-each', () => { console.log('MOCHA AFTER EACH EXECUTED') }) + // This skip is fixture input for reporter hook-order coverage. it.skip('can skip', () => {}) }) diff --git a/integration-tests/ci-visibility/mocha-plugin-tests/skipping.js b/integration-tests/ci-visibility/mocha-plugin-tests/skipping.js index 55c75c91752..5c2032b898c 100644 --- a/integration-tests/ci-visibility/mocha-plugin-tests/skipping.js +++ b/integration-tests/ci-visibility/mocha-plugin-tests/skipping.js @@ -2,16 +2,19 @@ const assert = require('assert') describe('mocha-test-skip', () => { + // This skip is fixture input for Test Optimization status reporting. it.skip('can skip', () => { assert.strictEqual(true, false) }) }) describe('mocha-test-skip-different', () => { + // This skip is fixture input for Test Optimization status reporting. it.skip('can skip too', () => { assert.strictEqual(true, false) }) + // This second skip verifies the reported skip cardinality. it.skip('can skip twice', () => { assert.strictEqual(true, false) }) @@ -19,6 +22,7 @@ describe('mocha-test-skip-different', () => { describe('mocha-test-programmatic-skip', () => { it('can skip too', function () { + // This programmatic skip is fixture input for Test Optimization status reporting. this.skip() }) }) diff --git a/integration-tests/ci-visibility/mocha-plugin-tests/suite-level-fail-skip-describe.js b/integration-tests/ci-visibility/mocha-plugin-tests/suite-level-fail-skip-describe.js index 699f90206e8..1fbe4ef3568 100644 --- a/integration-tests/ci-visibility/mocha-plugin-tests/suite-level-fail-skip-describe.js +++ b/integration-tests/ci-visibility/mocha-plugin-tests/suite-level-fail-skip-describe.js @@ -11,6 +11,7 @@ describe('mocha-test-suite-level-fail', function () { }) }) +// This skipped suite is fixture input for suite-level status aggregation. describe.skip('mocha-test-suite-level-skip', function () { it('will pass', () => { assert.strictEqual(2, 2) diff --git a/integration-tests/ci-visibility/mocha-plugin-tests/suite-level-fail-test.js b/integration-tests/ci-visibility/mocha-plugin-tests/suite-level-fail-test.js index 83d93d65269..20940682951 100644 --- a/integration-tests/ci-visibility/mocha-plugin-tests/suite-level-fail-test.js +++ b/integration-tests/ci-visibility/mocha-plugin-tests/suite-level-fail-test.js @@ -16,6 +16,7 @@ describe('mocha-test-suite-level-pass', function () { assert.strictEqual(2, 2) }) + // This skip is fixture input for mixed suite-level status aggregation. it.skip('will skip', () => { assert.strictEqual(2, 2) }) diff --git a/integration-tests/ci-visibility/mocha-plugin-tests/suite-level-pass.js b/integration-tests/ci-visibility/mocha-plugin-tests/suite-level-pass.js index d394e277133..556a6b81a0e 100644 --- a/integration-tests/ci-visibility/mocha-plugin-tests/suite-level-pass.js +++ b/integration-tests/ci-visibility/mocha-plugin-tests/suite-level-pass.js @@ -7,6 +7,7 @@ describe('mocha-test-suite-level-fail', function () { }) }) +// This skipped suite is fixture input for suite-level status aggregation. describe.skip('mocha-test-suite-level-skip', function () { it('will pass', () => { assert.strictEqual(2, 2) diff --git a/integration-tests/ci-visibility/mocha-skips/skip-test.js b/integration-tests/ci-visibility/mocha-skips/skip-test.js index befb0e6d52e..3a1f1f79e79 100644 --- a/integration-tests/ci-visibility/mocha-skips/skip-test.js +++ b/integration-tests/ci-visibility/mocha-skips/skip-test.js @@ -1,5 +1,6 @@ 'use strict' describe('mocha-skips', () => { + // This skip is the fixture behavior asserted by the parent integration test. it.skip('can report skipped tests', () => {}) }) diff --git a/integration-tests/ci-visibility/playwright-efd-failure-screenshot/efd-failure-screenshot-test.js b/integration-tests/ci-visibility/playwright-efd-failure-screenshot/efd-failure-screenshot-test.js new file mode 100644 index 00000000000..e508165d9e9 --- /dev/null +++ b/integration-tests/ci-visibility/playwright-efd-failure-screenshot/efd-failure-screenshot-test.js @@ -0,0 +1,15 @@ +'use strict' + +const { test, expect } = require('@playwright/test') + +test.describe('efd failure screenshot alignment', () => { + test('skips its scheduled retry after running slowly', async () => { + await new Promise(resolve => setTimeout(resolve, 5_100)) + }) + + test('uploads a failure screenshot', async ({ page }) => { + await page.goto(process.env.PW_BASE_URL) + + expect(true).toBe(false) + }) +}) diff --git a/integration-tests/ci-visibility/playwright-flush-error/flush-error-test.js b/integration-tests/ci-visibility/playwright-flush-error/flush-error-test.js new file mode 100644 index 00000000000..713161d0ee5 --- /dev/null +++ b/integration-tests/ci-visibility/playwright-flush-error/flush-error-test.js @@ -0,0 +1,12 @@ +'use strict' + +const tracer = require('dd-trace') +const { expect, test } = require('@playwright/test') + +test('finishes when trace flushing throws', () => { + tracer._tracer._exporter.flush = () => { + throw new Error('test flush failure') + } + + expect(1 + 2).toBe(3) +}) diff --git a/integration-tests/ci-visibility/playwright-tests-screenshot/failure-screenshot-test.js b/integration-tests/ci-visibility/playwright-tests-screenshot/failure-screenshot-test.js index 2f3e6384d2c..ab1d400cb89 100644 --- a/integration-tests/ci-visibility/playwright-tests-screenshot/failure-screenshot-test.js +++ b/integration-tests/ci-visibility/playwright-tests-screenshot/failure-screenshot-test.js @@ -1,6 +1,36 @@ 'use strict' -const { test, expect } = require('@playwright/test') +const { test: base, expect } = require('@playwright/test') + +let releaseDeferredFailureScreenshot +const test = base.extend({ + deferFailureScreenshotAttachment: [async ({ screenshot }, use, testInfo) => { + if (process.env.PLAYWRIGHT_DEFER_FAILURE_SCREENSHOT_ATTACHMENT !== 'true' || screenshot === 'off') { + await use() + return + } + + const originalAttachmentsPush = testInfo.attachments.push.bind(testInfo.attachments) + testInfo.attachments.push = (...attachments) => { + const hasFailureScreenshot = attachments.some(({ name, path }) => + name === 'screenshot' && /test-failed-1\.png$/.test(path ?? '')) + if (hasFailureScreenshot) { + releaseDeferredFailureScreenshot = () => originalAttachmentsPush(...attachments) + return testInfo.attachments.length + attachments.length + } + return originalAttachmentsPush(...attachments) + } + await use() + }, { auto: true }], + // The worker fixture must not depend on Playwright's test-scoped screenshot fixture. + // eslint-disable-next-line no-empty-pattern + releaseDeferredFailureScreenshot: [async ({}, use) => { + await use() + if (releaseDeferredFailureScreenshot) { + setImmediate(releaseDeferredFailureScreenshot) + } + }, { auto: true, scope: 'worker' }], +}) test('does not upload programmatic screenshots', async ({ page }, testInfo) => { await page.goto(process.env.PW_BASE_URL) @@ -8,6 +38,8 @@ test('does not upload programmatic screenshots', async ({ page }, testInfo) => { await page.screenshot({ path: testInfo.outputPath('programmatic-screenshot.png') }) }) +test.skip('does not reserve a worker trace slot for an expected skip', () => {}) + test('uploads only the automatic failure screenshot', async ({ page }, testInfo) => { await page.goto(process.env.PW_BASE_URL) diff --git a/integration-tests/ci-visibility/playwright-tests-test-management/disabled-serial-test.js b/integration-tests/ci-visibility/playwright-tests-test-management/disabled-serial-test.js index 95f98dd8580..479573d5ca1 100644 --- a/integration-tests/ci-visibility/playwright-tests-test-management/disabled-serial-test.js +++ b/integration-tests/ci-visibility/playwright-tests-test-management/disabled-serial-test.js @@ -10,4 +10,12 @@ test.describe.serial('disabled serial retry', () => { test('should not run disabled sibling', () => { throw new Error('SHOULD NOT BE EXECUTED') }) + + if (process.env.FAIL_AFTER_DISABLED === 'true') { + test('uploads screenshot after disabled sibling', async ({ page }) => { + await page.goto(process.env.PW_BASE_URL) + + expect(true).toBe(false) + }) + } }) diff --git a/integration-tests/ci-visibility/run-jest.js b/integration-tests/ci-visibility/run-jest.js index 1881d597635..dd5e1b3892d 100644 --- a/integration-tests/ci-visibility/run-jest.js +++ b/integration-tests/ci-visibility/run-jest.js @@ -40,6 +40,9 @@ function getJestRunArgs (options) { if (options.randomize) { args.push('--randomize', `--seed=${options.seed}`, '--showSeed') } + if (options.testSequencer) { + args.push('--testSequencer', options.testSequencer) + } return args } @@ -122,6 +125,10 @@ if (process.env.JEST_TEST_NAME_PATTERN) { options.testNamePattern = process.env.JEST_TEST_NAME_PATTERN } +if (process.env.TEST_SEQUENCER) { + options.testSequencer = process.env.TEST_SEQUENCER +} + if (process.env.USE_JEST_RUN) { jest.run(getJestRunArgs(options)).catch((error) => { // eslint-disable-next-line no-console diff --git a/integration-tests/ci-visibility/test-early-flake-detection/focused-test.js b/integration-tests/ci-visibility/test-early-flake-detection/focused-test.js index 6bd653c87ee..2e200987001 100644 --- a/integration-tests/ci-visibility/test-early-flake-detection/focused-test.js +++ b/integration-tests/ci-visibility/test-early-flake-detection/focused-test.js @@ -25,6 +25,7 @@ describe.only('early flake detection focused block', () => { }) }) +// This skipped block verifies that Early Flake Detection does not retry skipped tests. describe.skip('early flake detection skipped block', () => { test('new test inside a skipped block', () => { expect(3 + 3).toBe(6) diff --git a/integration-tests/ci-visibility/test-early-flake-detection/skipped-and-todo-test.js b/integration-tests/ci-visibility/test-early-flake-detection/skipped-and-todo-test.js index 3bffff44a34..682b3cc6910 100644 --- a/integration-tests/ci-visibility/test-early-flake-detection/skipped-and-todo-test.js +++ b/integration-tests/ci-visibility/test-early-flake-detection/skipped-and-todo-test.js @@ -10,6 +10,7 @@ describe('ci visibility', () => { it.todo('todo will not be retried') } + // This skip verifies that Early Flake Detection does not retry skipped tests. it.skip('skip will not be retried', () => { assert.strictEqual(1 + 2, 4) }) diff --git a/integration-tests/ci-visibility/test-management/test-attempt-to-fix-skip.js b/integration-tests/ci-visibility/test-management/test-attempt-to-fix-skip.js index 4cc1a849ea8..9cd0b98a06d 100644 --- a/integration-tests/ci-visibility/test-management/test-attempt-to-fix-skip.js +++ b/integration-tests/ci-visibility/test-management/test-attempt-to-fix-skip.js @@ -1,6 +1,7 @@ 'use strict' describe('skipped attempt to fix tests', () => { + // This skip verifies that attempt-to-fix does not retry skipped tests. it.skip('can skip', () => {}) it.todo('can be todo') }) diff --git a/integration-tests/config-jest.js b/integration-tests/config-jest.js index 12640da014d..8164ff8b7e6 100644 --- a/integration-tests/config-jest.js +++ b/integration-tests/config-jest.js @@ -27,6 +27,14 @@ if (process.env.CONFIG_TRANSFORM) { config.transform = JSON.parse(process.env.CONFIG_TRANSFORM) } +if (process.env.CONFIG_MODULE_NAME_MAPPER) { + config.moduleNameMapper = JSON.parse(process.env.CONFIG_MODULE_NAME_MAPPER) +} + +if (process.env.CONFIG_RESOLVER) { + config.resolver = process.env.CONFIG_RESOLVER +} + if (process.env.JEST_THROWING_REPORTER) { config.reporters = ['/ci-visibility/jest-reporter-throws.js'] } diff --git a/integration-tests/cucumber/cucumber.spec.js b/integration-tests/cucumber/cucumber.spec.js index 0b4a672cd8e..326db4fb680 100644 --- a/integration-tests/cucumber/cucumber.spec.js +++ b/integration-tests/cucumber/cucumber.spec.js @@ -842,8 +842,9 @@ describe(`cucumber@${version} commonJS`, () => { }) }) - if (reportMethod === 'agentless' && version !== '7.0.0') { - it('keeps module tags when worker traces arrive before parallel suite start', async () => { + { + const delayedWorkerTest = reportMethod === 'agentless' && version !== '7.0.0' ? it : it.skip + delayedWorkerTest('keeps module tags when worker traces arrive before parallel suite start', async () => { childProcess = exec( parallelModeCommand, { @@ -1420,8 +1421,9 @@ describe(`cucumber@${version} commonJS`, () => { }) }) - if (!isAgentless) { - context('if the agent is not event platform proxy compatible', () => { + { + const evpCompatibilityContext = isAgentless ? context.skip : context + evpCompatibilityContext('if the agent is not event platform proxy compatible', () => { it('does not do any intelligent test runner request', (done) => { receiver.setInfoResponse({ endpoints: [] }) diff --git a/integration-tests/debugger/diagnostics.spec.js b/integration-tests/debugger/diagnostics.spec.js index f76d599795b..2b4f7d6404b 100644 --- a/integration-tests/debugger/diagnostics.spec.js +++ b/integration-tests/debugger/diagnostics.spec.js @@ -2,10 +2,86 @@ const assert = require('assert') const { inspect } = require('node:util') + const { assertObjectContains, assertUUID } = require('../helpers') const { UNACKNOWLEDGED, ACKNOWLEDGED, ERROR } = require('../../packages/dd-trace/src/remote_config/apply_states') const { pollInterval, setup } = require('./utils') +/** + * @param {import('node:events').EventEmitter} agent + * @param {string} configId + * @param {Array} expectedPayloads + * @param {number} expectedAckUpdates + * @param {() => void} [onInstalled] + */ +function expectProbeEvents (agent, configId, expectedPayloads, expectedAckUpdates, onInstalled) { + return new Promise((resolve, reject) => { + let ackUpdates = 0 + let quietPeriod + + /** @param {Error} [error] */ + function finish (error) { + clearTimeout(quietPeriod) + agent.removeListener('debugger-diagnostics', handleDiagnostics) + agent.removeListener('remote-config-ack-update', handleAckUpdate) + if (error) reject(error) + else resolve() + } + + function observeQuietPeriod () { + if (expectedPayloads.length !== 0) return + clearTimeout(quietPeriod) + quietPeriod = setTimeout(() => { + try { + assert.strictEqual(ackUpdates, expectedAckUpdates) + finish() + } catch (error) { + finish(error) + } + }, pollInterval * 2 * 1000) + } + + /** + * @param {string} id + * @param {number} version + * @param {number} state + * @param {string} error + */ + function handleAckUpdate (id, version, state, error) { + if (state === UNACKNOWLEDGED) return + + try { + assert.strictEqual(id, configId) + assert.strictEqual(version, ++ackUpdates) + assert.strictEqual(state, ACKNOWLEDGED) + assert.ok(!error) + observeQuietPeriod() + } catch (error) { + finish(error) + } + } + + /** @param {{ payload: Array }} event */ + function handleDiagnostics ({ payload }) { + try { + for (const event of payload) { + const expected = expectedPayloads.shift() + assert.ok(expected, 'Received an unexpected diagnostics payload') + assertObjectContains(event, expected) + assertUUID(event.debugger.diagnostics.runtimeId) + if (event.debugger.diagnostics.status === 'INSTALLED') onInstalled?.() + } + observeQuietPeriod() + } catch (error) { + finish(error) + } + } + + agent.on('debugger-diagnostics', handleDiagnostics) + agent.on('remote-config-ack-update', handleAckUpdate) + }) +} + describe('Dynamic Instrumentation', function () { const t = setup({ testApp: 'target-app/basic.js', dependencies: ['fastify'] }) @@ -14,8 +90,7 @@ describe('Dynamic Instrumentation', function () { }) describe('diagnostics messages', function () { - it('should send expected diagnostics messages if probe is received and triggered', function (done) { - let receivedAckUpdate = false + it('should send expected diagnostics messages if probe is received and triggered', async function () { const probeId = t.rcConfig.config.id const expectedPayloads = [{ ddsource: 'dd_debugger', @@ -31,48 +106,15 @@ describe('Dynamic Instrumentation', function () { debugger: { diagnostics: { probeId, probeVersion: 0, status: 'EMITTING' } }, }] - t.agent.on('remote-config-ack-update', (id, version, state, error) => { - // Due to the very short DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS, there's a race condition in which we might - // get an UNACKNOWLEDGED state first before the ACKNOWLEDGED state. - if (state === UNACKNOWLEDGED) return - - assert.strictEqual(id, t.rcConfig.id) - assert.strictEqual(version, 1) - assert.strictEqual(state, ACKNOWLEDGED) - assert.ok(!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 }) => { - payload.forEach((event) => { - const expected = expectedPayloads.shift() - assertObjectContains(event, expected) - assertUUID(event.debugger.diagnostics.runtimeId) - - if (event.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() - } - }) - }) - + const breakpointTriggered = t.triggerBreakpoint() + const probeEvents = expectProbeEvents(t.agent, t.rcConfig.id, expectedPayloads, 1) t.agent.addRemoteConfig(t.rcConfig) - - function endIfDone () { - if (receivedAckUpdate && expectedPayloads.length === 0) done() - } + const [response] = await Promise.all([breakpointTriggered, probeEvents]) + assert.strictEqual(response.status, 200) + assert.deepStrictEqual(response.data, { hello: 'bar' }) }) - it('should send expected diagnostics messages if probe is first received and then updated', function (done) { - let receivedAckUpdates = 0 + it('should send expected diagnostics messages if probe is first received and then updated', async function () { const probeId = t.rcConfig.config.id const expectedPayloads = [{ ddsource: 'dd_debugger', @@ -99,43 +141,17 @@ describe('Dynamic Instrumentation', function () { () => {}, ] - t.agent.on('remote-config-ack-update', (id, version, state, error) => { - // Due to the very short DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS, there's a race condition in which we might - // get an UNACKNOWLEDGED state first before the ACKNOWLEDGED state. - if (state === UNACKNOWLEDGED) return - - assert.strictEqual(id, t.rcConfig.id) - assert.strictEqual(version, ++receivedAckUpdates) - assert.strictEqual(state, ACKNOWLEDGED) - assert.ok(!error) // falsy check since error will be an empty string, but that's an implementation detail - - endIfDone() - }) - - t.agent.on('debugger-diagnostics', ({ payload }) => { - payload.forEach((event) => { - const expected = expectedPayloads.shift() - assertObjectContains(event, expected) - assertUUID(event.debugger.diagnostics.runtimeId) - if (event.debugger.diagnostics.status === 'INSTALLED') { - const trigger = triggers.shift() - assert.ok(trigger, 'expecting a trigger function to be defined') - trigger() - } - endIfDone() - }) + const probeEvents = expectProbeEvents(t.agent, t.rcConfig.id, expectedPayloads, 2, () => { + const trigger = triggers.shift() + assert.ok(trigger, 'expecting a trigger function to be defined') + trigger() }) t.agent.addRemoteConfig(t.rcConfig) - - function endIfDone () { - if (receivedAckUpdates === 2 && expectedPayloads.length === 0) done() - } + await probeEvents }) - it('should send expected diagnostics messages if probe is first received and then deleted', function (done) { - let receivedAckUpdate = false - let payloadsProcessed = false + it('should send expected diagnostics messages if probe is first received and then deleted', async function () { const probeId = t.rcConfig.config.id const expectedPayloads = [{ ddsource: 'dd_debugger', @@ -147,42 +163,12 @@ describe('Dynamic Instrumentation', function () { debugger: { diagnostics: { probeId, probeVersion: 0, status: 'INSTALLED' } }, }] - t.agent.on('remote-config-ack-update', (id, version, state, error) => { - // Due to the very short DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS, there's a race condition in which we might - // get an UNACKNOWLEDGED state first before the ACKNOWLEDGED state. - if (state === UNACKNOWLEDGED) return - - assert.strictEqual(id, t.rcConfig.id) - assert.strictEqual(version, 1) - assert.strictEqual(state, ACKNOWLEDGED) - assert.ok(!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 }) => { - payload.forEach((event) => { - const expected = expectedPayloads.shift() - assertObjectContains(event, expected) - assertUUID(event.debugger.diagnostics.runtimeId) - - if (event.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 - } - }) + const probeEvents = expectProbeEvents(t.agent, t.rcConfig.id, expectedPayloads, 1, () => { + t.agent.removeRemoteConfig(t.rcConfig.id) }) t.agent.addRemoteConfig(t.rcConfig) - - function endIfDone () { - if (receivedAckUpdate && payloadsProcessed) done() - } + await probeEvents }) it( diff --git a/integration-tests/debugger/input-messages.spec.js b/integration-tests/debugger/input-messages.spec.js index b8abe0c809d..0e04edc80c1 100644 --- a/integration-tests/debugger/input-messages.spec.js +++ b/integration-tests/debugger/input-messages.spec.js @@ -1,6 +1,9 @@ 'use strict' const assert = require('assert') +const { on, once } = require('node:events') +const { setTimeout: delay } = require('node:timers/promises') + const { pollInterval, setup, testBasicInput } = require('./utils') describe('Dynamic Instrumentation', function () { @@ -9,60 +12,58 @@ describe('Dynamic Instrumentation', function () { describe('input messages', function () { it('should capture and send expected payload when a log line probe is triggered', testBasicInput.bind(null, t)) - it('should respond with updated message if probe message is updated', function (done) { + it('should respond with updated message if probe message is updated', async function () { 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) - }, - async () => { - await t.axios.get(t.breakpoint.url) - }, - ] + const receivedMessages = [] + + /** @param {{ payload: Array<{ message: string }> }} event */ + function collectMessages ({ payload }) { + for (const { message } of payload) receivedMessages.push(message) + } + t.agent.on('debugger-input', collectMessages) - t.agent.on('debugger-diagnostics', ({ payload }) => { - payload.forEach((event) => { - if (event.debugger.diagnostics.status === 'INSTALLED') { - const trigger = triggers.shift() - assert.ok(trigger, 'expecting a trigger function to be defined') - trigger().catch(done) - } - }) - }) + try { + const firstInput = once(t.agent, 'debugger-input') + const firstTrigger = t.triggerBreakpoint() + t.agent.addRemoteConfig(t.rcConfig) + await Promise.all([firstTrigger, firstInput]) - t.agent.on('debugger-input', ({ payload: [payload] }) => { - assert.strictEqual(payload.message, expectedMessages.shift()) - if (expectedMessages.length === 0) done() - }) + t.rcConfig.config.version++ + t.rcConfig.config.template = 'Hello Updated World!' + const secondInput = once(t.agent, 'debugger-input') + const secondTrigger = t.triggerBreakpoint() + t.agent.updateRemoteConfig(t.rcConfig.id, t.rcConfig.config) + await Promise.all([secondTrigger, secondInput]) + await delay(pollInterval * 2 * 1000) + } finally { + t.agent.removeListener('debugger-input', collectMessages) + } - t.agent.addRemoteConfig(t.rcConfig) + assert.deepStrictEqual(receivedMessages, expectedMessages) }) - it('should not trigger if probe is deleted', function (done) { - t.agent.on('debugger-diagnostics', ({ payload }) => { - payload.forEach((event) => { - if (event.debugger.diagnostics.status === 'INSTALLED') { - t.agent.once('remote-config-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 - }) + it('should not trigger if probe is deleted', async function () { + const diagnostics = on(t.agent, 'debugger-diagnostics') + let inputCount = 0 + const countInput = () => inputCount++ + t.agent.on('debugger-input', countInput) - t.agent.removeRemoteConfig(t.rcConfig.id) - } - }) - }) + try { + t.agent.addRemoteConfig(t.rcConfig) + for await (const [{ payload }] of diagnostics) { + if (payload.some(event => event.debugger.diagnostics.status === 'INSTALLED')) break + } - t.agent.on('debugger-input', () => { - assert.fail('should not capture anything when the probe is deleted') - }) + const configRemoved = once(t.agent, 'remote-config-responded') + t.agent.removeRemoteConfig(t.rcConfig.id) + await configRemoved + await t.axios.get(t.breakpoint.url) + await delay(pollInterval * 2 * 1000) + } finally { + t.agent.removeListener('debugger-input', countInput) + } - t.agent.addRemoteConfig(t.rcConfig) + assert.strictEqual(inputCount, 0) }) }) }) diff --git a/integration-tests/debugger/race-conditions.spec.js b/integration-tests/debugger/race-conditions.spec.js index f5e9abe47e3..256aaa169db 100644 --- a/integration-tests/debugger/race-conditions.spec.js +++ b/integration-tests/debugger/race-conditions.spec.js @@ -37,11 +37,11 @@ describe('Dynamic Instrumentation', function () { }) // Perform HTTP request to try and trigger the probe - t.axios.get(t.breakpoint.url).catch((err) => { + t.axios.get(t.breakpoint.url).catch((error) => { // 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 shouldn't be an issue. - if (!finished) done(err) + // `done(error)`. If we later add more tests below this one, this shouldn't be an issue. + if (!finished) done(error) }) } }) diff --git a/integration-tests/debugger/re-evaluation.spec.js b/integration-tests/debugger/re-evaluation.spec.js index c882166de39..3a5ce75a906 100644 --- a/integration-tests/debugger/re-evaluation.spec.js +++ b/integration-tests/debugger/re-evaluation.spec.js @@ -2,6 +2,8 @@ const { randomUUID } = require('node:crypto') const assert = require('node:assert') +const { on } = require('node:events') +const { setTimeout: delay } = require('node:timers/promises') const Axios = require('axios') @@ -62,10 +64,9 @@ describe('Dynamic Instrumentation Probe Re-Evaluation', function () { 'even if it is not loaded when the probe is received ' + `(attempt ${attempt})` - it(testName, function (done) { + it(testName, async function () { this.timeout(5000) - let doneCalled = false const probeId = rcConfig.config.id const expectedPayloads = [{ ddsource: 'dd_debugger', @@ -80,33 +81,18 @@ describe('Dynamic Instrumentation Probe Re-Evaluation', function () { service: 're-evaluation-test', debugger: { diagnostics: { probeId, probeVersion: 0, status: 'EMITTING' } }, }] + const receivedPayloads = [] - agent.on('debugger-diagnostics', async ({ payload }) => { - await Promise.all(payload.map(async (event) => { - if (event.debugger.diagnostics.status === 'ERROR') { - // shortcut to fail with a more relevant error message in case the target script could not be found, - // instead of asserting the entire expected event. - assert.fail(event.debugger.diagnostics.exception.message) - } - - const expected = expectedPayloads.shift() - assertObjectContains(event, expected) - - if (event.debugger.diagnostics.status === 'INSTALLED') { - const response = await axios.get('/') - assert.strictEqual(response.status, 200) - } - })) - - if (expectedPayloads.length === 0 && doneCalled === false) { - doneCalled = true - done() - } - }) + /** @param {{ payload: Array }} event */ + function collectDiagnostics ({ payload }) { + receivedPayloads.push(...payload) + } + agent.on('debugger-diagnostics', collectDiagnostics) + const diagnostics = on(agent, 'debugger-diagnostics') agent.addRemoteConfig(rcConfig) - spawnProc(sourceFile, { + proc = await spawnProc(sourceFile, { cwd: sandboxCwd(), env: { NODE_OPTIONS: '--import dd-trace/initialize.mjs', @@ -116,13 +102,35 @@ describe('Dynamic Instrumentation Probe Re-Evaluation', function () { DD_TRACE_DEBUG: process.env.DD_TRACE_DEBUG, // inherit to make debugging the sandbox easier DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.1', }, - }).then(_proc => { - assert(_proc, 'proc must be spawned successfully') - proc = _proc - // Possible race condition, in case axios.get() is called in the test before it's created here. But we have - // to start the test quickly in order to test the re-evaluation of the probe. - axios = Axios.create({ baseURL: proc.url }) }) + assert(proc, 'proc must be spawned successfully') + axios = Axios.create({ baseURL: proc.url }) + + try { + for await (const [{ payload }] of diagnostics) { + for (const event of payload) { + if (event.debugger.diagnostics.status === 'ERROR') { + // shortcut to fail with a more relevant error message in case the target script could not be found, + // instead of asserting the entire expected event. + assert.fail(event.debugger.diagnostics.exception.message) + } + + const expected = expectedPayloads.shift() + assertObjectContains(event, expected) + + if (event.debugger.diagnostics.status === 'INSTALLED') { + const response = await axios.get('/') + assert.strictEqual(response.status, 200) + } + } + + if (expectedPayloads.length === 0) break + } + await delay(200) + } finally { + agent.removeListener('debugger-diagnostics', collectDiagnostics) + } + assert.strictEqual(receivedPayloads.length, 3) }) } } diff --git a/integration-tests/debugger/sampling.spec.js b/integration-tests/debugger/sampling.spec.js index f13226d1d08..f7dc277fbef 100644 --- a/integration-tests/debugger/sampling.spec.js +++ b/integration-tests/debugger/sampling.spec.js @@ -24,21 +24,19 @@ describe('Dynamic Instrumentation', function () { t.agent.on('debugger-input', ({ payload }) => { payload.forEach(({ debugger: { snapshot: { timestamp } } }) => { - if (prev !== undefined) { - const duration = timestamp - prev + const previousTimestamp = prev + prev = timestamp + if (previousTimestamp !== undefined) { + const duration = timestamp - previousTimestamp clearTimeout(timer) - // The sampling check uses `process.hrtime.bigint()` (monotonic), but the snapshot `timestamp` is captured - // via `Date.now()` (wall clock). NTP slewing on CI runners can cause the wall clock to drift slightly - // relative to the monotonic clock during the >=1s sampling window, so we allow a 75ms tolerance on both - // sides of the expected 1000ms gap. + // Snapshot timestamps use wall-clock time while sampling uses monotonic time, so allow 75ms for drift. assert.ok(duration >= 925, `duration (${duration}) should be >= 925`) assert.ok(duration < 1075, `duration (${duration}) should be < 1075`) // Wait at least a full sampling period, to see if we get any more payloads - timer = setTimeout(done, 1250) + timer = setTimeout(() => done(), 1250) } - prev = timestamp }) }) @@ -84,10 +82,7 @@ describe('Dynamic Instrumentation', function () { const duration = timestamp - _state.prev clearTimeout(_state.timer) - // The sampling check uses `process.hrtime.bigint()` (monotonic), but the snapshot `timestamp` is captured - // via `Date.now()` (wall clock). NTP slewing on CI runners can cause the wall clock to drift slightly - // relative to the monotonic clock during the >=1s sampling window, so we allow a 75ms tolerance on both - // sides of the expected 1000ms gap. + // Snapshot timestamps use wall-clock time while sampling uses monotonic time, so allow 75ms for drift. assert.ok(duration >= 925, `duration (${duration}) should be >= 925`) assert.ok(duration < 1075, `duration (${duration}) should be < 1075`) diff --git a/integration-tests/debugger/snapshot-global-sample-rate.spec.js b/integration-tests/debugger/snapshot-global-sample-rate.spec.js index 966d0d5c9da..0dbf2c20f34 100644 --- a/integration-tests/debugger/snapshot-global-sample-rate.spec.js +++ b/integration-tests/debugger/snapshot-global-sample-rate.spec.js @@ -81,10 +81,10 @@ describe('Dynamic Instrumentation', function () { t.agent.addRemoteConfig(rcConfig1) t.agent.addRemoteConfig(rcConfig2) - function done (err) { + function done (error) { if (isDone) return isDone = true - _done(err) + _done(error) } }) }) diff --git a/integration-tests/electron/electron.spec.js b/integration-tests/electron/electron.spec.js index 250b1fc76d3..e3cc954c79b 100644 --- a/integration-tests/electron/electron.spec.js +++ b/integration-tests/electron/electron.spec.js @@ -158,23 +158,21 @@ describe('Electron integration', function () { child.send({ name: 'ipc' }) }) - it('should inject DatadogEventBridge in the renderer process', done => { - function handler (msg) { - if (!msg || msg.name !== 'bridge-result') return - child.removeListener('message', handler) - try { - assert.strictEqual(msg.result.exists, true, 'DatadogEventBridge should exist on window') - assert.strictEqual(msg.result.capabilities, '[]') - assert.ok(msg.result.privacyLevel, 'privacyLevel should be set') - assert.ok(msg.result.sendSuccess, 'bridge.send() should not throw') - done() - } catch (e) { - done(e) - } - } - - child.on('message', handler) + it('should inject DatadogEventBridge in the renderer process', async () => { + const messagePromise = new Promise(resolve => { + child.on('message', function onMessage (message) { + if (!message || message.name !== 'bridge-result') return + child.removeListener('message', onMessage) + resolve(message) + }) + }) child.send({ name: 'bridge' }) + + const message = await messagePromise + assert.strictEqual(message.result.exists, true, 'DatadogEventBridge should exist on window') + assert.strictEqual(message.result.capabilities, '[]') + assert.ok(message.result.privacyLevel, 'privacyLevel should be set') + assert.ok(message.result.sendSuccess, 'bridge.send() should not throw') }) it('should produce spans for both HTTP and IPC when both operations are triggered', done => { diff --git a/integration-tests/esbuild/esm.integration.spec.js b/integration-tests/esbuild/esm.integration.spec.js index ae563398820..6c91ef78670 100644 --- a/integration-tests/esbuild/esm.integration.spec.js +++ b/integration-tests/esbuild/esm.integration.spec.js @@ -6,7 +6,7 @@ const { execSync } = require('node:child_process') const axios = require('axios') -const { FakeAgent, spawnProc, sandboxCwd, useSandbox } = require('../helpers') +const { FakeAgent, spawnProc, stopProc, sandboxCwd, useSandbox } = require('../helpers') const { ESBUILD_VERSION } = process.env const esbuildVersions = ESBUILD_VERSION ? [ESBUILD_VERSION] : ['latest', '0.16.12'] @@ -24,7 +24,7 @@ function findWebSpan (payload) { esbuildVersions.forEach((version) => { describe('ESM is built and runs as expected in a sandbox', () => { - let agent, cwd + let agent, cwd, proc useSandbox([`esbuild@${version}`, 'hono', '@hono/node-server'], false, [__dirname]) @@ -36,8 +36,12 @@ esbuildVersions.forEach((version) => { agent = await new FakeAgent().start() }) - afterEach(() => { - agent.stop() + afterEach(async () => { + try { + await stopProc(proc) + } finally { + await agent.stop() + } }) it('should build basic esm http server exporting esm and create web traces at runtime', async () => { @@ -45,7 +49,7 @@ esbuildVersions.forEach((version) => { execSync(`node ${builder}`, { cwd }) const appFile = path.join(cwd, 'esbuild', 'esm-http-test-out.mjs') - const proc = await spawnProc(appFile, { + proc = await spawnProc(appFile, { cwd, env: { DD_TRACE_AGENT_URL: `http://localhost:${agent.port}`, @@ -68,7 +72,7 @@ esbuildVersions.forEach((version) => { execSync(`node ${builder}`, { cwd }) const appFile = path.join(cwd, 'esbuild', 'esm-http-test-out.cjs') - const proc = await spawnProc(appFile, { + proc = await spawnProc(appFile, { cwd, env: { DD_TRACE_AGENT_URL: `http://localhost:${agent.port}`, @@ -91,7 +95,7 @@ esbuildVersions.forEach((version) => { execSync(`node ${builder}`, { cwd }) const appFile = path.join(cwd, 'esbuild', 'hono-out.mjs') - const proc = await spawnProc(appFile, { + proc = await spawnProc(appFile, { cwd, env: { DD_TRACE_AGENT_URL: `http://localhost:${agent.port}`, @@ -114,7 +118,7 @@ esbuildVersions.forEach((version) => { execSync(`node ${builder}`, { cwd }) const appFile = path.join(cwd, 'esbuild', 'hono-out.cjs') - const proc = await spawnProc(appFile, { + proc = await spawnProc(appFile, { cwd, env: { DD_TRACE_AGENT_URL: `http://localhost:${agent.port}`, diff --git a/integration-tests/esbuild/openfeature.spec.js b/integration-tests/esbuild/openfeature.spec.js index 2cd6009a30a..21f9f1ca31f 100644 --- a/integration-tests/esbuild/openfeature.spec.js +++ b/integration-tests/esbuild/openfeature.spec.js @@ -30,9 +30,7 @@ esbuildVersions.forEach((version) => { agent = await new FakeAgent().start() }) - afterEach(() => { - agent.stop() - }) + afterEach(() => agent.stop()) it('should not crash build after installing with yarn', () => { execSync('node esbuild/build.esm-hono-output-esm.mjs', { cwd }) diff --git a/integration-tests/esbuild/package.json b/integration-tests/esbuild/package.json index 033016bca9a..a683f652884 100644 --- a/integration-tests/esbuild/package.json +++ b/integration-tests/esbuild/package.json @@ -29,6 +29,6 @@ "express": "4.22.2", "knex": "3.3.0", "koa": "3.2.1", - "openai": "7.4.0" + "openai": "7.5.0" } } diff --git a/integration-tests/init.spec.js b/integration-tests/init.spec.js index 25d2677b273..4fdde2a13fd 100644 --- a/integration-tests/init.spec.js +++ b/integration-tests/init.spec.js @@ -40,8 +40,9 @@ function testInjectionScenarios (arg, filename, esmWorks = false) { const NODE_OPTIONS = `--no-warnings --${arg} ${path.join(__dirname, '..', filename)}` useEnv({ DD_TEST_TRACER_ROOT: path.join(__dirname, '..'), NODE_OPTIONS }) - if (currentVersionIsSupported) { - context('without DD_INJECTION_ENABLED', () => { + { + const supportedRuntimeContext = currentVersionIsSupported ? context : context.skip + supportedRuntimeContext('without DD_INJECTION_ENABLED', () => { it('should initialize the tracer', () => testFile(tracerFile, 'true\n', [], 'manual')) it('should initialize instrumentation', () => testFile(instrFile, 'true\n', [], 'manual')) @@ -60,8 +61,9 @@ function testInjectionScenarios (arg, filename, esmWorks = false) { it('should not initialize ESM instrumentation', () => testFile('init/instrument.mjs', 'false\n', [], '')) - if (arg === 'import') { - it('does not load loader internals after deferring to the app copy', () => + { + const loaderInternalsTest = arg === 'import' ? it : it.skip + loaderInternalsTest('does not load loader internals after deferring to the app copy', () => testFile('init/loader-hook-loaded.js', 'false\n', [], '')) } }) @@ -211,8 +213,9 @@ true }) }) - if (currentVersionIsSupported) { - context('when node version is in range of the engines field', () => { + { + const supportedRuntimeContext = currentVersionIsSupported ? context : context.skip + supportedRuntimeContext('when node version is in range of the engines field', () => { useEnv({ NODE_OPTIONS }) before(() => { @@ -324,8 +327,9 @@ describe('init.js', () => { // ESM is not supportable prior to Node.js 14.13.1 on the 14.x line, // or on 18.0.0 in particular. -if (semver.satisfies(process.versions.node, '>=14.13.1')) { - describe('initialize.mjs', () => { +{ + const initializeEsmSuite = semver.satisfies(process.versions.node, '>=14.13.1') ? describe : describe.skip + initializeEsmSuite('initialize.mjs', () => { setShouldKill(false) useSandbox() stubTracerIfNeeded() @@ -355,8 +359,9 @@ if (semver.satisfies(process.versions.node, '>=14.13.1')) { testRuntimeVersionChecks('loader', 'initialize.mjs') // Only off-thread loaders install the matcher; see initialize.mjs. - if (esmWorks && semver.satisfies(process.versions.node, '>=18.19.0')) { - context('import-in-the-middle include matcher', () => { + { + const matcherContext = esmWorks && semver.satisfies(process.versions.node, '>=18.19.0') ? context : context.skip + matcherContext('import-in-the-middle include matcher', () => { useEnv({ NODE_OPTIONS: '--no-warnings --loader dd-trace/initialize.mjs', pm2_env: JSON.stringify({ @@ -370,8 +375,9 @@ if (semver.satisfies(process.versions.node, '>=14.13.1')) { }) } - if (process.versions.node === '20.0.0') { - context('with the Node.js 20.0.0 loader', () => { + { + const node20Context = process.versions.node === '20.0.0' ? context : context.skip + node20Context('with the Node.js 20.0.0 loader', () => { const NODE_OPTIONS = '--no-warnings --loader dd-trace/initialize.mjs' context('with force', () => { @@ -395,8 +401,9 @@ if (semver.satisfies(process.versions.node, '>=14.13.1')) { } }) - if (semver.satisfies(process.versions.node, '>=20.6.0')) { - context('as --import', () => { + { + const importContext = semver.satisfies(process.versions.node, '>=20.6.0') ? context : context.skip + importContext('as --import', () => { // The loader hook is skipped on bailout, so --import children exit on their // own; killing them would mask a regression that keeps the process alive. setShouldKill(false) diff --git a/integration-tests/jest/jest.core.spec.js b/integration-tests/jest/jest.core.spec.js index d9b4b91e8a4..92d1d76afc3 100644 --- a/integration-tests/jest/jest.core.spec.js +++ b/integration-tests/jest/jest.core.spec.js @@ -66,9 +66,9 @@ const requestedJestVersion = process.env.JEST_VERSION || 'latest' const oldestJestVersion = DD_MAJOR >= 6 ? '28.0.0' : '24.8.0' const JEST_VERSION = requestedJestVersion === 'oldest' ? oldestJestVersion : requestedJestVersion const onlyLatestIt = JEST_VERSION === 'latest' ? it : it.skip +const esmIt = JEST_VERSION === 'latest' || Number(JEST_VERSION.split('.')[0]) >= 28 ? it : it.skip const shouldInstallJestEnvironmentJsdom = JEST_VERSION === 'latest' || Number(JEST_VERSION.split('.')[0]) >= 28 -// TODO: add ESM tests describe(`jest@${JEST_VERSION} commonJS`, () => { let receiver let childProcess @@ -86,9 +86,11 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { JEST_VERSION !== 'latest' ? `jest-circus@${JEST_VERSION}` : '', ...getBabelDependencies(JEST_VERSION), '@happy-dom/jest-environment', + 'bunyan', + 'jest-image-snapshot', 'office-addin-mock', + 'pino', 'winston', - 'jest-image-snapshot', ].filter(Boolean), true) before(function () { @@ -1631,6 +1633,291 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }) }) + context('winston mocking', () => { + it('should allow winston to be mocked and verify createLogger is called', async () => { + childProcess = exec( + runTestsCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: 'jest-mock-bypass-require/winston-mock-test', + SHOULD_CHECK_RESULTS: '1', + }, + } + ) + + const [code] = await once(childProcess, 'exit') + assert.strictEqual(code, 0, `Jest should pass but failed with code ${code}`) + }) + }) + + context('Pino and Bunyan module loading', () => { + for (const loggerName of ['pino', 'bunyan']) { + for (const resolutionType of ['moduleNameMapper', 'custom resolver']) { + it(`respects Jest ${resolutionType} for ${loggerName}`, async () => { + let testOutput = '' + // Ensure the native bypass still defers to Jest when its resolution is customized. + const resolutionConfig = resolutionType === 'moduleNameMapper' + ? { + CONFIG_MODULE_NAME_MAPPER: JSON.stringify({ + [`^${loggerName}$`]: '/ci-visibility/jest-mock-bypass-require/mapped-logger.js', + }), + } + : { + CONFIG_RESOLVER: '/ci-visibility/jest-mock-bypass-require/logger-resolver.js', + } + + childProcess = exec( + runTestsCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + ...resolutionConfig, + TEST_LOGGER: loggerName, + TESTS_TO_RUN: 'jest-mock-bypass-require/mapped-logger-test', + USE_CONFIG_FILE: '1', + USE_JEST_RUN: '1', + }, + } + ) + childProcess.stdout.on('data', chunk => { + testOutput += chunk.toString() + }) + childProcess.stderr.on('data', chunk => { + testOutput += chunk.toString() + }) + + const [code] = await once(childProcess, 'exit') + assert.strictEqual(code, 0, `Jest should pass but failed with code ${code}: ${testOutput}`) + }) + } + + it(`instruments ${loggerName} after another suite mocks it`, async () => { + let testOutput = '' + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.includes('/api/v2/logs'), payloads => { + assert.strictEqual(payloads.length, 1, testOutput) + + const [{ headers, logMessage, url }] = payloads + assert.strictEqual(headers['content-type'], 'application/json') + assert.strictEqual(headers['dd-api-key'], 'api-key') + assert.strictEqual(url, `/api/v2/logs?ddsource=${loggerName}&service=my-service`) + assert.strictEqual(logMessage.length, 1) + + const [{ dd, msg }] = logMessage + assert.strictEqual(msg, 'real logger after mock') + assert.strictEqual(dd.service, 'my-service') + assert.match(dd.trace_id, /^\d+$/) + assert.match(dd.span_id, /^\d+$/) + }) + + childProcess = exec( + runTestsCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + DD_AGENTLESS_LOG_SUBMISSION_ENABLED: '1', + DD_AGENTLESS_LOG_SUBMISSION_URL: `http://localhost:${receiver.port}`, + DD_API_KEY: 'api-key', + DD_SERVICE: 'my-service', + TEST_LOGGER: loggerName, + // Run the mocked suite first to expose bypass state leaking between Jest runtimes. + TEST_SEQUENCER: './ci-visibility/jest-mock-bypass-require/test-sequencer.js', + TESTS_TO_RUN: `jest-mock-bypass-require/(${loggerName}-mock|z-real-logger)-test`, + USE_JEST_RUN: '1', + }, + } + ) + childProcess.stdout.on('data', chunk => { + testOutput += chunk.toString() + }) + childProcess.stderr.on('data', chunk => { + testOutput += chunk.toString() + }) + + const [[code]] = await Promise.all([ + once(childProcess, 'exit'), + logsPromise, + ]) + + assert.strictEqual(code, 0, `Jest should pass but failed with code ${code}: ${testOutput}`) + }) + } + }) + + context('ESM logger loading', () => { + for (const resolutionType of ['moduleNameMapper', 'custom resolver']) { + esmIt(`respects Jest ESM ${resolutionType}`, async () => { + let testOutput = '' + const resolutionConfig = resolutionType === 'moduleNameMapper' + ? { + CONFIG_MODULE_NAME_MAPPER: JSON.stringify({ + '^winston$': '/ci-visibility/jest-mock-bypass-require/mapped-logger.js', + }), + } + : { + CONFIG_RESOLVER: '/ci-visibility/jest-mock-bypass-require/logger-resolver.js', + } + + childProcess = exec( + runTestsCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + ...resolutionConfig, + CONFIG_TEST_MATCH: '**/ci-visibility/jest-mock-bypass-require/esm-mapped-logger-test.mjs', + NODE_OPTIONS: '-r dd-trace/ci/init --experimental-vm-modules', + TEST_LOGGER: 'winston', + USE_CONFIG_FILE: '1', + USE_JEST_RUN: '1', + }, + } + ) + childProcess.stdout.on('data', chunk => { + testOutput += chunk.toString() + }) + childProcess.stderr.on('data', chunk => { + testOutput += chunk.toString() + }) + + const [code] = await once(childProcess, 'exit') + assert.strictEqual(code, 0, `Jest should pass but failed with code ${code}: ${testOutput}`) + }) + } + + esmIt('does not resolve loggers for unrelated CommonJS ESM imports', async () => { + let testOutput = '' + childProcess = exec( + runTestsCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + NODE_OPTIONS: '-r dd-trace/ci/init ' + + '--require ./ci-visibility/jest-mock-bypass-require/track-logger-resolution.js ' + + '--experimental-vm-modules', + TESTS_TO_RUN: 'jest-mock-bypass-require/esm-unrelated-cjs-test.mjs', + USE_JEST_RUN: '1', + }, + } + ) + childProcess.stdout.on('data', chunk => { + testOutput += chunk.toString() + }) + childProcess.stderr.on('data', chunk => { + testOutput += chunk.toString() + }) + + const [code] = await once(childProcess, 'exit') + assert.doesNotMatch(testOutput, /\[unexpected logger resolution\]/) + assert.strictEqual(code, 0, `Jest should pass but failed with code ${code}: ${testOutput}`) + }) + + esmIt('instruments a statically imported logger through a renamed symlink', async () => { + const loggerModulePath = path.join(cwd, 'node_modules', 'winston') + const linkedLoggerPath = path.join(cwd, 'linked-logger') + const linkedLoggerModulePath = path.join(linkedLoggerPath, 'node_modules', 'winston') + const linkedLoggerIndexPath = path.join(linkedLoggerPath, 'index.js') + const linkedLoggerPackagePath = path.join(linkedLoggerPath, 'package.json') + fs.mkdirSync(path.dirname(linkedLoggerModulePath), { recursive: true }) + fs.renameSync(loggerModulePath, linkedLoggerModulePath) + fs.writeFileSync(linkedLoggerIndexPath, "module.exports = require('./node_modules/winston')\n") + fs.writeFileSync(linkedLoggerPackagePath, '{"name":"winston","main":"index.js"}\n') + + try { + fs.symlinkSync(linkedLoggerPath, loggerModulePath, 'junction') + assert.strictEqual(fs.realpathSync(loggerModulePath), fs.realpathSync(linkedLoggerPath)) + + let testOutput = '' + const logsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.includes('/api/v2/logs'), payloads => { + assert.strictEqual(payloads.length, 1, testOutput) + + const [{ headers, logMessage, url }] = payloads + assert.strictEqual(headers['content-type'], 'application/json') + assert.strictEqual(headers['dd-api-key'], 'api-key') + assert.strictEqual(url, '/api/v2/logs?ddsource=winston&service=my-service') + assert.strictEqual(logMessage.length, 1) + + const [{ dd, message }] = logMessage + assert.strictEqual(message, 'linked logger') + assert.strictEqual(dd.service, 'my-service') + assert.match(dd.trace_id, /^\d+$/) + assert.match(dd.span_id, /^\d+$/) + }) + + childProcess = exec( + runTestsCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + DD_AGENTLESS_LOG_SUBMISSION_ENABLED: '1', + DD_AGENTLESS_LOG_SUBMISSION_URL: `http://localhost:${receiver.port}`, + DD_API_KEY: 'api-key', + DD_SERVICE: 'my-service', + NODE_OPTIONS: '-r dd-trace/ci/init --experimental-vm-modules', + TESTS_TO_RUN: 'jest-mock-bypass-require/esm-linked-logger-test.mjs', + USE_JEST_RUN: '1', + }, + } + ) + childProcess.stdout.on('data', chunk => { + testOutput += chunk.toString() + }) + childProcess.stderr.on('data', chunk => { + testOutput += chunk.toString() + }) + + const [[code]] = await Promise.all([ + once(childProcess, 'exit'), + logsPromise, + ]) + + assert.strictEqual(code, 0, `Jest should pass but failed with code ${code}: ${testOutput}`) + } finally { + if (fs.existsSync(loggerModulePath)) fs.unlinkSync(loggerModulePath) + fs.renameSync(linkedLoggerModulePath, loggerModulePath) + fs.unlinkSync(linkedLoggerIndexPath) + fs.unlinkSync(linkedLoggerPackagePath) + fs.rmdirSync(path.join(linkedLoggerPath, 'node_modules')) + fs.rmdirSync(linkedLoggerPath) + } + }) + + for (const loggerName of ['winston', 'pino', 'bunyan']) { + esmIt(`respects Jest ESM mocks for ${loggerName}`, async () => { + let testOutput = '' + childProcess = exec( + runTestsCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + NODE_OPTIONS: '-r dd-trace/ci/init --experimental-vm-modules', + TEST_LOGGER: loggerName, + TESTS_TO_RUN: 'jest-mock-bypass-require/esm-mock-test.mjs', + USE_JEST_RUN: '1', + }, + } + ) + childProcess.stdout.on('data', chunk => { + testOutput += chunk.toString() + }) + childProcess.stderr.on('data', chunk => { + testOutput += chunk.toString() + }) + + const [code] = await once(childProcess, 'exit') + assert.strictEqual(code, 0, `Jest should pass but failed with code ${code}: ${testOutput}`) + }) + } + }) + context('when using off timing imports', () => { onlyLatestIt('reports test suite errors when waitForUnhandledRejections=true', async () => { const eventsPromise = receiver diff --git a/integration-tests/jest/jest.test-management.spec.js b/integration-tests/jest/jest.test-management.spec.js index 2d623f52fc4..f6f72e5f5e0 100644 --- a/integration-tests/jest/jest.test-management.spec.js +++ b/integration-tests/jest/jest.test-management.spec.js @@ -78,9 +78,8 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { JEST_VERSION !== 'latest' ? `jest-circus@${JEST_VERSION}` : '', ...getBabelDependencies(JEST_VERSION), '@happy-dom/jest-environment', - 'office-addin-mock', - 'winston', 'jest-image-snapshot', + 'office-addin-mock', ].filter(Boolean), true) before(function () { @@ -3556,25 +3555,6 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }) }) - context('winston mocking', () => { - it('should allow winston to be mocked and verify createLogger is called', async () => { - childProcess = exec( - runTestsCommand, - { - cwd, - env: { - ...getCiVisAgentlessConfig(receiver.port), - TESTS_TO_RUN: 'jest-mock-bypass-require/winston-mock-test', - SHOULD_CHECK_RESULTS: '1', - }, - } - ) - - const [code] = await once(childProcess, 'exit') - assert.strictEqual(code, 0, `Jest should pass but failed with code ${code}`) - }) - }) - context('seed suffix normalization', () => { onlyLatestIt('should remove seed suffix from reported test names', async () => { const eventsPromise = receiver diff --git a/integration-tests/mocha-parallel-files.spec.js b/integration-tests/mocha-parallel-files.spec.js index dda1e103c69..7e03940feef 100644 --- a/integration-tests/mocha-parallel-files.spec.js +++ b/integration-tests/mocha-parallel-files.spec.js @@ -85,13 +85,14 @@ describe('mocha-parallel-files script', function () { assert.match(stdout, /Failed:\s+0\b/) }) - it('preserves the SIGINT exit code on user interrupt', async function () { - if (process.platform === 'win32') { - this.skip() - return - } - const fixture = path.join(fixturesDir, 'long-running.js') - const { code } = await runParallel(['--', fixture], { killSignal: 'SIGINT', killOnFirstStdout: true }) - assert.strictEqual(code, 130) - }) + { + const sigintTest = process.platform === 'win32' ? it.skip : it + + // Windows does not support the SIGINT process semantics exercised here. + sigintTest('preserves the SIGINT exit code on user interrupt', async function () { + const fixture = path.join(fixturesDir, 'long-running.js') + const { code } = await runParallel(['--', fixture], { killSignal: 'SIGINT', killOnFirstStdout: true }) + assert.strictEqual(code, 130) + }) + } }) diff --git a/integration-tests/playwright/playwright-efd.spec.js b/integration-tests/playwright/playwright-efd.spec.js index cc4576eb52d..05dc6bc5846 100644 --- a/integration-tests/playwright/playwright-efd.spec.js +++ b/integration-tests/playwright/playwright-efd.spec.js @@ -24,6 +24,8 @@ const { TEST_NAME, TEST_BROWSER_NAME, TEST_RETRY_REASON_TYPES, + TEST_FAILURE_SCREENSHOT_UPLOADED, + TEST_FAILURE_SCREENSHOT_UPLOAD_ERROR, } = require('../../packages/dd-trace/src/plugins/util/test') const { PLAYWRIGHT_VERSION } = process.env @@ -44,6 +46,9 @@ versions.forEach((version) => { describe(`playwright@${version}`, function () { const it = createParallelIt(global.it, { withReceiver: true }) + const failureScreenshotHandoffTest = satisfies(version, '>=1.60.0') || version === 'latest' + ? it + : global.it.skip let cwd, webAppPort, webAppServer @@ -235,6 +240,63 @@ versions.forEach((version) => { await Promise.all([once(proc, 'exit'), receiverPromise]) }) + failureScreenshotHandoffTest( + 'keeps failure screenshots aligned when EFD skips a scheduled retry', + async (receiver, run) => { + receiver.setSettings({ + early_flake_detection: { + enabled: true, + slow_test_retries: { + '5s': 1, + }, + faulty_session_threshold: 100, + }, + known_tests_enabled: true, + }) + receiver.setKnownTests({ playwright: {} }) + + const proc = run( + './node_modules/.bin/playwright test -c playwright.config.js', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + DD_TEST_FAILURE_SCREENSHOTS_ENABLED: 'true', + PLAYWRIGHT_FAILURE_SCREENSHOT_MODE: 'only-on-failure', + PLAYWRIGHT_WORKERS: '1', + PW_BASE_URL: `http://localhost:${webAppPort}`, + TEST_DIR: './ci-visibility/playwright-efd-failure-screenshot', + }, + } + ) + const payloadsPromise = receiver.gatherPayloadsUntilChildExit( + proc, + ({ url }) => url.startsWith('/api/v2/ci/test-runs/') || url.endsWith('/api/v2/citestcycle'), + (payloads) => { + const mediaPayloads = payloads.filter(({ url }) => url.startsWith('/api/v2/ci/test-runs/')) + const failedTests = payloads + .filter(({ url }) => url.endsWith('/api/v2/citestcycle')) + .flatMap(({ payload }) => payload.events) + .filter(event => event.type === 'test') + .map(event => event.content) + .filter(test => test.meta[TEST_NAME] === + 'efd failure screenshot alignment uploads a failure screenshot') + + assert.strictEqual(failedTests.length, 2) + for (const failedTest of failedTests) { + assert.strictEqual(failedTest.meta[TEST_FAILURE_SCREENSHOT_UPLOADED], 'true') + assert.strictEqual(failedTest.meta[TEST_FAILURE_SCREENSHOT_UPLOAD_ERROR], undefined) + } + assert.strictEqual(mediaPayloads.length, 2) + }, + { hardTimeout: 60_000 } + ) + + const [[exitCode]] = await Promise.all([once(proc, 'exit'), payloadsPromise]) + assert.strictEqual(exitCode, 1) + } + ) + it('overrides slow test retries with the EFD retry count environment variable', async (receiver, run) => { receiver.setSettings({ early_flake_detection: { diff --git a/integration-tests/playwright/playwright-reporting.spec.js b/integration-tests/playwright/playwright-reporting.spec.js index 155e2e52ff4..1cdf5781162 100644 --- a/integration-tests/playwright/playwright-reporting.spec.js +++ b/integration-tests/playwright/playwright-reporting.spec.js @@ -75,6 +75,9 @@ versions.forEach((version) => { describe(`playwright@${version}`, function () { const it = createParallelIt(global.it, { withReceiver: true }) + const deferredFailureScreenshotTest = satisfies(version, '>=1.60.0') || version === 'latest' + ? it + : global.it.skip let cwd, webAppPort, webAppServer @@ -704,7 +707,8 @@ versions.forEach((version) => { run, screenshotMode = 'only-on-failure', isScreenshotUploadEnabled = true, - testOptimizationConfig = getCiVisAgentlessConfig(receiver.port) + testOptimizationConfig = getCiVisAgentlessConfig(receiver.port), + additionalEnvironment = {} ) { let testOutput = '' const proc = run( @@ -720,6 +724,7 @@ versions.forEach((version) => { DD_TEST_FAILURE_SCREENSHOTS_ENABLED: isScreenshotUploadEnabled ? 'true' : undefined, DD_TRACE_DEBUG: 'true', DD_TRACE_LOG_LEVEL: 'warn', + ...additionalEnvironment, }, } ) @@ -787,6 +792,42 @@ versions.forEach((version) => { }) } + // This race relies on Playwright 1.60 keeping the matching worker trace pending after testEnd. + deferredFailureScreenshotTest( + 'uploads a failure screenshot deferred by test code', + async (receiver, run) => { + const { proc, getTestOutput } = runWithFailureScreenshots( + receiver, + run, + 'only-on-failure', + true, + getCiVisAgentlessConfig(receiver.port), + { PLAYWRIGHT_DEFER_FAILURE_SCREENSHOT_ATTACHMENT: 'true' } + ) + const payloadsPromise = receiver.gatherPayloadsUntilChildExit( + proc, + ({ url }) => url.startsWith('/api/v2/ci/test-runs/') || url.endsWith('/api/v2/citestcycle'), + (payloads) => { + const mediaPayloads = payloads.filter(({ url }) => url.startsWith('/api/v2/ci/test-runs/')) + const failedTest = payloads + .filter(({ url }) => url.endsWith('/api/v2/citestcycle')) + .flatMap(({ payload }) => payload.events) + .filter(event => event.type === 'test') + .find(event => event.content.meta[TEST_NAME] === 'uploads only the automatic failure screenshot') + + assert.ok(failedTest, `failed test event should be reported\n${getTestOutput()}`) + assert.strictEqual(failedTest.content.meta[TEST_FAILURE_SCREENSHOT_UPLOADED], 'true') + assert.strictEqual(failedTest.content.meta[TEST_FAILURE_SCREENSHOT_UPLOAD_ERROR], undefined) + assert.strictEqual(mediaPayloads.length, 1, `automatic screenshot should upload\n${getTestOutput()}`) + }, + { hardTimeout: 60000 } + ) + + const [[exitCode]] = await Promise.all([once(proc, 'exit'), payloadsPromise]) + assert.strictEqual(exitCode, 1) + } + ) + for (const isScreenshotUploadEnabled of [true, false]) { const testName = isScreenshotUploadEnabled ? 'warns when screenshot upload is enabled but screenshot capture is off' @@ -1178,6 +1219,23 @@ versions.forEach((version) => { const [exitCode] = await once(proc, 'exit') assert.strictEqual(exitCode, 0) }) + + it('finishes if worker trace flushing throws synchronously', async (receiver, run) => { + const proc = run( + './node_modules/.bin/playwright test -c playwright.config.js', + { + cwd, + timeout: 30000, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TEST_DIR: './ci-visibility/playwright-flush-error', + }, + } + ) + + const [exitCode] = await once(proc, 'exit') + assert.strictEqual(exitCode, 0) + }) } const fullyParallelConfigValue = [true, false] diff --git a/integration-tests/playwright/playwright-test-management.spec.js b/integration-tests/playwright/playwright-test-management.spec.js index 6be2f04391d..72b68f94304 100644 --- a/integration-tests/playwright/playwright-test-management.spec.js +++ b/integration-tests/playwright/playwright-test-management.spec.js @@ -31,6 +31,8 @@ const { TEST_NAME, TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, TEST_RETRY_REASON_TYPES, + TEST_FAILURE_SCREENSHOT_UPLOADED, + TEST_FAILURE_SCREENSHOT_UPLOAD_ERROR, } = require('../../packages/dd-trace/src/plugins/util/test') const { PLAYWRIGHT_VERSION } = process.env @@ -975,6 +977,49 @@ versions.forEach((version) => { assert.doesNotMatch(testOutput, /SHOULD NOT BE EXECUTED/) assert.strictEqual(exitCode, 0, testOutput) }) + + it('keeps failure screenshots aligned after a disabled serial retry sibling', async (receiver, run) => { + receiver.setTestManagementTests(DISABLED_MANAGEMENT_TESTS) + receiver.setSettings({ test_management: { enabled: true } }) + + const proc = run( + './node_modules/.bin/playwright test -c playwright.config.js disabled-serial-test.js --retries=1', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + DD_TEST_FAILURE_SCREENSHOTS_ENABLED: 'true', + FAIL_AFTER_DISABLED: 'true', + PLAYWRIGHT_FAILURE_SCREENSHOT_MODE: 'only-on-failure', + PW_BASE_URL: `http://localhost:${webAppPort}`, + TEST_DIR: './ci-visibility/playwright-tests-test-management', + }, + } + ) + const payloadsPromise = receiver.gatherPayloadsUntilChildExit( + proc, + ({ url }) => url.startsWith('/api/v2/ci/test-runs/') || url.endsWith('/api/v2/citestcycle'), + (payloads) => { + const mediaPayloads = payloads.filter(({ url }) => url.startsWith('/api/v2/ci/test-runs/')) + const failedTest = payloads + .filter(({ url }) => url.endsWith('/api/v2/citestcycle')) + .flatMap(({ payload }) => payload.events) + .filter(event => event.type === 'test') + .map(event => event.content) + .find(test => test.meta[TEST_NAME] === + 'disabled serial retry uploads screenshot after disabled sibling') + + assert.ok(failedTest) + assert.strictEqual(failedTest.meta[TEST_FAILURE_SCREENSHOT_UPLOADED], 'true') + assert.strictEqual(failedTest.meta[TEST_FAILURE_SCREENSHOT_UPLOAD_ERROR], undefined) + assert.strictEqual(mediaPayloads.length, 1) + }, + { hardTimeout: PLAYWRIGHT_TEST_MANAGEMENT_GATHER_TIMEOUT } + ) + + const [[exitCode]] = await Promise.all([once(proc, 'exit'), payloadsPromise]) + assert.strictEqual(exitCode, 1) + }) } it('fails if disable is not enabled', async (receiver) => { diff --git a/integration-tests/profiler/allocation-profiler.spec.js b/integration-tests/profiler/allocation-profiler.spec.js index 7849e4bffc1..326a5549959 100644 --- a/integration-tests/profiler/allocation-profiler.spec.js +++ b/integration-tests/profiler/allocation-profiler.spec.js @@ -72,73 +72,76 @@ describe('allocation profiler', () => { await agent.stop() }) - it('sends heap profiles with the expected sample types on Node.js 26+', async function () { - if (!isAtLeast26) { - this.skip() - return - } - - const cases = [ - { - allocationProfilingEnabled: false, - sampleTypes: ['inuse_objects', 'inuse_space'], - }, - { - allocationProfilingEnabled: true, - sampleTypes: ['inuse_objects', 'alloc_objects', 'inuse_space', 'alloc_space'], - }, - ] - - for (const { allocationProfilingEnabled, sampleTypes } of cases) { - proc = fork(profilerTestFile, { - cwd, - env: { - DD_TRACE_AGENT_PORT: agent.port, - DD_PROFILING_ALLOCATION_ENABLED: allocationProfilingEnabled ? '1' : '0', - DD_PROFILING_DEBUG_UPLOAD_COMPRESSION: 'off', - DD_PROFILING_EXPORTERS: 'agent', - DD_PROFILING_PROFILERS: 'space', - DD_PROFILING_SOURCE_MAP: '0', - DD_PROFILING_UPLOAD_PERIOD: '1', - TEST_DURATION_MS: '5000', + { + const allocationProfileTest = isAtLeast26 ? it : it.skip + + // Allocation profiling is only available on Node.js 26 and newer. + allocationProfileTest('sends heap profiles with the expected sample types on Node.js 26+', async function () { + const cases = [ + { + allocationProfilingEnabled: false, + sampleTypes: ['inuse_objects', 'inuse_space'], }, - }) - - const [ - { event, spaceProfile }, - ] = await Promise.all([ - expectProfileUpload(agent), - processExitPromise(proc, TIMEOUT), - ]) - - assert.deepStrictEqual(event.attachments, ['space.pprof']) - assert.strictEqual(event.info.profiler.settings.allocationProfilingEnabled, allocationProfilingEnabled) - assert.deepStrictEqual(getSampleTypeNames(spaceProfile), sampleTypes) - - await stopProc(proc) - proc = undefined - } - }) - - it('does not crash when allocation profiling is requested on unsupported Node.js versions', async function () { - if (isAtLeast26) { - this.skip() - return - } - - proc = fork(profilerTestFile, { - cwd, - env: { - DD_PROFILING_ALLOCATION_ENABLED: '1', - DD_PROFILING_DEBUG_UPLOAD_COMPRESSION: 'off', - DD_PROFILING_EXPORTERS: 'file', - DD_PROFILING_PROFILERS: 'space', - DD_PROFILING_SOURCE_MAP: '0', - DD_PROFILING_UPLOAD_PERIOD: '1', - TEST_DURATION_MS: '5000', - }, + { + allocationProfilingEnabled: true, + sampleTypes: ['inuse_objects', 'alloc_objects', 'inuse_space', 'alloc_space'], + }, + ] + + for (const { allocationProfilingEnabled, sampleTypes } of cases) { + proc = fork(profilerTestFile, { + cwd, + env: { + DD_TRACE_AGENT_PORT: agent.port, + DD_PROFILING_ALLOCATION_ENABLED: allocationProfilingEnabled ? '1' : '0', + DD_PROFILING_DEBUG_UPLOAD_COMPRESSION: 'off', + DD_PROFILING_EXPORTERS: 'agent', + DD_PROFILING_PROFILERS: 'space', + DD_PROFILING_SOURCE_MAP: '0', + DD_PROFILING_UPLOAD_PERIOD: '1', + TEST_DURATION_MS: '5000', + }, + }) + + const [ + { event, spaceProfile }, + ] = await Promise.all([ + expectProfileUpload(agent), + processExitPromise(proc, TIMEOUT), + ]) + + assert.deepStrictEqual(event.attachments, ['space.pprof']) + assert.strictEqual(event.info.profiler.settings.allocationProfilingEnabled, allocationProfilingEnabled) + assert.deepStrictEqual(getSampleTypeNames(spaceProfile), sampleTypes) + + await stopProc(proc) + proc = undefined + } }) - - await processExitPromise(proc, TIMEOUT) - }) + } + + { + const unsupportedAllocationProfileTest = isAtLeast26 ? it.skip : it + + // Node.js 26 supports allocation profiling, so the unsupported-runtime case does not apply. + unsupportedAllocationProfileTest( + 'does not crash when allocation profiling is requested on unsupported Node.js versions', + async function () { + proc = fork(profilerTestFile, { + cwd, + env: { + DD_PROFILING_ALLOCATION_ENABLED: '1', + DD_PROFILING_DEBUG_UPLOAD_COMPRESSION: 'off', + DD_PROFILING_EXPORTERS: 'file', + DD_PROFILING_PROFILERS: 'space', + DD_PROFILING_SOURCE_MAP: '0', + DD_PROFILING_UPLOAD_PERIOD: '1', + TEST_DURATION_MS: '5000', + }, + }) + + await processExitPromise(proc, TIMEOUT) + } + ) + } }) diff --git a/integration-tests/profiler/profiler.spec.js b/integration-tests/profiler/profiler.spec.js index fb61757ce61..a99a842dffc 100644 --- a/integration-tests/profiler/profiler.spec.js +++ b/integration-tests/profiler/profiler.spec.js @@ -423,13 +423,10 @@ describe('profiler', () => { await agent.stop() }) - describe('on non-Windows platforms', () => { - before(function () { - if (process.platform === 'win32') { - this.skip() - } - }) + const nonWindowsDescribe = process.platform === 'win32' ? describe.skip : describe + // The profiler is not supported on Windows. + nonWindowsDescribe('on non-Windows platforms', () => { it('code hotspots and endpoint tracing works', async function () { // see comment on busyCycleTimeNs recomputation below. Ideally a single retry should be enough // with recomputed busyCycleTimeNs, but let's give ourselves more leeway. @@ -659,20 +656,22 @@ describe('profiler', () => { } }) - it('gc timeline events work with the minor mark-sweep collector', async function () { + { + const gcTimelineTest = satisfies(process.versions.node, '>=22.0.0') ? it : it.skip + // V8's --minor-ms collector emits GC events with kind 2, which has no // NODE_PERFORMANCE_GC_* constant and used to crash the profiler. // It is stable since Node 22. See issue #8839. - if (!satisfies(process.versions.node, '>=22.0.0')) { - this.skip() - } - const gcTypes = await gatherGcTypes(cwd, 'profiler/gctest.js', agent.port, ['--minor-ms']) - // The collector was renamed from minor_mark_compact to minor_mark_sweep in Node 22. - assert.ok(gcTypes.has('minor_mark_sweep'), `Expected a minor_mark_sweep GC event, got ${inspect(gcTypes)}`) - for (const gcType of gcTypes) { - assert.doesNotMatch(gcType, /^unknown/, `Unexpected unknown GC type: ${gcType}`) - } - }) + // This regression test requires the stable Node.js 22 inspector implementation. + gcTimelineTest('gc timeline events work with the minor mark-sweep collector', async function () { + const gcTypes = await gatherGcTypes(cwd, 'profiler/gctest.js', agent.port, ['--minor-ms']) + // The collector was renamed from minor_mark_compact to minor_mark_sweep in Node 22. + assert.ok(gcTypes.has('minor_mark_sweep'), `Expected a minor_mark_sweep GC event, got ${inspect(gcTypes)}`) + for (const gcType of gcTypes) { + assert.doesNotMatch(gcType, /^unknown/, `Unexpected unknown GC type: ${gcType}`) + } + }) + } }) context('shutdown', () => { @@ -708,13 +707,10 @@ describe('profiler', () => { await Promise.all([checkProfiles(agent, proc, timeout), expectTimeout(checkTelemetry)]) }) - describe('on non-Windows platform', () => { - before(function () { - if (process.platform === 'win32') { - this.skip() - } - }) + const nonWindowsOomDescribe = process.platform === 'win32' ? describe.skip : describe + // The profiler is not supported on Windows. + nonWindowsOomDescribe('on non-Windows platform', () => { // All OOM tests below are retried 3 times because OOM export behavior is timing-sensitive // and Node.js version-dependent: newer V8 versions (e.g. Node 26) crash faster or handle // worker OOM differently, making these tests inherently unreliable without retries. @@ -883,43 +879,44 @@ describe('profiler', () => { assert.strictEqual(requestCount, pointsCount) }) - it('sends wall profiler sample context telemetry', async function () { - if (satisfies(process.versions.node, '<24.0.0')) { - this.skip() // Wall profiler context count telemetry is not supported in Node < 24 - } - if (process.platform === 'win32') { - this.skip() // Wall profiler context count telemetry is not supported on Windows - } - proc = fork(profilerTestFile, { - cwd, - env: { - DD_TRACE_AGENT_PORT: agent.port, - DD_PROFILING_ENABLED: '1', - DD_PROFILING_UPLOAD_PERIOD: '1', - DD_PROFILING_ASYNC_CONTEXT_FRAME_ENABLED: '1', - DD_TELEMETRY_HEARTBEAT_INTERVAL: '1', // every second - TEST_DURATION_MS: 3000, - }, - }) + { + const wallProfilerSupported = satisfies(process.versions.node, '>=24.0.0') && process.platform !== 'win32' + const wallProfilerTest = wallProfilerSupported ? it : it.skip - const checkMetrics = agent.assertTelemetryReceived({ - fn: ({ _, payload }) => { - const pp = payload.payload; - ['live', 'used'].forEach(metricName => { - const sampleContexts = pp.series.find(s => s.metric === `wall.async_contexts_${metricName}`) - assert.notStrictEqual(sampleContexts, undefined) - assert.strictEqual(sampleContexts.type, 'gauge') - assert.ok(sampleContexts.points[0][1] >= 1, `Expected ${sampleContexts.points[0][1]} >= 1`) - }) - }, - requestType: 'generate-metrics', - timeout, - resolveAtFirstSuccess: true, - namespace: 'profilers', - }) + // Wall profiler context count telemetry is not supported before Node.js 24. + // Wall profiler context count telemetry is not supported on Windows. + wallProfilerTest('sends wall profiler sample context telemetry', async function () { + proc = fork(profilerTestFile, { + cwd, + env: { + DD_TRACE_AGENT_PORT: agent.port, + DD_PROFILING_ENABLED: '1', + DD_PROFILING_UPLOAD_PERIOD: '1', + DD_PROFILING_ASYNC_CONTEXT_FRAME_ENABLED: '1', + DD_TELEMETRY_HEARTBEAT_INTERVAL: '1', // every second + TEST_DURATION_MS: 3000, + }, + }) - await Promise.all([checkProfiles(agent, proc, timeout), checkMetrics]) - }) + const checkMetrics = agent.assertTelemetryReceived({ + fn: ({ _, payload }) => { + const pp = payload.payload; + ['live', 'used'].forEach(metricName => { + const sampleContexts = pp.series.find(s => s.metric === `wall.async_contexts_${metricName}`) + assert.notStrictEqual(sampleContexts, undefined) + assert.strictEqual(sampleContexts.type, 'gauge') + assert.ok(sampleContexts.points[0][1] >= 1, `Expected ${sampleContexts.points[0][1]} >= 1`) + }) + }, + requestType: 'generate-metrics', + timeout, + resolveAtFirstSuccess: true, + namespace: 'profilers', + }) + + await Promise.all([checkProfiles(agent, proc, timeout), checkMetrics]) + }) + } }) function forkSsi (args) { diff --git a/integration-tests/remote_config.spec.js b/integration-tests/remote_config.spec.js index ecf3ec2223c..527ece0279b 100644 --- a/integration-tests/remote_config.spec.js +++ b/integration-tests/remote_config.spec.js @@ -1,11 +1,14 @@ 'use strict' const assert = require('node:assert/strict') - -const path = require('path') +const { once } = require('node:events') +const path = require('node:path') const { inspect } = require('node:util') + const Axios = require('axios') + const { sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('./helpers') + describe('Remote config client id', () => { let axios, cwd, appFile @@ -47,39 +50,24 @@ describe('Remote config client id', () => { }) }) - it('should include process tags in remote config requests', (done) => { - const handleRemoteConfigRequest = (payload) => { - try { - const { client } = payload - assert.ok(client, 'client should exist in remote config request') - assert.ok(client.client_tracer, 'client_tracer should exist') - assert.ok(client.client_tracer.process_tags, 'process_tags should exist') - - const processTags = client.client_tracer.process_tags - - // Verify process_tags is an array of strings - assert.ok(Array.isArray(processTags), 'process_tags should be an array') - - // Verify required process tags are present - assert.ok(processTags.some(tag => tag.startsWith('entrypoint.basedir:')), `Got: ${inspect(processTags)}`) - assert.ok(processTags.some(tag => tag.startsWith('entrypoint.name:')), `Got: ${inspect(processTags)}`) - assert.ok(processTags.some(tag => tag.startsWith('entrypoint.type:')), `Got: ${inspect(processTags)}`) - assert.ok(processTags.some(tag => tag.startsWith('entrypoint.workdir:')), `Got: ${inspect(processTags)}`) - - // Verify entrypoint.type has the expected value - assert.ok(processTags.some(tag => tag === 'entrypoint.type:script'), `Got: ${inspect(processTags)}`) - agent.removeListener('remote-config-request', handleRemoteConfigRequest) - done() - } catch (err) { - agent.removeListener('remote-config-request', handleRemoteConfigRequest) - done(err) - } - } - - agent.on('remote-config-request', handleRemoteConfigRequest) - + it('should include process tags in remote config requests', async () => { + const request = once(agent, 'remote-config-request') // Trigger a request to ensure remote config is polled - axios.get('/').catch(() => {}) + await axios.get('/') + const [{ client }] = await request + + assert.ok(client, 'client should exist in remote config request') + assert.ok(client.client_tracer, 'client_tracer should exist') + assert.ok(client.client_tracer.process_tags, 'process_tags should exist') + + const processTags = client.client_tracer.process_tags + + assert.ok(Array.isArray(processTags), 'process_tags should be an array') + assert.ok(processTags.some(tag => tag.startsWith('entrypoint.basedir:')), `Got: ${inspect(processTags)}`) + assert.ok(processTags.some(tag => tag.startsWith('entrypoint.name:')), `Got: ${inspect(processTags)}`) + assert.ok(processTags.some(tag => tag.startsWith('entrypoint.type:')), `Got: ${inspect(processTags)}`) + assert.ok(processTags.some(tag => tag.startsWith('entrypoint.workdir:')), `Got: ${inspect(processTags)}`) + assert.ok(processTags.some(tag => tag === 'entrypoint.type:script'), `Got: ${inspect(processTags)}`) }) }) diff --git a/integration-tests/startup.spec.js b/integration-tests/startup.spec.js index 8c748234600..be6ccc097ca 100644 --- a/integration-tests/startup.spec.js +++ b/integration-tests/startup.spec.js @@ -95,8 +95,9 @@ execArgvs.forEach(({ execArgv, skip, optional = true }) => { }) // This feature requires libdatadog which is an optional dependency. - if (optional) { - it('saves tracer configuration on disk', async () => { + { + const libdatadogTest = optional ? it : it.skip + libdatadogTest('saves tracer configuration on disk', async () => { if (process.platform !== 'linux') { return } diff --git a/integration-tests/webdriverio/fixtures/automatic-log-submission-logger.js b/integration-tests/webdriverio/fixtures/automatic-log-submission-logger.js new file mode 100644 index 00000000000..f73e672992d --- /dev/null +++ b/integration-tests/webdriverio/fixtures/automatic-log-submission-logger.js @@ -0,0 +1,21 @@ +'use strict' + +let logger + +if (process.env.TEST_LOGGER === 'bunyan') { + logger = require('bunyan').createLogger({ name: 'test-logger' }) +} else if (process.env.TEST_LOGGER === 'pino') { + logger = require('pino')({ level: 'info' }) +} else { + const { createLogger, format, transports } = require('winston') + logger = createLogger({ + level: 'info', + exitOnError: false, + format: format.json(), + transports: [ + new transports.Console(), + ], + }) +} + +module.exports = logger diff --git a/integration-tests/webdriverio/fixtures/automatic-log-submission.e2e.js b/integration-tests/webdriverio/fixtures/automatic-log-submission.e2e.js new file mode 100644 index 00000000000..f5aba8120d3 --- /dev/null +++ b/integration-tests/webdriverio/fixtures/automatic-log-submission.e2e.js @@ -0,0 +1,14 @@ +'use strict' + +const assert = require('node:assert/strict') + +const logger = require('./automatic-log-submission-logger') + +describe('WebdriverIO automatic log submission', () => { + it('logs from an active Test Optimization span', () => { + const activeSpan = require('dd-trace').scope().active() + + assert.ok(activeSpan) + logger.info('Hello from WebdriverIO!') + }) +}) diff --git a/integration-tests/webdriverio/fixtures/wdio.conf.js b/integration-tests/webdriverio/fixtures/wdio.conf.js index 7294e14b1b2..8632a560292 100644 --- a/integration-tests/webdriverio/fixtures/wdio.conf.js +++ b/integration-tests/webdriverio/fixtures/wdio.conf.js @@ -32,6 +32,13 @@ const baseConfig = { } const scenarioConfig = { + automaticLogSubmission: { + after () { + require('./automatic-log-submission-logger').info('Hello from WebdriverIO after hook!') + }, + maxInstances: 1, + specs: ['./automatic-log-submission.e2e.js'], + }, atr: { maxInstances: 1, specs: ['./atr.e2e.js'], diff --git a/integration-tests/webdriverio/webdriverio.test-optimization.spec.js b/integration-tests/webdriverio/webdriverio.test-optimization.spec.js index 009c29a56d9..f45d9a0aee5 100644 --- a/integration-tests/webdriverio/webdriverio.test-optimization.spec.js +++ b/integration-tests/webdriverio/webdriverio.test-optimization.spec.js @@ -9,6 +9,7 @@ const path = require('node:path') const { getCiVisAgentlessConfig, + getCiVisEvpProxyConfig, sandboxCwd, useSandbox, } = require('../helpers') @@ -139,6 +140,16 @@ function countRequests (payloads, requestPath) { return payloads.filter(({ url }) => url.endsWith(requestPath)).length } +/** + * Gets automatic log-submission requests from intake payloads. + * + * @param {object[]} payloads + * @returns {object[]} + */ +function getLogRequests (payloads) { + return payloads.filter(({ url }) => url.startsWith('/api/v2/logs?')) +} + for (const version of versions) { describe(`webdriverio@${version} Test Optimization`, function () { this.timeout(90_000) @@ -154,6 +165,9 @@ for (const version of versions) { `@wdio/jasmine-framework@${version}`, `@wdio/local-runner@${version}`, `@wdio/mocha-framework@${version}`, + 'bunyan', + 'pino', + 'winston', ], true, [ './integration-tests/webdriverio/fixtures/*', './integration-tests/ci-visibility/dynamic-instrumentation/dependency.js', @@ -289,6 +303,93 @@ for (const version of versions) { ) } + if (version === 'latest') { + describe('automatic log submission', () => { + const loggers = { + bunyan: { level: 30, messageKey: 'msg' }, + pino: { level: 30, messageKey: 'msg' }, + winston: { level: 'info', messageKey: 'message' }, + } + + for (const [loggerName, { level: expectedLevel, messageKey }] of Object.entries(loggers)) { + describe(`with ${loggerName}`, () => { + it('submits correlated logs', async () => { + await runScenario('automaticLogSubmission', 1, payloads => { + const logRequests = getLogRequests(payloads) + + assert.ok(logRequests.length > 0) + for (const logRequest of logRequests) { + assert.strictEqual(logRequest.headers['dd-api-key'], '1') + assert.strictEqual(logRequest.headers['content-type'], 'application/json') + assert.strictEqual( + logRequest.url, + `/api/v2/logs?ddsource=${loggerName}&service=my-service` + ) + } + + const logMessages = logRequests.flatMap(({ logMessage }) => logMessage) + assert.strictEqual(logMessages.length, 2) + + const logMessage = logMessages.find( + logMessage => logMessage[messageKey] === 'Hello from WebdriverIO!' + ) + const afterHookLogMessage = logMessages.find( + logMessage => logMessage[messageKey] === 'Hello from WebdriverIO after hook!' + ) + const test = getEvents(payloads).find(event => event.type === 'test').content + + assert.ok(logMessage) + assert.strictEqual(logMessage.level, expectedLevel) + assert.deepStrictEqual(Object.keys(logMessage.dd).sort(), ['service', 'span_id', 'trace_id']) + assert.strictEqual(logMessage.dd.service, 'my-service') + assert.strictEqual(logMessage.dd.span_id, test.span_id.toString()) + assert.strictEqual(logMessage.dd.trace_id, test.trace_id.toString()) + assert.ok(afterHookLogMessage) + assert.strictEqual(afterHookLogMessage.level, expectedLevel) + }, { + DD_AGENTLESS_LOG_SUBMISSION_ENABLED: '1', + DD_AGENTLESS_LOG_SUBMISSION_URL: `http://127.0.0.1:${receiver.port}`, + DD_SERVICE: 'my-service', + TEST_LOGGER: loggerName, + }) + + assert.match(testOutput, /Hello from WebdriverIO!/) + }) + + it('does not submit logs when automatic submission is disabled', async () => { + await runScenario('automaticLogSubmission', 1, payloads => { + assert.strictEqual(getLogRequests(payloads).length, 0) + }, { + DD_AGENTLESS_LOG_SUBMISSION_URL: `http://127.0.0.1:${receiver.port}`, + DD_SERVICE: 'my-service', + TEST_LOGGER: loggerName, + }) + + assert.match(testOutput, /Hello from WebdriverIO!/) + assert.match(testOutput, /span_id/) + }) + + it('does not submit logs when the API key is missing', async () => { + await runScenario('automaticLogSubmission', 1, payloads => { + assert.strictEqual(getLogRequests(payloads).length, 0) + }, { + ...getCiVisEvpProxyConfig(receiver.port), + DD_AGENTLESS_LOG_SUBMISSION_ENABLED: '1', + DD_AGENTLESS_LOG_SUBMISSION_URL: `http://127.0.0.1:${receiver.port}`, + DD_API_KEY: '', + DD_SERVICE: 'my-service', + NODE_OPTIONS: '-r dd-trace/ci/init --import dd-trace/register.js', + TEST_LOGGER: loggerName, + }) + + assert.match(testOutput, /Hello from WebdriverIO!/) + assert.match(testOutput, /span_id/) + }) + }) + } + }) + } + it('requests enabled data once and keeps TIA disabled across parallel workers', async () => { receiver.setSettings({ code_coverage: true, diff --git a/package.json b/package.json index 5370d7657f5..c32f956811a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dd-trace", - "version": "6.12.0", + "version": "6.13.0", "description": "Datadog APM tracing client for JavaScript", "main": "index.js", "typings": "index.d.ts", @@ -115,7 +115,7 @@ "test:integration:plugins:coverage": "yarn services && node ./integration-tests/coverage/run-suite.js \"packages/datadog-plugin-@(${PLUGINS})/test/integration-test/**/${SPEC:-*}*.spec.js\"", "test:unit:plugins": "mocha \"packages/datadog-instrumentations/test/@(${PLUGINS}).spec.js\" \"packages/datadog-plugin-@(${PLUGINS})/test/**/*.spec.js\" --exclude \"packages/datadog-plugin-@(${PLUGINS})/test/integration-test/**/*.spec.js\"", "test:release": "mocha \"scripts/release/**/*.spec.js\"", - "test:scripts": "mocha \"benchmark/sirun/*.spec.js\" \"scripts/helpers/**/*.spec.js\" \"scripts/*.spec.mjs\"", + "test:scripts": "mocha \"benchmark/e2e-test-optimization/*.spec.js\" \"benchmark/sirun/*.spec.js\" \"scripts/helpers/**/*.spec.js\" \"scripts/*.spec.mjs\"", "test:shimmer": "mocha \"packages/datadog-shimmer/test/**/*.spec.js\"", "test:shimmer:ci": "node scripts/c8-ci.js test:shimmer", "verify:workflow-job-names": "node scripts/verify-workflow-job-names.js", @@ -184,11 +184,11 @@ }, "optionalDependencies": { "@datadog/libdatadog": "0.12.1", - "@datadog/native-appsec": "11.0.1", - "@datadog/native-iast-taint-tracking": "4.2.0", - "@datadog/native-metrics": "3.1.2", + "@datadog/native-appsec": "11.0.2", + "@datadog/native-iast-taint-tracking": "4.2.1", + "@datadog/native-metrics": "4.0.0", "@datadog/pprof": "5.18.1", - "@datadog/wasm-js-rewriter": "5.0.3", + "@datadog/wasm-js-rewriter": "5.0.4", "@opentelemetry/api": ">=1.0.0 <1.10.0", "@opentelemetry/api-logs": "<1.0.0", "oxc-parser": "^0.132.0" @@ -200,7 +200,7 @@ "@eslint/eslintrc": "^3.3.6", "@eslint/js": "^10.0.1", "@eslint/plugin-kit": "^0.7.2", - "@datadog/openfeature-node-server": "2.1.0", + "@datadog/openfeature-node-server": "2.2.0", "@msgpack/msgpack": "^3.1.3", "@openfeature/core": "^1.12.0", "@openfeature/server-sdk": "~1.23.0", @@ -208,18 +208,18 @@ "@types/mocha": "^10.0.10", "@types/node": "^18.19.106", "@types/sinon": "^22.0.0", - "@vercel/nft": "^1.10.2", + "@vercel/nft": "^1.11.0", "@yarnpkg/lockfile": "^1.1.0", "axios": "^1.19.0", "benchmark": "^2.1.4", "body-parser": "^2.3.0", - "bun": "1.3.14", + "bun": "1.4.0", "c8": "^12.0.0", "codeowners-audit": "^2.9.0", "eslint": "^10.8.1", "eslint-plugin-cypress": "^7.0.0", "eslint-plugin-import-x": "^4.16.2", - "eslint-plugin-jsdoc": "^64.2.0", + "eslint-plugin-jsdoc": "^64.2.1", "eslint-plugin-mocha": "^11.3.0", "eslint-plugin-n": "^18.3.0", "eslint-plugin-promise": "^7.3.0", diff --git a/packages/datadog-instrumentations/src/bunyan.js b/packages/datadog-instrumentations/src/bunyan.js index 74a0f6bb89e..1c3f60950fe 100644 --- a/packages/datadog-instrumentations/src/bunyan.js +++ b/packages/datadog-instrumentations/src/bunyan.js @@ -1,9 +1,11 @@ 'use strict' const wrapLogger = require('./helpers/bunyan') -const { addHook } = require('./helpers/instrument') +const { addHook, channel } = require('./helpers/instrument') + +const logSubmissionCh = channel('ci:log-submission:log') addHook({ name: 'bunyan', versions: ['>=1'] }, Logger => { - wrapLogger(Logger, 'bunyan') + wrapLogger(Logger, 'bunyan', logSubmissionCh) return Logger }) diff --git a/packages/datadog-instrumentations/src/helpers/bunyan.js b/packages/datadog-instrumentations/src/helpers/bunyan.js index 464fa9fe746..fc8f45648d8 100644 --- a/packages/datadog-instrumentations/src/helpers/bunyan.js +++ b/packages/datadog-instrumentations/src/helpers/bunyan.js @@ -6,17 +6,26 @@ const { channel } = require('./instrument') /** * @param {{ prototype: object }} Logger * @param {string} id + * @param {import('node:diagnostics_channel').Channel} [logSubmissionCh] */ -module.exports = function wrapLogger (Logger, id) { +module.exports = function wrapLogger (Logger, id, logSubmissionCh) { const logCh = channel(`apm:${id}:log`) + // The record must be replaceable before Bunyan emits it, while the serialized line is only available afterward. + // This before-and-after contract requires the existing runtime wrapper rather than an Orchestrion subscriber. shimmer.wrap(Logger.prototype, '_emit', emit => { return function wrappedEmit (rec) { if (logCh.hasSubscribers) { const payload = { message: rec } logCh.publish(payload) - arguments[0] = payload.message + rec = arguments[0] = payload.message } - return emit.apply(this, arguments) + + // Reuse Bunyan's cycle-safe serialization instead of serializing the record again. + const line = emit.apply(this, arguments) + if (logSubmissionCh?.hasSubscribers && logCh.hasSubscribers && !arguments[1]) { + logSubmissionCh.publish({ source: id, message: line ?? rec }) + } + return line } }) } diff --git a/packages/datadog-instrumentations/src/helpers/pool-acquire.js b/packages/datadog-instrumentations/src/helpers/pool-acquire.js index b54a2e87e09..2519555c529 100644 --- a/packages/datadog-instrumentations/src/helpers/pool-acquire.js +++ b/packages/datadog-instrumentations/src/helpers/pool-acquire.js @@ -621,6 +621,7 @@ module.exports = { clearPoolWaitTime, dispatchesAcquireSynchronously, isPoolQueryAcquire, + reportPoolAcquireError, runOutsidePoolQueryAcquire, runPoolAcquireError, runWithPoolWait, diff --git a/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js b/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js index c6725510a5d..5f03bbe7e83 100644 --- a/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +++ b/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js @@ -54,6 +54,34 @@ module.exports = [ channelName: 'LocalRunner_shutdown', transform: 'waitForAsyncEnd', }, + { + module: { + name: '@wdio/runner', + versionRange: '>=9.0.0', + filePath: 'build/index.js', + }, + functionQuery: { + className: 'BaseReporter', + methodName: 'waitForSync', + kind: 'Async', + }, + channelName: 'BaseReporter_waitForSync', + }, + { + module: { + name: '@wdio/runner', + versionRange: '>=9.0.0', + filePath: 'build/index.js', + }, + astQuery: 'VariableDeclarator[id.name="BaseReporter"] > ClassExpression > ClassBody > ' + + 'MethodDefinition[key.name="waitForSync"] ReturnStatement > ' + + 'CallExpression[callee.object.name="promise"][callee.property.name="then"], ' + + 'ClassDeclaration[id.name="BaseReporter"] > ClassBody > ' + + 'MethodDefinition[key.name="waitForSync"] ReturnStatement > ' + + 'CallExpression[callee.object.name="promise"][callee.property.name="then"]', + channelName: 'BaseReporter_waitForSync', + transform: 'waitForAsyncEnd', + }, { module: { name: '@wdio/jasmine-framework', diff --git a/packages/datadog-instrumentations/src/helpers/rewriter/targets.json b/packages/datadog-instrumentations/src/helpers/rewriter/targets.json index 4261aa56bd2..0692f3b24f2 100644 --- a/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +++ b/packages/datadog-instrumentations/src/helpers/rewriter/targets.json @@ -33,6 +33,7 @@ "@wdio/cli/build/index.js": "@wdio/cli", "@wdio/jasmine-framework/build/index.js": "@wdio/jasmine-framework", "@wdio/local-runner/build/index.js": "@wdio/local-runner", + "@wdio/runner/build/index.js": "@wdio/runner", "@wdio/utils/build/index.js": "@wdio/utils", "ai/dist/index.js": "ai", "ai/dist/index.mjs": "ai", diff --git a/packages/datadog-instrumentations/src/jest.js b/packages/datadog-instrumentations/src/jest.js index e24a12003de..a9c070cdf9d 100644 --- a/packages/datadog-instrumentations/src/jest.js +++ b/packages/datadog-instrumentations/src/jest.js @@ -20,7 +20,6 @@ const { } = require('../../dd-trace/src/ci-visibility/efd-retry-policy') const { getCoveredFilesFromCoverage, - getExecutableFilesFromCoverage, JEST_WORKER_TRACE_PAYLOAD_CODE, JEST_WORKER_COVERAGE_PAYLOAD_CODE, JEST_WORKER_TELEMETRY_PAYLOAD_CODE, @@ -39,7 +38,7 @@ const { logAttemptToFixTestExecution, logTestOptimizationSummary, TEST_IMPACT_ANALYSIS_ALL_TESTS_SKIPPED_MESSAGE, - getTestCoverageLinesPercentage, + getTestCoverageLinesData, applySkippedCoverageToCoverage, getTestOptimizationRequestResults, } = require('../../dd-trace/src/plugins/util/test') @@ -174,9 +173,11 @@ const newTests = new Set() const testSuiteJestObjects = new Map() const testSuiteDatadogEnvironments = new Map() const wrappedJestGlobals = new WeakSet() +const wrappedJestEsmLoaders = new WeakSet() const wrappedJestObjects = new WeakSet() const wrappedWorkerInitializers = new WeakSet() const publishedRuntimeReferenceErrors = new WeakMap() +const jestEsmBypassModulePathsByRuntime = new WeakMap() const wrappedCoverageReporters = new WeakSet() const coverageReporterRequires = new WeakMap() const handledJestEvents = new WeakSet() @@ -2667,16 +2668,15 @@ function getTestSessionCoveragePayload (results, fallbackRootDir) { if (isSuitesSkipped) { applySkippedCoverageToJestCoverageMap(coverageMap, coverageRootDir) } - payload.testCodeCoverageLinesTotal = getTestCoverageLinesPercentage( + const coverageLinesData = getTestCoverageLinesData( coverageMap, undefined, - coverageRootDir + coverageRootDir, + isTiaCoverageBackfillEnabled() ) - if (isTiaCoverageBackfillEnabled()) { - payload.testSessionCoverageFiles = getExecutableFilesFromCoverage(coverageMap).map(({ filename, bitmap }) => ({ - filename: getTestSuitePath(filename, coverageRootDir), - bitmap, - })) + payload.testCodeCoverageLinesTotal = coverageLinesData.percentage + if (coverageLinesData.executableFiles) { + payload.testSessionCoverageFiles = coverageLinesData.executableFiles } } catch { // ignore errors @@ -3728,7 +3728,13 @@ if (DD_MAJOR < 6) { }, jestConfigSyncWrapper) } +const LOGGING_LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE = new Set([ + 'bunyan', + 'pino', + 'winston', +]) const LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE = new Set([ + ...LOGGING_LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE, 'selenium-webdriver', 'selenium-webdriver/chrome', 'selenium-webdriver/edge', @@ -3736,7 +3742,6 @@ const LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE = new Set([ 'selenium-webdriver/firefox', 'selenium-webdriver/ie', 'selenium-webdriver/chromium', - 'winston', ]) function recordMockedFile (suiteFilePath, moduleName) { @@ -3786,8 +3791,6 @@ function wrapJestObject (jestObject, suiteFilePath) { wrappedJestObjects.add(jestObject) shimmer.wrap(jestObject, 'mock', mock => function (moduleName) { - // If the library is mocked with `jest.mock`, we don't want to bypass jest's own require engine - LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE.delete(moduleName) recordMockedFile(suiteFilePath, moduleName) return mock.apply(this, arguments) }) @@ -3870,6 +3873,111 @@ function requireOutsideJestRequireEngine (runtime, moduleName) { return require(moduleName) } +/** + * @param {object} runtime + * @param {string} from + * @param {string} moduleName + * @returns {void} + */ +function recordJestEsmBypassModulePath (runtime, from, moduleName) { + if (typeof from !== 'string' || !LOGGING_LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE.has(moduleName)) return + + let pathsByParent = jestEsmBypassModulePathsByRuntime.get(runtime) + if (!pathsByParent) { + pathsByParent = new Map() + jestEsmBypassModulePathsByRuntime.set(runtime, pathsByParent) + } + + let modulePaths = pathsByParent.get(from) + if (!modulePaths) { + modulePaths = new Map() + pathsByParent.set(from, modulePaths) + } + if (modulePaths.has(moduleName)) return + + try { + modulePaths.set(moduleName, createRequire(from).resolve(moduleName)) + } catch { + modulePaths.set(moduleName, undefined) + } +} + +/** + * @param {object} runtime + * @param {string} from + * @param {string} modulePath + * @returns {boolean} + */ +function hasJestEsmBypassModulePath (runtime, from, modulePath) { + const modulePaths = jestEsmBypassModulePathsByRuntime.get(runtime)?.get(from) + if (!modulePaths) return false + + for (const resolvedPath of modulePaths.values()) { + if (resolvedPath === modulePath) return true + } + return false +} + +/** + * @param {object} runtime + * @returns {void} + */ +function wrapJestEsmLoader (runtime) { + const esmLoader = runtime?.esmLoader + if (!esmLoader || wrappedJestEsmLoaders.has(esmLoader)) return + + wrappedJestEsmLoaders.add(esmLoader) + if (typeof esmLoader.resolveModule === 'function') { + shimmer.wrap(esmLoader, 'resolveModule', resolveModule => function (moduleName, from) { + recordJestEsmBypassModulePath(runtime, from, moduleName) + return resolveModule.apply(this, arguments) + }) + } + if (typeof esmLoader.resolveSpecifierForSyncGraph === 'function') { + shimmer.wrap( + esmLoader, + 'resolveSpecifierForSyncGraph', + resolveSpecifier => function (from, moduleName) { + recordJestEsmBypassModulePath(runtime, from, moduleName) + return resolveSpecifier.apply(this, arguments) + } + ) + } +} + +/** + * @param {object} runtime + * @param {string} from + * @param {string} moduleName + * @returns {string | undefined} + */ +function getJestBypassModulePath (runtime, from, moduleName) { + if (typeof from !== 'string' || typeof moduleName !== 'string') return + + if (!LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE.has(moduleName)) { + // Jest passes the resolved path when a CommonJS package is imported from an ESM test. + if (path.isAbsolute(moduleName) && hasJestEsmBypassModulePath(runtime, from, moduleName)) { + return moduleName + } + return + } + + try { + let jestModulePath + if (typeof runtime._resolveCjsModule === 'function') { + jestModulePath = runtime._resolveCjsModule(from, moduleName) + } else if (typeof runtime.cjsLoader?.resolution?.resolveCjs === 'function') { + jestModulePath = runtime.cjsLoader.resolution.resolveCjs(from, moduleName) + } else { + // Jest 24-27 uses this name for its synchronous CommonJS resolver. + jestModulePath = runtime._resolveModule(from, moduleName) + } + if (jestModulePath === createRequire(from).resolve(moduleName)) return jestModulePath + } catch { + // Let Jest produce its own resolution error or load a resolver-only module. + } +} + function formatDefaultStackTrace (error, structuredStackTrace) { const errorString = Error.prototype.toString.call(error) if (structuredStackTrace.length === 0) return errorString @@ -3893,10 +4001,29 @@ addHook({ }) } + // Jest 28 through 30.3 keeps ESM dependency resolution on Runtime itself. + if (typeof Runtime.prototype.resolveModule === 'function') { + shimmer.wrap(Runtime.prototype, 'resolveModule', resolveModule => function (moduleName, from) { + recordJestEsmBypassModulePath(this, from, moduleName) + return resolveModule.apply(this, arguments) + }) + } + + if (typeof Runtime.prototype.unstable_importModule === 'function') { + shimmer.wrap(Runtime.prototype, 'unstable_importModule', importModule => function () { + wrapJestEsmLoader(this) + return importModule.apply(this, arguments) + }) + } + shimmer.wrap(Runtime.prototype, 'requireModule', requireModule => function (from, moduleName) { wrapJestGlobalsForRuntime(this) try { - const returnedValue = requireModule.apply(this, arguments) + // Jest calls requireModule only after deciding that the module should not be mocked. + const bypassModulePath = getJestBypassModulePath(this, from, moduleName) + const returnedValue = bypassModulePath + ? requireOutsideJestRequireEngine(this, bypassModulePath) + : requireModule.apply(this, arguments) if (moduleName === '@jest/globals') { wrapConcurrentJestGlobalsForRuntime(this, returnedValue) } @@ -3925,11 +4052,6 @@ addHook({ return formatDefaultStackTrace(error, filteredStackTrace) } try { - // TODO: do this for every library that we instrument - if (LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE.has(moduleName)) { - // To bypass jest's own require engine - return requireOutsideJestRequireEngine(this, moduleName) - } let returnedValue try { returnedValue = requireModuleOrMock.apply(this, arguments) diff --git a/packages/datadog-instrumentations/src/mariadb-bundle.js b/packages/datadog-instrumentations/src/mariadb-bundle.js new file mode 100644 index 00000000000..7139fa64362 --- /dev/null +++ b/packages/datadog-instrumentations/src/mariadb-bundle.js @@ -0,0 +1,1597 @@ +'use strict' + +const { errorMonitor } = require('node:events') +const { performance } = require('node:perf_hooks') + +const shimmer = require('../../datadog-shimmer') +const { channel } = require('./helpers/instrument') +const { acquireWait, reportPoolAcquireError } = require('./helpers/pool-acquire') + +const connectionStartCh = channel('apm:mariadb:connection:start') +const connectionFinishCh = channel('apm:mariadb:connection:finish') +const startCh = channel('apm:mariadb:query:start') +const finishCh = channel('apm:mariadb:query:finish') +const errorCh = channel('apm:mariadb:query:error') +const skipCh = channel('apm:mariadb:pool:skip') +const acquireStartCh = channel('apm:mariadb:pool:acquire:start') +const acquireFinishCh = channel('apm:mariadb:pool:acquire:finish') +const poolAcquireChannels = { + connectionFinishCh, + acquireStartCh, + acquireFinishCh, +} + +const activeCommands = new WeakMap() +const commandMethods = ['query', 'execute', 'batch'] +const trackedCommandMethods = ['changeUser', 'ping', 'prepare', 'reset'] +const emptyConnectionContext = { currentStore: {} } +const emptyOptions = {} +const wrappedClients = new WeakSet() +const wrappedConnections = new WeakSet() +const IMPORT_FILE_RESOURCE = 'IMPORT FILE' +const noop = () => {} +const POOL_ACQUISITION_COMPACTION_THRESHOLD = 1024 +const STATUS_IN_TRANSACTION = 1 +const transactionMethods = [ + ['beginTransaction', 'START TRANSACTION'], + ['commit', 'COMMIT'], + ['rollback', 'ROLLBACK'], +] + +/** @typedef {{ length: number, [index: number]: unknown } & Iterable} ArgumentsLike */ +/** @typedef {{ options: object, pendingRemovals: number }} ClusterNodeOptions */ +/** @typedef {{ options?: object }} ClusterSelection */ +/** @typedef {import('node:async_hooks').AsyncLocalStorage} ClusterSelectionStorage */ +/** + * @typedef {object} PoolAcquisition + * @property {Record} [acquireCtx] + * @property {boolean} [acquired] + * @property {object} connectionCtx + * @property {unknown} [error] + * @property {boolean} [errorReported] + * @property {boolean} [finished] + * @property {boolean} measure + * @property {object} options + * @property {object} pool + * @property {object} [queryCtx] + * @property {boolean} [queryStarted] + * @property {boolean} [ready] + * @property {number} [start] + */ +/** @typedef {import('node:async_hooks').AsyncLocalStorage} PoolAcquisitionStorage */ +/** @typedef {{ acquisitions: Array, index: number }} PendingPoolAcquisitions */ + +/** @type {ClusterSelectionStorage | undefined} */ +let clusterSelectionStorage + +/** @type {PoolAcquisitionStorage | undefined} */ +let poolAcquisitionStorage + +/** @type {WeakMap} */ +const pendingPoolAcquisitions = new WeakMap() + +/** + * Creates cluster selection storage only when an application uses pool clusters. + * + * @returns {ClusterSelectionStorage} + */ +function getClusterSelectionStorage () { + if (clusterSelectionStorage === undefined) { + const { AsyncLocalStorage } = require('node:async_hooks') + clusterSelectionStorage = new AsyncLocalStorage() + } + return clusterSelectionStorage +} + +/** + * Creates pool acquisition storage only when an application uses a bundled pool. + * + * @returns {PoolAcquisitionStorage} + */ +function getPoolAcquisitionStorage () { + if (poolAcquisitionStorage === undefined) { + const { AsyncLocalStorage } = require('node:async_hooks') + poolAcquisitionStorage = new AsyncLocalStorage() + } + return poolAcquisitionStorage +} + +/** + * Extracts SQL from MariaDB's string and object command forms. + * + * @param {unknown} command + * @returns {unknown} + */ +function normalizeSql (command) { + return command?.sql ?? command +} + +/** + * Parses connection options without allowing instrumentation to break the factory call. + * + * @param {Function} defaultOptions + * @param {unknown} options + * @returns {object} + */ +function normalizeOptions (defaultOptions, options) { + try { + return defaultOptions(options) + } catch { + return options !== null && typeof options === 'object' ? options : {} + } +} + +/** + * Creates acquisition state while the caller context is still active. + * + * @param {object} pool + * @param {object} options + * @param {object} [queryCtx] + * @param {'explicit' | 'measure' | 'observe'} mode + * @returns {PoolAcquisition} + */ +function createPoolAcquisition (pool, options, queryCtx, mode) { + const explicit = mode === 'explicit' + const measure = explicit || mode === 'measure' + const connectionCtx = measure || queryCtx !== undefined ? {} : emptyConnectionContext + const acquisition = { connectionCtx, measure, options, pool, queryCtx } + + if (connectionCtx !== emptyConnectionContext) connectionStartCh.publish(connectionCtx) + if (explicit) { + acquisition.acquireCtx = { conf: options } + acquireStartCh.publish(acquisition.acquireCtx) + } + + return acquisition +} + +/** + * Queues a delayed acquisition for pool events that no longer carry its async-local context. + * + * @param {PoolAcquisition} acquisition + * @returns {void} + */ +function queuePoolAcquisition (acquisition) { + let pending = pendingPoolAcquisitions.get(acquisition.pool) + if (pending === undefined) { + pending = { acquisitions: [], index: 0 } + pendingPoolAcquisitions.set(acquisition.pool, pending) + } + pending.acquisitions.push(acquisition) +} + +/** + * Reports whether an acquisition still needs a matching pool event. + * + * @param {PoolAcquisition} acquisition + * @returns {boolean} + */ +function isPoolAcquisitionPending (acquisition) { + return !acquisition.acquired && !acquisition.finished && !acquisition.errorReported +} + +/** + * Releases a consumed acquisition and periodically compacts its queue. + * + * @param {PendingPoolAcquisitions} pending + * @returns {void} + */ +function discardPoolAcquisition (pending) { + pending.acquisitions[pending.index++] = undefined + + if ( + pending.index < POOL_ACQUISITION_COMPACTION_THRESHOLD || + pending.index * 2 < pending.acquisitions.length || + pending.index === pending.acquisitions.length + ) { + return + } + + pending.acquisitions = pending.acquisitions.slice(pending.index) + pending.index = 0 +} + +/** + * Removes completed entries from the front of a pool's acquisition queue. + * + * @param {object} pool + * @returns {void} + */ +function prunePoolAcquisitions (pool) { + const pending = pendingPoolAcquisitions.get(pool) + if (pending === undefined) return + + while (pending.index < pending.acquisitions.length) { + const acquisition = /** @type {PoolAcquisition} */ (pending.acquisitions[pending.index]) + if (isPoolAcquisitionPending(acquisition)) return + + discardPoolAcquisition(pending) + } + + pendingPoolAcquisitions.delete(pool) +} + +/** + * Takes the next unfinished acquisition from a pool's public-operation queue. + * + * @param {object} pool + * @returns {PoolAcquisition | undefined} + */ +function takePoolAcquisition (pool) { + const pending = pendingPoolAcquisitions.get(pool) + if (pending === undefined) return + + while (pending.index < pending.acquisitions.length) { + const acquisition = /** @type {PoolAcquisition} */ (pending.acquisitions[pending.index]) + discardPoolAcquisition(pending) + + if (isPoolAcquisitionPending(acquisition)) return acquisition + } + + pendingPoolAcquisitions.delete(pool) +} + +/** + * Records the pool wait when MariaDB announces that the calling operation acquired a connection. + * + * @param {object} pool + * @returns {void} + */ +function recordPoolAcquisition (pool) { + let acquisition = poolAcquisitionStorage?.getStore() + if (acquisition?.pool !== pool || !isPoolAcquisitionPending(acquisition)) { + acquisition = takePoolAcquisition(pool) + } + if (acquisition === undefined) return + + acquisition.acquired = true + prunePoolAcquisitions(pool) + if (!acquisition.measure) { + startPoolCommand(acquisition) + return + } + + const poolWaitTime = acquireWait(acquisition.start) + + if (acquisition.queryCtx !== undefined) acquisition.queryCtx.poolWaitTime = poolWaitTime + if (acquisition.acquireCtx !== undefined) acquisition.acquireCtx.poolWaitTime = poolWaitTime + + startPoolCommand(acquisition) +} + +/** + * Starts a pooled command after MariaDB has acquired the connection that will execute it. + * + * @param {PoolAcquisition} acquisition + * @returns {void} + */ +function startPoolCommand (acquisition) { + const queryCtx = acquisition.queryCtx + if (queryCtx === undefined || acquisition.queryStarted) return + + acquisition.queryStarted = true + connectionFinishCh.runStores(acquisition.connectionCtx, runCommandStart, undefined, queryCtx) +} + +/** + * Starts a command lifecycle without running connector work inside its span store. + * + * @param {object} ctx + * @returns {void} + */ +function runCommandStart (ctx) { + startCh.runStores(ctx, noop) +} + +/** + * Creates an acquire error span when a pooled query fails before receiving a connection. + * + * @param {PoolAcquisition | undefined} acquisition + * @param {unknown} error + * @returns {void} + */ +function reportPoolQueryAcquireError (acquisition, error) { + if (acquisition === undefined || acquisition.acquired || acquisition.errorReported) return + + acquisition.error = error + if (!acquisition.ready) return + + acquisition.errorReported = true + prunePoolAcquisitions(acquisition.pool) + connectionFinishCh.runStores(acquisition.connectionCtx, reportPoolAcquireError, undefined, + acquisition.start, error, { conf: acquisition.options }, poolAcquireChannels) +} + +/** + * Calls a method with its original receiver and arguments. + * + * @param {Function} method + * @param {unknown} receiver + * @param {ArgumentsLike} args + * @returns {unknown} + */ +function callMethod (method, receiver, args) { + return method.apply(receiver, args) +} + +/** + * Runs a public pool operation while associating its acquire event with that operation. + * + * @param {PoolAcquisition} acquisition + * @param {Function} method + * @param {object} receiver + * @param {ArgumentsLike} args + * @returns {unknown} + */ +function runPoolAcquisition (acquisition, method, receiver, args) { + const result = getPoolAcquisitionStorage().run(acquisition, callMethod, method, receiver, args) + + acquisition.ready = true + if (!acquisition.acquired && !acquisition.finished) { + queuePoolAcquisition(acquisition) + if (!acquisition.measure) return result + + if (acquisition.error === undefined) { + acquisition.start = performance.now() + } else { + reportPoolQueryAcquireError(acquisition, acquisition.error) + } + } + + return result +} + +/** + * Completes tracking for a pool command that has returned to the caller. + * + * @param {PoolAcquisition | undefined} acquisition + * @param {unknown} [error] + * @returns {void} + */ +function finishPoolCommandAcquisition (acquisition, error) { + if (acquisition === undefined) return + if (error && acquisition.measure) { + reportPoolQueryAcquireError(acquisition, error) + if (!acquisition.ready) return + } + if (!acquisition.acquired) acquisition.finished = true + prunePoolAcquisitions(acquisition.pool) +} + +/** + * Runs a bundled pool method in the instrumentation skip context. + * + * @param {Function} method + * @param {unknown} receiver + * @param {ArgumentsLike} args + * @returns {unknown} + */ +function runSkippedPoolMethod (method, receiver, args) { + return skipCh.runStores({}, method, receiver, ...args) +} + +/** + * Finishes the acquire span created for a public getConnection call. + * + * @param {PoolAcquisition} acquisition + * @param {unknown} [error] + * @returns {void} + */ +function finishExplicitPoolAcquisition (acquisition, error) { + if (acquisition.finished) return + acquisition.finished = true + prunePoolAcquisitions(acquisition.pool) + + const acquireCtx = acquisition.acquireCtx + if (acquireCtx === undefined) return + + acquireCtx.poolWaitTime ??= acquireWait(acquisition.start) + if (error) acquireCtx.error = error + acquireFinishCh.publish(acquireCtx) +} + +/** + * Restores the acquisition observer if application listener cleanup removed it. + * + * @param {object} pool + * @param {Function} observer + * @returns {void} + */ +function restorePoolAcquisitionObserver (pool, observer) { + const listeners = pool.listeners('acquire') + for (const listener of listeners) { + if (listener === observer) return + } + pool.prependListener('acquire', observer) +} + +/** + * Observes public acquire events forwarded by a bundled pool. + * + * @param {object} pool + * @returns {void} + */ +function observePoolAcquisitions (pool) { + const observer = () => { + if (connectionStartCh.hasSubscribers) recordPoolAcquisition(pool) + } + pool.prependListener('acquire', observer) + + shimmer.wrap(pool, 'removeAllListeners', removeAllListeners => function (event) { + const result = removeAllListeners.apply(this, arguments) + if (event === undefined || event === 'acquire') restorePoolAcquisitionObserver(pool, observer) + return result + }) +} + +/** + * Marks a command as active on its owning public connection. + * + * @param {object | undefined} owner + * @returns {void} + */ +function startCommand (owner) { + if (owner === undefined) return + activeCommands.set(owner, (activeCommands.get(owner) ?? 0) + 1) +} + +/** + * Releases one active command without clearing concurrent commands. + * + * @param {object | undefined} owner + * @returns {void} + */ +function endCommand (owner) { + if (owner === undefined) return + const active = activeCommands.get(owner) + if (active === 1) { + activeCommands.delete(owner) + } else if (active !== undefined) { + activeCommands.set(owner, active - 1) + } +} + +/** + * Publishes command completion state and releases its owner. + * + * @param {object} ctx + * @param {object | undefined} owner + * @param {Error} [error] + * @param {unknown} [result] + * @returns {void} + */ +function finishCommandState (ctx, owner, error, result) { + endCommand(owner) + if (error) { + ctx.error = error + errorCh.publish(ctx) + } + ctx.result = result +} + +/** + * Publishes a complete command lifecycle outside a callback continuation. + * + * @param {object} ctx + * @param {object | undefined} owner + * @param {Error} [error] + * @param {unknown} [result] + * @returns {void} + */ +function finishCommand (ctx, owner, error, result) { + finishCommandState(ctx, owner, error, result) + finishCh.publish(ctx) +} + +/** + * Replaces an existing callback or inserts one in the method's declared callback slot. + * + * @param {ArgumentsLike} args + * @param {number} callbackIndex + * @param {unknown} callback + * @param {(callback?: Function) => Function} createCallback + * @returns {void} + */ +function setCommandCallback (args, callbackIndex, callback, createCallback) { + if (typeof callback === 'function') { + args[args.length - 1] = shimmer.wrapCallback(callback, createCallback) + return + } + + const wrappedCallback = createCallback() + // MariaDB reads the declared callback slot, so appending after an explicit null leaves the wrapper unused. + if (callbackIndex >= 0 && args.length > callbackIndex && args[callbackIndex] == null) { + args[callbackIndex] = wrappedCallback + return + } + + args.length = Math.max(args.length + 1, callbackIndex + 1) + args[args.length - 1] = wrappedCallback +} + +/** + * Tracks an untraced promise command while MariaDB may have work queued for it. + * + * @param {Function} command + * @returns {Function} + */ +function createTrackPromiseCommand (command) { + return function () { + if (!startCh.hasSubscribers) return command.apply(this, arguments) + + const owner = this + startCommand(owner) + + let result + try { + result = command.apply(this, arguments) + } catch (error) { + endCommand(owner) + throw error + } + + return result.then(result => { + endCommand(owner) + return result + }, error => { + endCommand(owner) + throw error + }) + } +} + +/** + * Tracks an untraced callback command while MariaDB may have work queued for it. + * + * @param {Function} command + * @returns {Function} + */ +function createTrackCallbackCommand (command) { + const callbackIndex = command.length - 1 + + return function () { + if (!startCh.hasSubscribers) return command.apply(this, arguments) + + const owner = this + let finished = false + const finish = () => { + if (finished) return + finished = true + endCommand(owner) + } + const createCallback = callback => function () { + finish() + if (typeof callback === 'function') return callback.apply(this, arguments) + } + + const callback = arguments[arguments.length - 1] + setCommandCallback(arguments, callbackIndex, callback, createCallback) + startCommand(owner) + + try { + return command.apply(this, arguments) + } catch (error) { + finish() + throw error + } + } +} + +/** + * Creates a promise-returning command wrapper. + * + * @param {object} options + * @param {unknown} [preparedSql] + * @param {object} [commandOwner] + * @param {number} [_commandArity] Reserved for callback command wrappers. + * @param {boolean} [trackActiveCommands] + * @param {'measure' | 'observe'} [poolAcquisition] + * @returns {(command: Function) => Function} + */ +function createWrapPromiseCommand ( + options, + preparedSql, + commandOwner, + _commandArity, + trackActiveCommands = true, + poolAcquisition +) { + return function wrapCommand (command) { + return function (sql) { + if (!startCh.hasSubscribers) return command.apply(this, arguments) + + const owner = trackActiveCommands ? (commandOwner ?? this) : undefined + const ctx = { sql: preparedSql ?? normalizeSql(sql), conf: options } + const acquisition = poolAcquisition === undefined + ? undefined + : createPoolAcquisition(this, options, ctx, poolAcquisition) + + startCommand(owner) + + let result + try { + result = acquisition === undefined + ? startCh.runStores(ctx, command, this, ...arguments) + : runPoolAcquisition(acquisition, command, this, arguments) + } catch (error) { + finishPoolCommandAcquisition(acquisition, error) + if (acquisition === undefined || acquisition.queryStarted) finishCommand(ctx, owner, error) + throw error + } + + return result.then(result => { + finishPoolCommandAcquisition(acquisition) + if (acquisition === undefined || acquisition.queryStarted) finishCommand(ctx, owner, undefined, result) + return result + }, error => { + finishPoolCommandAcquisition(acquisition, error) + if (acquisition === undefined || acquisition.queryStarted) finishCommand(ctx, owner, error) + throw error + }) + } + } +} + +/** + * Creates a callback command wrapper and supplies a completion callback when the caller omits one. + * + * @param {object} options + * @param {unknown} [preparedSql] + * @param {object} [commandOwner] + * @param {number} [commandArity] Original arity when command is wrapped by a variadic forwarding function. + * @param {boolean} [trackActiveCommands] + * @param {'measure' | 'observe'} [poolAcquisition] + * @returns {(command: Function) => Function} + */ +function createWrapCallbackCommand ( + options, + preparedSql, + commandOwner, + commandArity, + trackActiveCommands = true, + poolAcquisition +) { + return function wrapCommand (command) { + const callbackIndex = (commandArity ?? command.length) - 1 + + return function (sql) { + if (!startCh.hasSubscribers) return command.apply(this, arguments) + + const owner = trackActiveCommands ? (commandOwner ?? this) : undefined + const callback = arguments[arguments.length - 1] + const ctx = { sql: preparedSql ?? normalizeSql(sql), conf: options } + const acquisition = poolAcquisition === undefined + ? undefined + : createPoolAcquisition(this, options, ctx, poolAcquisition) + const wrapper = callback => function (error) { + finishPoolCommandAcquisition(acquisition, error) + if (acquisition !== undefined && !acquisition.queryStarted) { + return typeof callback === 'function' + ? connectionFinishCh.runStores(acquisition.connectionCtx, callback, this, ...arguments) + : undefined + } + + finishCommandState(ctx, owner, error) + + return typeof callback === 'function' + ? finishCh.runStores(ctx, callback, this, ...arguments) + : finishCh.runStores(ctx, noop, this) + } + + setCommandCallback(arguments, callbackIndex, callback, wrapper) + + startCommand(owner) + + try { + return acquisition === undefined + ? startCh.runStores(ctx, command, this, ...arguments) + : runPoolAcquisition(acquisition, command, this, arguments) + } catch (error) { + finishPoolCommandAcquisition(acquisition, error) + if (acquisition === undefined || acquisition.queryStarted) finishCommand(ctx, owner, error) + throw error + } + } + } +} + +/** + * Traces a Readable-returning command without replacing the stream. + * + * @param {object} options + * @param {unknown} [preparedSql] + * @param {object} [commandOwner] + * @returns {(streamMethod: Function) => Function} + */ +function createWrapStream (options, preparedSql, commandOwner) { + return function wrapStream (streamMethod) { + return function (sql) { + if (!startCh.hasSubscribers) return streamMethod.apply(this, arguments) + + const owner = commandOwner ?? this + const ctx = { sql: preparedSql ?? normalizeSql(sql), conf: options } + let stream + + startCommand(owner) + try { + stream = startCh.runStores(ctx, streamMethod, this, ...arguments) + } catch (error) { + finishCommand(ctx, owner, error) + throw error + } + + let finished = false + const cleanup = () => { + stream.removeListener('end', onEnd) + stream.removeListener('close', onClose) + stream.removeListener(errorMonitor, onError) + } + const complete = error => { + if (finished) return + finished = true + cleanup() + finishCommand(ctx, owner, error) + } + const onEnd = () => complete() + const onClose = () => complete() + const onError = error => complete(error) + + stream.once('end', onEnd) + stream.once('close', onClose) + stream.once(errorMonitor, onError) + + return stream + } + } +} + +/** + * Wraps command methods exposed by a bundled client. + * + * @param {object} client + * @param {(command: Function) => Function} wrapper + * @returns {void} + */ +function wrapClientCommands (client, wrapper) { + if (wrappedClients.has(client)) return + + wrappedClients.add(client) + for (const method of commandMethods) { + if (typeof client[method] === 'function') shimmer.wrap(client, method, wrapper) + } +} + +/** + * Wraps commands exposed by a bundled pool and tracks acquisition for query and execute. + * + * @param {object} pool + * @param {object} options + * @param {(options: object, sql?: unknown, owner?: object, commandArity?: number, + * trackActiveCommands?: boolean, poolAcquisition?: 'measure' | 'observe') => + * (command: Function) => Function} createWrapper + * @returns {void} + */ +function wrapPoolCommands (pool, options, createWrapper) { + if (wrappedClients.has(pool)) return + + wrappedClients.add(pool) + for (const method of commandMethods) { + if (typeof pool[method] !== 'function') continue + const poolAcquisition = method === 'query' || method === 'execute' ? 'measure' : 'observe' + shimmer.wrap(pool, method, createWrapPoolCommand(options, createWrapper, undefined, poolAcquisition)) + } +} + +/** + * Wraps transaction helpers whose bundled implementations bypass the public query methods. + * + * @param {object} client + * @param {object} options + * @param {(options: object, sql: string, owner?: object, commandArity?: number) => + * (command: Function) => Function} createWrapper + * @returns {void} + */ +function wrapTransactionMethods (client, options, createWrapper) { + for (const [method, sql] of transactionMethods) { + shimmer.wrap(client, method, createWrapTransaction(options, sql, createWrapper)) + } +} + +/** + * Traces a transaction helper only when MariaDB sends its command. + * + * @param {object} options + * @param {string} sql + * @param {(options: object, sql: string, owner?: object, commandArity?: number) => + * (command: Function) => Function} createWrapper + * @returns {(transaction: Function) => Function} + */ +function createWrapTransaction (options, sql, createWrapper) { + return function wrapTransaction (transaction) { + const wrapCommand = createWrapper(options, sql, undefined, transaction.length) + const tracedTransaction = wrapCommand(function () { + return skipCh.runStores({}, transaction, this, ...arguments) + }) + + return function () { + if (!startCh.hasSubscribers) return transaction.apply(this, arguments) + + if (sql !== 'START TRANSACTION' && + !activeCommands.has(this) && + !(this.info?.status & STATUS_IN_TRANSACTION)) { + return transaction.apply(this, arguments) + } + + return tracedTransaction.apply(this, arguments) + } + } +} + +/** + * Wraps a promise connection and the prepared statements it creates. + * + * @param {object} connection + * @param {object} options + * @returns {object} + */ +function wrapPromiseConnection (connection, options) { + wrapClientCommands(connection, createWrapPromiseCommand(options)) + if (wrappedConnections.has(connection)) return connection + + wrappedConnections.add(connection) + for (const method of trackedCommandMethods) { + if (typeof connection[method] === 'function') shimmer.wrap(connection, method, createTrackPromiseCommand) + } + shimmer.wrap(connection, 'importFile', createWrapPromiseCommand(options, IMPORT_FILE_RESOURCE)) + if (typeof connection.queryStream === 'function') { + shimmer.wrap(connection, 'queryStream', createWrapStream(options)) + } + shimmer.wrap(connection, 'prepare', createWrapPromisePrepare(options)) + wrapTransactionMethods(connection, options, createWrapPromiseCommand) + + return connection +} + +/** + * Wraps a callback connection and the prepared statements it creates. + * + * @param {object} connection + * @param {object} options + * @returns {object} + */ +function wrapCallbackConnection (connection, options) { + wrapClientCommands(connection, createWrapCallbackCommand(options)) + if (wrappedConnections.has(connection)) return connection + + wrappedConnections.add(connection) + for (const method of trackedCommandMethods) { + if (typeof connection[method] === 'function') shimmer.wrap(connection, method, createTrackCallbackCommand) + } + shimmer.wrap(connection, 'importFile', createWrapCallbackCommand(options, IMPORT_FILE_RESOURCE)) + if (typeof connection.queryStream === 'function') { + shimmer.wrap(connection, 'queryStream', createWrapStream(options)) + } + shimmer.wrap(connection, 'prepare', createWrapCallbackPrepare(options)) + wrapTransactionMethods(connection, options, createWrapCallbackCommand) + + return connection +} + +/** + * Wraps prepared statements created by a promise connection. + * + * @param {object} options + * @returns {(prepare: Function) => Function} + */ +function createWrapPromisePrepare (options) { + return function wrapPrepare (prepare) { + return function (sql) { + const connection = this + const preparedSql = normalizeSql(sql) + return prepare.apply(this, arguments).then(statement => { + shimmer.wrap(statement, 'execute', createWrapPromiseCommand(options, preparedSql, connection)) + if (typeof statement.executeStream === 'function') { + shimmer.wrap(statement, 'executeStream', createWrapStream(options, preparedSql, connection)) + } + return statement + }) + } + } +} + +/** + * Wraps prepared statements created by a callback connection. + * + * @param {object} options + * @returns {(prepare: Function) => Function} + */ +function createWrapCallbackPrepare (options) { + return function wrapPrepare (prepare) { + return function (sql) { + const connection = this + const preparedSql = normalizeSql(sql) + const callback = arguments[arguments.length - 1] + if (typeof callback !== 'function') return prepare.apply(this, arguments) + + arguments[arguments.length - 1] = function () { + const statement = arguments[1] + if (statement) { + shimmer.wrap( + statement, + 'execute', + createWrapCallbackPreparedExecute(options, preparedSql, connection) + ) + if (typeof statement.executeStream === 'function') { + shimmer.wrap(statement, 'executeStream', createWrapStream(options, preparedSql, connection)) + } + } + return callback.apply(this, arguments) + } + + return prepare.apply(this, arguments) + } + } +} + +/** + * Wraps callback prepared statements, which return a promise when no callback is provided. + * + * @param {object} options + * @param {unknown} sql + * @param {object} connection + * @returns {(execute: Function) => Function} + */ +function createWrapCallbackPreparedExecute (options, sql, connection) { + return function wrapExecute (execute) { + const wrapCallbackCommand = createWrapCallbackCommand(options, sql, connection) + const wrapPromiseCommand = createWrapPromiseCommand(options, sql, connection) + const executeWithCallback = wrapCallbackCommand(execute) + const executeWithPromise = wrapPromiseCommand(execute) + + return function () { + const hasCallback = typeof arguments[1] === 'function' || typeof arguments[2] === 'function' + const wrappedExecute = hasCallback ? executeWithCallback : executeWithPromise + return wrappedExecute.apply(this, arguments) + } + } +} + +/** + * Runs bundled pool internals in the skip store while tracing the public command. + * + * @param {object} options + * @param {(options: object, sql?: unknown, owner?: object, commandArity?: number, + * trackActiveCommands?: boolean, poolAcquisition?: 'measure' | 'observe') => + * (command: Function) => Function} createWrapper + * @param {unknown} [preparedSql] + * @param {'measure' | 'observe'} [poolAcquisition] + * @returns {(command: Function) => Function} + */ +function createWrapPoolCommand (options, createWrapper, preparedSql, poolAcquisition) { + return function wrapPoolCommand (command) { + const wrapCommand = createWrapper(options, preparedSql, undefined, command.length, false, poolAcquisition) + return wrapCommand(function () { + return skipCh.runStores({}, command, this, ...arguments) + }) + } +} + +/** + * Restores the caller context and instruments a pooled promise connection. + * + * @param {object} ctx + * @param {object} connection + * @param {object} options + * @returns {object} + */ +function finishPromiseGetConnection (ctx, connection, options) { + return connectionFinishCh.runStores(ctx, wrapPromiseConnection, undefined, connection, options) +} + +/** + * Restores the caller context when a promise acquisition fails. + * + * @param {object} ctx + * @param {Error} error + * @throws {Error} The connection acquisition error. + */ +function finishPromiseGetConnectionError (ctx, error) { + return connectionFinishCh.runStores(ctx, () => { throw error }) +} + +/** + * Finishes an explicit promise acquisition and instruments its connection in the caller context. + * + * @param {PoolAcquisition} acquisition + * @param {object} connection + * @param {object} options + * @returns {object} + */ +function finishExplicitPromiseGetConnection (acquisition, connection, options) { + return connectionFinishCh.runStores(acquisition.connectionCtx, () => { + finishExplicitPoolAcquisition(acquisition) + return wrapPromiseConnection(connection, options) + }) +} + +/** + * Finishes a failed explicit promise acquisition in the caller context. + * + * @param {PoolAcquisition} acquisition + * @param {Error} error + * @throws {Error} The connection acquisition error. + */ +function finishExplicitPromiseGetConnectionError (acquisition, error) { + return connectionFinishCh.runStores(acquisition.connectionCtx, () => { + finishExplicitPoolAcquisition(acquisition, error) + throw error + }) +} + +/** + * Wraps getConnection on a bundled promise pool. + * + * @param {object} options + * @returns {(getConnection: Function) => Function} + */ +function createWrapPromiseGetConnection (options) { + return function wrapGetConnection (getConnection) { + return function () { + if (!connectionStartCh.hasSubscribers) return getConnection.apply(this, arguments) + + if (!acquireStartCh.hasSubscribers) { + const ctx = {} + connectionStartCh.publish(ctx) + return skipCh.runStores({}, getConnection, this, ...arguments).then( + connection => finishPromiseGetConnection(ctx, connection, options), + error => finishPromiseGetConnectionError(ctx, error) + ) + } + + const acquisition = createPoolAcquisition(this, options, undefined, 'explicit') + let result + + try { + result = runPoolAcquisition(acquisition, runSkippedPoolMethod, undefined, [getConnection, this, arguments]) + } catch (error) { + return finishExplicitPromiseGetConnectionError(acquisition, error) + } + + return result.then( + connection => finishExplicitPromiseGetConnection(acquisition, connection, options), + error => finishExplicitPromiseGetConnectionError(acquisition, error) + ) + } + } +} + +/** + * Wraps getConnection on a bundled callback pool. + * + * @param {object} options + * @returns {(getConnection: Function) => Function} + */ +function createWrapCallbackGetConnection (options) { + return function wrapGetConnection (getConnection) { + return function () { + const callback = arguments[arguments.length - 1] + if (typeof callback !== 'function') return getConnection.apply(this, arguments) + + if (!connectionStartCh.hasSubscribers) return getConnection.apply(this, arguments) + + if (!acquireStartCh.hasSubscribers) { + const ctx = {} + arguments[arguments.length - 1] = function () { + const connection = arguments[1] + if (connection) wrapCallbackConnection(connection, options) + return connectionFinishCh.runStores(ctx, callback, this, ...arguments) + } + + connectionStartCh.publish(ctx) + return skipCh.runStores({}, getConnection, this, ...arguments) + } + + const acquisition = createPoolAcquisition(this, options, undefined, 'explicit') + arguments[arguments.length - 1] = function (error, connection) { + if (connection) wrapCallbackConnection(connection, options) + return connectionFinishCh.runStores(acquisition.connectionCtx, () => { + finishExplicitPoolAcquisition(acquisition, error) + return callback.apply(this, arguments) + }) + } + + try { + return runPoolAcquisition(acquisition, runSkippedPoolMethod, undefined, [getConnection, this, arguments]) + } catch (error) { + return finishExplicitPromiseGetConnectionError(acquisition, error) + } + } + } +} + +/** + * Instruments connection wrappers emitted when a bundled pool creates a connection. + * + * @param {object} pool + * @param {object} options + * @param {(connection: object, options: object) => object} wrapConnection + * @returns {void} + */ +function wrapPoolConnectionEvent (pool, options, wrapConnection) { + shimmer.wrap(pool, 'emit', emit => function (event, connection) { + if (event !== 'connection') return emit.apply(this, arguments) + wrapConnection(connection, options) + return connectionFinishCh.runStores(emptyConnectionContext, emit, this, ...arguments) + }) +} + +/** + * Captures the connection options registered with a pool cluster. + * + * @param {object} cluster + * @param {Function} defaultOptions + * @param {ClusterSelectionStorage} selectionStorage + * @returns {void} + */ +function captureClusterOptions (cluster, defaultOptions, selectionStorage) { + /** @type {Map} */ + const optionsByIdentifier = new Map() + let nodeCounter = 0 + + const removeOptions = identifier => { + const nodeOptions = optionsByIdentifier.get(identifier) + if (nodeOptions?.pendingRemovals) { + nodeOptions.pendingRemovals-- + } else { + optionsByIdentifier.delete(identifier) + } + } + + // ClusterCallback.on is bound to its private Cluster, so EventEmitter returns the internal + // runtime object for both APIs. MariaDB does not expose the selected node on the returned + // connection; capture _selectPool while the acquisition's async-local selection is active. + const internalCluster = cluster.on('remove', removeOptions) + if (typeof internalCluster._selectPool === 'function') { + shimmer.wrap(internalCluster, '_selectPool', selectPool => function () { + const identifier = selectPool.apply(this, arguments) + const selection = selectionStorage.getStore() + if (selection !== undefined) selection.options = optionsByIdentifier.get(identifier)?.options + return identifier + }) + } + + shimmer.wrap(cluster, 'add', add => function (identifier, options) { + const hasIdentifier = typeof identifier === 'string' || + Object.prototype.toString.call(identifier) === '[object String]' + const generatedIdentifier = hasIdentifier ? String(identifier) : `PoolNode-${nodeCounter++}` + const connectionOptions = hasIdentifier ? options : identifier + const result = skipCh.runStores({}, add, this, ...arguments) + const previousNodeOptions = optionsByIdentifier.get(generatedIdentifier) + + // MariaDB deletes a failed node before emitting its delayed remove event. A successful + // same-identifier add while options remain therefore adds one stale event to ignore. + const nodeOptions = { + options: normalizeOptions(defaultOptions, connectionOptions), + pendingRemovals: previousNodeOptions === undefined ? 0 : previousNodeOptions.pendingRemovals + 1, + } + optionsByIdentifier.set(generatedIdentifier, nodeOptions) + + return result + }) + + shimmer.wrap(cluster, 'remove', remove => function (pattern) { + const result = remove.apply(this, arguments) + removeClusterOptions(optionsByIdentifier, pattern) + return result + }) + + shimmer.wrap(cluster, 'end', end => function () { + const result = end.apply(this, arguments) + optionsByIdentifier.clear() + internalCluster.removeListener('remove', removeOptions) + return result + }) +} + +/** + * Removes options for every cluster node matching a selector. + * + * @param {Map} optionsByIdentifier + * @param {string} pattern + * @returns {void} + */ +function removeClusterOptions (optionsByIdentifier, pattern) { + const regularExpression = new RegExp(pattern) + + for (const identifier of optionsByIdentifier.keys()) { + regularExpression.lastIndex = 0 + if (regularExpression.test(identifier)) optionsByIdentifier.delete(identifier) + } +} + +/** + * Calls a cluster acquisition inside the pool-skip context while selection storage is active. + * + * @param {Function} getConnection + * @param {object} receiver + * @param {ArgumentsLike} args + * @returns {unknown} + */ +function runClusterGetConnection (getConnection, receiver, args) { + return skipCh.runStores({}, getConnection, receiver, ...args) +} + +/** + * Reports a failed bundled cluster node acquisition. + * + * @param {ClusterSelection} selection + * @param {number | undefined} start + * @param {unknown} error + * @returns {void} + */ +function reportBundledClusterAcquireError (selection, start, error) { + if (!acquireStartCh.hasSubscribers) return + reportPoolAcquireError(start, error, { conf: selection.options ?? emptyOptions }, poolAcquireChannels) +} + +/** + * Restores promise caller context and reports a failed bundled cluster node acquisition. + * + * @param {object} ctx + * @param {ClusterSelection} selection + * @param {number | undefined} start + * @param {unknown} error + * @throws {unknown} The connection acquisition error. + */ +function finishPromiseClusterGetConnectionError (ctx, selection, start, error) { + return connectionFinishCh.runStores(ctx, () => { + reportBundledClusterAcquireError(selection, start, error) + throw error + }) +} + +/** + * Restores callback caller context and finishes a bundled cluster acquisition. + * + * @param {ClusterSelection} selection + * @param {number | undefined} start + * @param {Function} callback + * @param {unknown} receiver + * @param {ArgumentsLike} args + * @returns {unknown} + */ +function finishCallbackClusterGetConnection (selection, start, callback, receiver, args) { + const error = args[0] + const connection = args[1] + if (error) reportBundledClusterAcquireError(selection, start, error) + if (connection) wrapCallbackConnection(connection, selection.options ?? emptyOptions) + return callback.apply(receiver, args) +} + +/** + * Wraps promise connections acquired from a bundled pool cluster. + * + * @param {ClusterSelectionStorage} selectionStorage + * @returns {(getConnection: Function) => Function} + */ +function createWrapPromiseClusterGetConnection (selectionStorage) { + return function wrapGetConnection (getConnection) { + return function () { + const ctx = {} + /** @type {ClusterSelection} */ + const selection = {} + const start = acquireStartCh.hasSubscribers ? performance.now() : undefined + + connectionStartCh.publish(ctx) + + const result = selectionStorage.run( + selection, + runClusterGetConnection, + getConnection, + this, + arguments + ) + + return result.then( + connection => finishPromiseGetConnection(ctx, connection, selection.options ?? emptyOptions), + error => finishPromiseClusterGetConnectionError(ctx, selection, start, error) + ) + } + } +} + +/** + * Wraps callback connections acquired from a bundled pool cluster. + * + * @param {ClusterSelectionStorage} selectionStorage + * @returns {(getConnection: Function) => Function} + */ +function createWrapCallbackClusterGetConnection (selectionStorage) { + return function wrapGetConnection (getConnection) { + return function () { + const callback = arguments[arguments.length - 1] + if (typeof callback !== 'function') return getConnection.apply(this, arguments) + + const ctx = {} + /** @type {ClusterSelection} */ + const selection = {} + const start = acquireStartCh.hasSubscribers ? performance.now() : undefined + arguments[arguments.length - 1] = function () { + return connectionFinishCh.runStores( + ctx, + finishCallbackClusterGetConnection, + undefined, + selection, + start, + callback, + this, + arguments + ) + } + + connectionStartCh.publish(ctx) + + return selectionStorage.run( + selection, + runClusterGetConnection, + getConnection, + this, + arguments + ) + } + } +} + +/** + * Wraps promise connections acquired from a bundled pool cluster. + * + * @param {object} cluster + * @param {Function} defaultOptions + * @returns {object} + */ +function wrapPromiseCluster (cluster, defaultOptions) { + const selectionStorage = getClusterSelectionStorage() + captureClusterOptions(cluster, defaultOptions, selectionStorage) + + shimmer.wrap(cluster, 'getConnection', createWrapPromiseClusterGetConnection(selectionStorage)) + + return cluster +} + +/** + * Wraps callback connections acquired from a bundled pool cluster. + * + * @param {object} cluster + * @param {Function} defaultOptions + * @returns {object} + */ +function wrapCallbackCluster (cluster, defaultOptions) { + const selectionStorage = getClusterSelectionStorage() + captureClusterOptions(cluster, defaultOptions, selectionStorage) + + shimmer.wrap(cluster, 'getConnection', createWrapCallbackClusterGetConnection(selectionStorage)) + // The filtered callback facade delegates to a private Cluster instance, bypassing the public method above. + shimmer.wrap(cluster, 'of', of => function () { + const filteredCluster = of.apply(this, arguments) + shimmer.wrap(filteredCluster, 'getConnection', createWrapCallbackClusterGetConnection(selectionStorage)) + return filteredCluster + }) + + return cluster +} + +/** + * Wraps the createConnection factory from a bundled promise entry. + * + * @param {Function} defaultOptions + * @returns {(createConnection: Function) => Function} + */ +function createWrapPromiseConnectionFactory (defaultOptions) { + return function wrapCreateConnection (createConnection) { + return function (options) { + return createConnection.apply(this, arguments).then(connection => { + return wrapPromiseConnection(connection, normalizeOptions(defaultOptions, options)) + }) + } + } +} + +/** + * Wraps the createConnection factory from a bundled callback entry. + * + * @param {Function} defaultOptions + * @returns {(createConnection: Function) => Function} + */ +function createWrapCallbackConnectionFactory (defaultOptions) { + return function wrapCreateConnection (createConnection) { + return function (options) { + const connection = createConnection.apply(this, arguments) + return wrapCallbackConnection(connection, normalizeOptions(defaultOptions, options)) + } + } +} + +/** + * Wraps the createPool factory from a bundled promise entry. + * + * @param {Function} defaultOptions + * @returns {(createPool: Function) => Function} + */ +function createWrapPromisePoolFactory (defaultOptions) { + return function wrapCreatePool (createPool) { + return function (options) { + const pool = skipCh.runStores({}, createPool, this, ...arguments) + const normalizedOptions = normalizeOptions(defaultOptions, options) + + observePoolAcquisitions(pool) + wrapPoolConnectionEvent(pool, normalizedOptions, wrapPromiseConnection) + wrapPoolCommands(pool, normalizedOptions, createWrapPromiseCommand) + shimmer.wrap( + pool, + 'importFile', + createWrapPoolCommand(normalizedOptions, createWrapPromiseCommand, IMPORT_FILE_RESOURCE, 'observe') + ) + shimmer.wrap(pool, 'getConnection', createWrapPromiseGetConnection(normalizedOptions)) + + return pool + } + } +} + +/** + * Wraps the createPool factory from a bundled callback entry. + * + * @param {Function} defaultOptions + * @returns {(createPool: Function) => Function} + */ +function createWrapCallbackPoolFactory (defaultOptions) { + return function wrapCreatePool (createPool) { + return function (options) { + const pool = skipCh.runStores({}, createPool, this, ...arguments) + const normalizedOptions = normalizeOptions(defaultOptions, options) + + observePoolAcquisitions(pool) + wrapPoolConnectionEvent(pool, normalizedOptions, wrapCallbackConnection) + wrapPoolCommands(pool, normalizedOptions, createWrapCallbackCommand) + shimmer.wrap( + pool, + 'importFile', + createWrapPoolCommand(normalizedOptions, createWrapCallbackCommand, IMPORT_FILE_RESOURCE, 'observe') + ) + shimmer.wrap(pool, 'getConnection', createWrapCallbackGetConnection(normalizedOptions)) + + return pool + } + } +} + +/** + * Wraps the createPoolCluster factory from a bundled promise entry. + * + * @param {Function} defaultOptions + * @returns {(createPoolCluster: Function) => Function} + */ +function createWrapPromiseClusterFactory (defaultOptions) { + return function wrapCreatePoolCluster (createPoolCluster) { + return function () { + return wrapPromiseCluster(createPoolCluster.apply(this, arguments), defaultOptions) + } + } +} + +/** + * Wraps the createPoolCluster factory from a bundled callback entry. + * + * @param {Function} defaultOptions + * @returns {(createPoolCluster: Function) => Function} + */ +function createWrapCallbackClusterFactory (defaultOptions) { + return function wrapCreatePoolCluster (createPoolCluster) { + return function () { + return wrapCallbackCluster(createPoolCluster.apply(this, arguments), defaultOptions) + } + } +} + +/** + * Wraps the top-level importFile helper from a bundled promise entry. + * + * @param {Function} defaultOptions + * @returns {(importFile: Function) => Function} + */ +function createWrapPromiseImportFile (defaultOptions) { + return function wrapImportFile (importFile) { + return function (options) { + const wrapCommand = createWrapPromiseCommand( + normalizeOptions(defaultOptions, options), + IMPORT_FILE_RESOURCE, + undefined, + undefined, + false + ) + const wrapper = wrapCommand(importFile) + return wrapper.apply(this, arguments) + } + } +} + +/** + * Wraps the top-level importFile helper from a bundled callback entry. + * + * @param {Function} defaultOptions + * @returns {(importFile: Function) => Function} + */ +function createWrapCallbackImportFile (defaultOptions) { + return function wrapImportFile (importFile) { + return function (options) { + const wrapCommand = createWrapCallbackCommand( + normalizeOptions(defaultOptions, options), + IMPORT_FILE_RESOURCE, + undefined, + undefined, + false + ) + const wrapper = wrapCommand(importFile) + return wrapper.apply(this, arguments) + } + } +} + +/** + * Wraps selected CommonJS factories in the mutable default export and its non-configurable namespace getters. + * + * @param {object} mariadb + * @param {Array<[string, (factory: Function) => Function]>} factories + * @returns {object} + */ +function wrapBundle (mariadb, factories) { + const defaultExport = mariadb.default + let wrappedBundle = mariadb + + for (const [name, wrapper] of factories) { + wrappedBundle = shimmer.wrap(wrappedBundle, name, wrapper, { replaceGetter: true }) + } + for (const [name] of factories) { + shimmer.wrap(defaultExport, name, () => wrappedBundle[name]) + } + + return wrappedBundle +} + +/** + * Instruments the promise API exported by MariaDB's 3.5.3+ CommonJS bundle. + * + * @param {object} mariadb + * @param {string} _version + * @param {boolean} isIitm + * @returns {object} + */ +function wrapPromiseBundle (mariadb, _version, isIitm) { + if (isIitm) return mariadb + + const defaultOptions = mariadb.defaultOptions + return wrapBundle(mariadb, [ + ['createConnection', createWrapPromiseConnectionFactory(defaultOptions)], + ['createPool', createWrapPromisePoolFactory(defaultOptions)], + ['createPoolCluster', createWrapPromiseClusterFactory(defaultOptions)], + ['importFile', createWrapPromiseImportFile(defaultOptions)], + ]) +} + +/** + * Instruments the callback API exported by MariaDB's 3.5.3+ CommonJS bundle. + * + * @param {object} mariadb + * @returns {object} + */ +function wrapCallbackBundle (mariadb) { + const defaultOptions = mariadb.defaultOptions + return wrapBundle(mariadb, [ + ['createConnection', createWrapCallbackConnectionFactory(defaultOptions)], + ['createPool', createWrapCallbackPoolFactory(defaultOptions)], + ['createPoolCluster', createWrapCallbackClusterFactory(defaultOptions)], + ['importFile', createWrapCallbackImportFile(defaultOptions)], + ]) +} + +module.exports = { wrapCallbackBundle, wrapPromiseBundle } diff --git a/packages/datadog-instrumentations/src/mariadb.js b/packages/datadog-instrumentations/src/mariadb.js index bd8ce1c3da9..c5edb1ee128 100644 --- a/packages/datadog-instrumentations/src/mariadb.js +++ b/packages/datadog-instrumentations/src/mariadb.js @@ -14,6 +14,7 @@ const { takePoolWaitTime, wrapPoolQueryMethod, } = require('./helpers/pool-acquire') +const { wrapCallbackBundle, wrapPromiseBundle } = require('./mariadb-bundle') const commandAddCh = channel('apm:mariadb:command:add') const connectionStartCh = channel('apm:mariadb:connection:start') @@ -443,3 +444,9 @@ addHook({ name, file: 'lib/connection.js', versions: ['>=2.0.4 <=2.5.1'] }, (Con addHook({ name, file: 'lib/pool-base.js', versions: ['>=2.0.4 <3'] }, (PoolBase) => { return shimmer.wrapFunction(PoolBase, wrapPoolBase) }) + +// MariaDB 3.5.3 added single-file CommonJS bundles that do not load the original source modules at runtime. +// Matching their generated, minified internals would couple instrumentation to unstable bundle output, so wrap +// the runtime objects returned by the public factories instead. +addHook({ name, versions: ['>=3.5.3'] }, wrapPromiseBundle) +addHook({ name, file: 'dist/callback.cjs', versions: ['>=3.5.3'] }, wrapCallbackBundle) diff --git a/packages/datadog-instrumentations/src/pino.js b/packages/datadog-instrumentations/src/pino.js index 0dae6795acb..2701f1ecd32 100644 --- a/packages/datadog-instrumentations/src/pino.js +++ b/packages/datadog-instrumentations/src/pino.js @@ -6,6 +6,8 @@ const { addHook, } = require('./helpers/instrument') +const logSubmissionCh = channel('ci:log-submission:log') + /** * @param {string} symbol * @param {(original: Function) => Function} wrapper @@ -36,13 +38,22 @@ function wrapAsJson (asJson) { obj = arguments[0] = obj || {} // Caller-provided `dd` wins -- skip the splice so a bespoke `dd` survives. + let line if (!jsonCh.hasSubscribers || Object.hasOwn(obj, 'dd')) { - return asJson.apply(this, arguments) + line = asJson.apply(this, arguments) + } else { + const payload = { line: asJson.apply(this, arguments) } + jsonCh.publish(payload) + line = payload.line + } + + // Submit the serialized line for agentless log collection only when trace + // correlation is active, matching the Bunyan contract. + if (jsonCh.hasSubscribers && logSubmissionCh.hasSubscribers) { + logSubmissionCh.publish({ source: 'pino', message: line }) } - const payload = { line: asJson.apply(this, arguments) } - jsonCh.publish(payload) - return payload.line + return line } } diff --git a/packages/datadog-instrumentations/src/playwright.js b/packages/datadog-instrumentations/src/playwright.js index 0e501b4c4d9..983b3ebb202 100644 --- a/packages/datadog-instrumentations/src/playwright.js +++ b/packages/datadog-instrumentations/src/playwright.js @@ -59,6 +59,7 @@ const testSuiteStartCh = channel('ci:playwright:test-suite:start') const testSuiteFinishCh = channel('ci:playwright:test-suite:finish') const workerReportCh = channel('ci:playwright:worker:report') +const logSubmissionFlushCh = channel('ci:log-submission:flush') const workerReportTelemetryCh = channel('ci:playwright:worker-report:telemetry') const testPageGotoCh = channel('ci:playwright:test:page-goto') @@ -1085,7 +1086,7 @@ function onDispatcherCreateWorker (dispatcher, worker) { const projects = getProjectsFromDispatcher(dispatcher) sessionProjects = projects - const automaticFailureScreenshotPathsByTestId = new Map() + const automaticFailureScreenshotsByTestId = new Map() if (disabledTestIds.size && !worker[kDdPlaywrightWorkerHostInstrumented] && typeof worker.runTestGroup === 'function') { @@ -1110,19 +1111,21 @@ function onDispatcherCreateWorker (dispatcher, worker) { const test = getTestByTestId(dispatcher, testId) if (!test) return + automaticFailureScreenshotsByTestId.clear() const browser = getBrowserNameFromProjects(projects, test) const shouldCreateTestSpan = test.expectedStatus === 'skipped' testBeginHandler(test, browser, shouldCreateTestSpan) }) - worker.on('attach', ({ testId, path, _ddIsAutomaticFailureScreenshot }) => { + worker.on('attach', (attachment) => { + const { testId, _ddIsAutomaticFailureScreenshot } = attachment if (!_ddIsAutomaticFailureScreenshot) return - let screenshotPaths = automaticFailureScreenshotPathsByTestId.get(testId) - if (!screenshotPaths) { - screenshotPaths = new Set() - automaticFailureScreenshotPathsByTestId.set(testId, screenshotPaths) + let screenshots = automaticFailureScreenshotsByTestId.get(testId) + if (!screenshots) { + screenshots = [] + automaticFailureScreenshotsByTestId.set(testId, screenshots) } - screenshotPaths.add(path) + screenshots.push(attachment) }) worker.on('testEnd', ({ testId, status, errors, annotations }) => { const test = getTestByTestId(dispatcher, testId) @@ -1146,19 +1149,20 @@ function onDispatcherCreateWorker (dispatcher, worker) { } ) const testResult = test.results.at(-1) - const automaticFailureScreenshotPaths = automaticFailureScreenshotPathsByTestId.get(testId) - automaticFailureScreenshotPathsByTestId.delete(testId) - if (testStatus === 'fail' && automaticFailureScreenshotPaths?.size && testResult?.attachments?.length) { - const screenshots = [] - for (const attachment of testResult.attachments) { - if (automaticFailureScreenshotPaths.has(attachment.path)) { - screenshots.push(attachment) + if (isFailureScreenshotUploadEnabled && + !shouldCreateTestSpan && + !test._ddShouldSkipEfdRetry && + !disabledTestIds.has(testId)) { + let screenshots + if (testStatus === 'fail') { + screenshots = automaticFailureScreenshotsByTestId.get(testId) + if (!screenshots) { + screenshots = [] + automaticFailureScreenshotsByTestId.set(testId, screenshots) } } - if (screenshots.length) { - worker[kDdPlaywrightFailureScreenshots] ??= [] - worker[kDdPlaywrightFailureScreenshots].push(screenshots) - } + worker[kDdPlaywrightFailureScreenshots] ??= [] + worker[kDdPlaywrightFailureScreenshots].push(screenshots) } const isAtrRetry = testResult?.retry > 0 && isFlakyTestRetriesEnabled && @@ -2212,7 +2216,8 @@ addHook({ function instrumentWorkerMainMethods (workerMain) { if (!workerMain || workerMain[kDdPlaywrightWorkerInstrumented] || - typeof workerMain._runTest !== 'function' || typeof workerMain.dispatchEvent !== 'function') { + typeof workerMain._runTest !== 'function' || typeof workerMain.dispatchEvent !== 'function' || + typeof workerMain.gracefullyClose !== 'function') { return workerMain } @@ -2228,6 +2233,15 @@ function instrumentWorkerMainMethods (workerMain) { return runTestGroup.apply(this, arguments) }) + // Playwright >=1.60 creates WorkerMain through a runtime factory, which Orchestrion cannot wrap statically. + shimmer.wrap(workerMain, 'gracefullyClose', gracefullyClose => async function () { + try { + return await gracefullyClose.apply(this, arguments) + } finally { + await getChannelPromise(logSubmissionFlushCh) + } + }) + shimmer.wrap(workerMain, '_runTest', _runTest => async function (test) { if (this[kDdPlaywrightDisabledTestIds]?.has(test.id)) { test._ddIsDisabled = true @@ -2393,7 +2407,7 @@ function instrumentWorkerMainMethods (workerMain) { // We reproduce what happens in `Dispatcher#_onStepBegin` and `Dispatcher#_onStepEnd`, // since `startTime` and `duration` are not available directly in the worker process shimmer.wrap(workerMain, 'dispatchEvent', dispatchEvent => function (event, payload) { - if (event === 'testBegin' || event === 'testEnd') { + if (event === 'testBegin') { automaticFailureScreenshotPaths.clear() } else if (event === 'stepBegin') { stepInfoByStepId[payload.stepId] = { diff --git a/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js b/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js index b8894615569..b519e7719ff 100644 --- a/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +++ b/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js @@ -1602,15 +1602,18 @@ function splitNodeOptions (nodeOptions) { } function serializeNodeOptions (tokens) { - const serializedTokens = [] + let serializedTokens = '' + let hasSerializedToken = false for (const token of tokens) { + if (hasSerializedToken) serializedTokens += ' ' if (NODE_OPTIONS_QUOTE_RE.test(token)) { - serializedTokens.push(JSON.stringify(token)) + serializedTokens += JSON.stringify(token) } else { - serializedTokens.push(token) + serializedTokens += token } + hasSerializedToken = true } - return serializedTokens.join(' ') + return serializedTokens } function getTestSpecificationProject (testSpecification) { diff --git a/packages/datadog-instrumentations/src/vitest-worker.js b/packages/datadog-instrumentations/src/vitest-worker.js index c741c632cbb..437ad9b921c 100644 --- a/packages/datadog-instrumentations/src/vitest-worker.js +++ b/packages/datadog-instrumentations/src/vitest-worker.js @@ -5,6 +5,7 @@ const { performance } = require('node:perf_hooks') const { fileURLToPath } = require('node:url') const { isMainThread, parentPort } = require('node:worker_threads') +const { channel } = require('dc-polyfill') const shimmer = require('../../datadog-shimmer') const log = require('../../dd-trace/src/log') const { getEfdRetryCountForDuration } = require('../../dd-trace/src/ci-visibility/efd-retry-policy') @@ -40,10 +41,12 @@ const { } = require('./vitest-util') const EFD_SUITE_ADMISSION_TIMEOUT_MS = 5000 +const logSubmissionFlushCh = channel('ci:log-submission:flush') const taskToCtx = new WeakMap() const taskToTestProperties = new WeakMap() const taskToStatuses = new WeakMap() const taskToReportedErrorCount = new WeakMap() +const runnersWithLogSubmissionCleanup = new WeakSet() const attemptToFixTaskToStatuses = new WeakMap() const fileToHasConcurrentTests = new WeakMap() const fileToEfdSuiteAdmission = new WeakMap() @@ -954,6 +957,14 @@ addHook({ if (!testSuiteFinishCh.hasSubscribers) { return startTests.apply(this, arguments) } + const runner = arguments[1] + // Vitest 3+ exposes the only awaited worker-shutdown boundary; older versions keep timer/before-exit behavior. + if (logSubmissionFlushCh.hasSubscribers && + typeof runner?.onCleanupWorkerContext === 'function' && + !runnersWithLogSubmissionCleanup.has(runner)) { + runnersWithLogSubmissionCleanup.add(runner) + runner.onCleanupWorkerContext(() => getChannelPromise(logSubmissionFlushCh)) + } // From >=3.0.1, the first arguments changes from a string to an object containing the filepath const testSuiteAbsolutePath = testPaths[0]?.filepath || testPaths[0] const providedContext = getProvidedContext() diff --git a/packages/datadog-instrumentations/src/webdriverio.js b/packages/datadog-instrumentations/src/webdriverio.js index 3cd62eac164..360e3cef8b5 100644 --- a/packages/datadog-instrumentations/src/webdriverio.js +++ b/packages/datadog-instrumentations/src/webdriverio.js @@ -16,6 +16,7 @@ const { MOCHA_WORKER_TRACE_PAYLOAD_CODE, TEST_SUITE_EXECUTION_ID, } = require('../../dd-trace/src/plugins/util/test') +const { publishWithCompletion } = require('./helpers/channel') const { addHook, channel, tracingChannel } = require('./helpers/instrument') const { CONFIGURATION_REQUEST, @@ -38,6 +39,7 @@ const testSuiteStartCh = channel('ci:mocha:test-suite:start') const testSuiteFinishCh = channel('ci:mocha:test-suite:finish') const knownTestsCh = channel('ci:mocha:known-tests') const libraryConfigurationCh = channel('ci:mocha:library-configuration') +const logSubmissionFlushCh = channel('ci:log-submission:flush') const modifiedFilesCh = channel('ci:mocha:modified-files') const testManagementTestsCh = channel('ci:mocha:test-management-tests') const workerConfigurationCh = channel('ci:mocha:worker:configuration') @@ -46,6 +48,7 @@ const workerReportTelemetryCh = channel('ci:mocha:worker-report:telemetry') const workerReportTraceCh = channel('ci:mocha:worker-report:trace') const jasmineAdapterInitCh = tracingChannel('orchestrion:@wdio/jasmine-framework:JasmineAdapter_init') +const baseReporterWaitForSyncCh = tracingChannel('orchestrion:@wdio/runner:BaseReporter_waitForSync') const launcherStartInstanceCh = tracingChannel('orchestrion:@wdio/cli:Launcher_startInstance') const localRunnerRunCh = tracingChannel('orchestrion:@wdio/local-runner:LocalRunner_run') const localRunnerShutdownCh = tracingChannel('orchestrion:@wdio/local-runner:LocalRunner_shutdown') @@ -1000,6 +1003,29 @@ function finishCoordinator (state, error, onDone) { }) } +/** + * Delays WebdriverIO worker exit until pending log-submission requests settle. + * + * @param {{ + * resolveCallback?: (onDone: () => void) => void, + * rejectCallback?: (onDone: () => void) => void + * }} context + * @returns {void} + */ +function waitForLogSubmissionAtWorkerExit (context) { + if (!isWebdriverioWorker || !logSubmissionFlushCh.hasSubscribers) { + return + } + + const waitForLogs = onDone => publishWithCompletion(logSubmissionFlushCh, {}, onDone) + context.resolveCallback = waitForLogs + context.rejectCallback = waitForLogs +} + +baseReporterWaitForSyncCh.asyncEnd.subscribe( + /** @type {import('node:diagnostics_channel').ChannelListener} */ (waitForLogSubmissionAtWorkerExit) +) + // dc-polyfill supports partial tracing-channel subscribers, unlike the Node.js type definition. // @ts-expect-error jasmineAdapterInitCh.subscribe({ diff --git a/packages/datadog-instrumentations/src/winston.js b/packages/datadog-instrumentations/src/winston.js index 9a968a33fa7..ebcc7a1ad92 100644 --- a/packages/datadog-instrumentations/src/winston.js +++ b/packages/datadog-instrumentations/src/winston.js @@ -12,12 +12,15 @@ const patched = new WeakSet() const configureCh = channel('ci:log-submission:winston:configure') const addTransport = channel('ci:log-submission:winston:add-transport') -addHook({ name: 'winston', file: 'lib/winston/transports/index.js', versions: ['>=3'] }, transportsPackage => { +addHook({ name: 'winston', versions: ['>=3'] }, winston => { if (configureCh.hasSubscribers) { - configureCh.publish(transportsPackage.Http) + configureCh.publish({ + createJsonFormat: winston.format.json, + StreamTransport: winston.transports.Stream, + }) } - return transportsPackage + return winston }) addHook({ name: 'winston', file: 'lib/winston/logger.js', versions: ['>=3'] }, Logger => { diff --git a/packages/datadog-instrumentations/test/fixtures/node_modules/@wdio/runner/package.json b/packages/datadog-instrumentations/test/fixtures/node_modules/@wdio/runner/package.json new file mode 100644 index 00000000000..3374cbd1fbb --- /dev/null +++ b/packages/datadog-instrumentations/test/fixtures/node_modules/@wdio/runner/package.json @@ -0,0 +1,5 @@ +{ + "name": "@wdio/runner", + "version": "9.31.3", + "type": "module" +} diff --git a/packages/datadog-instrumentations/test/fixtures/webdriverio-runner.mjs b/packages/datadog-instrumentations/test/fixtures/webdriverio-runner.mjs new file mode 100644 index 00000000000..44f81f6662a --- /dev/null +++ b/packages/datadog-instrumentations/test/fixtures/webdriverio-runner.mjs @@ -0,0 +1,23 @@ +const BaseReporter = class { + waitForSync () { + return Promise.resolve(true) + } +} + +const Runner = class { + constructor () { + this._reporter = new BaseReporter() + } + + async _shutdown (failures) { + await this._reporter.waitForSync() + this.emit('exit', failures === 0 ? 0 : 1) + return failures + } + + emit (event, code) { + this.onEvent?.(event, code) + } +} + +export { BaseReporter, Runner } diff --git a/packages/datadog-instrumentations/test/helpers/register.spec.js b/packages/datadog-instrumentations/test/helpers/register.spec.js index 82b91b573a4..154df3baa62 100644 --- a/packages/datadog-instrumentations/test/helpers/register.spec.js +++ b/packages/datadog-instrumentations/test/helpers/register.spec.js @@ -146,6 +146,18 @@ describe('register', () => { assert.strictEqual(result, moduleExports) sinon.assert.notCalled(patch) + const unsupportedModuleExports = { default: class Query {} } + const unsupportedVersion = hook( + unsupportedModuleExports, + 'mariadb/lib/cmd/query.js', + '/path/to/mariadb', + '3.5.0', + true + ) + + assert.strictEqual(unsupportedVersion, unsupportedModuleExports) + sinon.assert.notCalled(patch) + const Query = class Query {} patch.returns('patched') diff --git a/packages/datadog-instrumentations/test/vitest-main.spec.js b/packages/datadog-instrumentations/test/vitest-main.spec.js index ea84b6b8b43..69d9d27c45d 100644 --- a/packages/datadog-instrumentations/test/vitest-main.spec.js +++ b/packages/datadog-instrumentations/test/vitest-main.spec.js @@ -144,6 +144,7 @@ describe('vitest main instrumentation', () => { }, } const realInstrument = require('../src/helpers/instrument') + const realNoWorkerInit = require('../src/vitest-main-no-worker-init') const realVitestUtil = require('../src/vitest-util') proxyquire('../src/vitest-main', { @@ -181,6 +182,7 @@ describe('vitest main instrumentation', () => { }, }, './vitest-main-no-worker-init': { + ...realNoWorkerInit, configure (_ctx, _frameworkVersion, _testSpecifications, _setupData, options) { reserveEarlyFlakeDetectionSuite = options.reserveEarlyFlakeDetectionSuite noWorkerInitStates.push(options.state) @@ -299,6 +301,38 @@ describe('vitest main instrumentation', () => { ]]) assert.strictEqual(noWorkerInitStates[noWorkerInitStates.length - 1].isEfdSuiteAdmissionEnabled, false) + class Vitest { + /** @param {object[]} testSpecifications test specifications */ + async runFiles (testSpecifications) { + return testSpecifications + } + } + const cliApiHook = hooks.find(({ target }) => target.filePattern === 'dist/chunks/cli-api.*').hook + cliApiHook({ async startVitest () {}, Vitest }, '4.1.10') + await Vitest.prototype.runFiles.call(ctx, [[ + { config: { pool: 'forks' } }, + { filepath: '/repo/no-worker.mjs', pool: 'threads' }, + ]]) + + class TinyPool { + /** @param {{ env: Record, filename: string }} options worker pool options */ + constructor (options) { + this.options = options + } + } + const tinyPoolHook = hooks.find(({ target }) => target.name === 'tinypool').hook + const DatadogTinyPool = tinyPoolHook(TinyPool) + const pool = new DatadogTinyPool({ + env: { + NODE_OPTIONS: '--require dd-trace/ci/init --conditions "custom condition"', + VITEST: 'true', + }, + filename: '/repo/node_modules/vitest/dist/worker.js', + }) + assert.strictEqual(pool.options.env.NODE_OPTIONS, '--conditions "custom condition"') + assert.strictEqual(pool.options.env.DD_TEST_OPT_VITEST_NO_WORKER_INIT_ACTIVE, '1') + assert.strictEqual(pool.options.env.DD_VITEST_WORKER, '1') + assert.deepStrictEqual( libraryConfigurationRequests.map(request => request.isVitestNoWorkerInitActive), [true, true, true, true, true, true, true] diff --git a/packages/datadog-instrumentations/test/webdriverio.spec.js b/packages/datadog-instrumentations/test/webdriverio.spec.js index 48f458af979..46963fb4ba1 100644 --- a/packages/datadog-instrumentations/test/webdriverio.spec.js +++ b/packages/datadog-instrumentations/test/webdriverio.spec.js @@ -53,6 +53,16 @@ const fixtureModulePath = path.join( 'build', 'index.js' ) +const runnerFixturePath = path.join(__dirname, 'fixtures', 'webdriverio-runner.mjs') +const runnerFixtureModulePath = path.join( + __dirname, + 'fixtures', + 'node_modules', + '@wdio', + 'runner', + 'build', + 'index.js' +) const jasmineFixturePath = path.join(__dirname, 'fixtures', 'webdriverio-jasmine-framework.mjs') const jasmineFixtureModulePath = path.join( __dirname, @@ -115,6 +125,16 @@ describe('webdriverio instrumentation', () => { assert.match(rewrittenSource, /__apm\$ctx\.rejectCallback/) }) + it('rewrites the ESM worker runner and waits before worker exit', () => { + const source = fs.readFileSync(runnerFixturePath, 'utf8') + const rewrittenSource = rewriter.rewrite(source, runnerFixtureModulePath, 'module') + + assert.notStrictEqual(rewrittenSource, source) + assert.match(rewrittenSource, /orchestrion:@wdio\/runner:BaseReporter_waitForSync/) + assert.match(rewrittenSource, /__apm\$ctx\.resolveCallback/) + assert.match(rewrittenSource, /__apm\$ctx\.rejectCallback/) + }) + it('rewrites the ESM Jasmine adapter and reporter', () => { const source = fs.readFileSync(jasmineFixturePath, 'utf8') const rewrittenSource = rewriter.rewrite(source, jasmineFixtureModulePath, 'module') @@ -294,6 +314,44 @@ describe('webdriverio instrumentation', () => { } }) + it('waits for worker completion before Runner._shutdown emits exit', async () => { + const source = fs.readFileSync(runnerFixturePath, 'utf8') + const rewrittenSource = rewriter.rewrite(source, runnerFixtureModulePath, 'module') + const outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-webdriverio-runner-rewriter-')) + const outputPath = path.join(outputDirectory, 'index.mjs') + const reporterWaitForSyncCh = tracingChannel('orchestrion:@wdio/runner:BaseReporter_waitForSync') + const steps = [] + const subscriber = { + asyncEnd (context) { + steps.push('asyncEnd') + context.resolveCallback = onDone => { + setImmediate(() => { + steps.push('logs') + onDone() + }) + } + }, + } + + fs.writeFileSync(outputPath, rewrittenSource) + reporterWaitForSyncCh.subscribe(subscriber) + + try { + const { Runner } = await import(pathToFileURL(outputPath)) + const runner = new Runner() + runner.onEvent = (event, code) => steps.push(`${event}:${code}`) + const resultPromise = runner._shutdown(0) + + await Promise.resolve() + + assert.deepStrictEqual(steps, ['asyncEnd']) + assert.strictEqual(await resultPromise, 0) + assert.deepStrictEqual(steps, ['asyncEnd', 'logs', 'exit:0']) + } finally { + reporterWaitForSyncCh.unsubscribe(subscriber) + } + }) + it('waits for coordinator readiness before resolving JasmineAdapter.init', async () => { const source = fs.readFileSync(jasmineFixturePath, 'utf8') const rewrittenSource = rewriter.rewrite(source, jasmineFixtureModulePath, 'module') diff --git a/packages/datadog-plugin-ai/test/index.spec.js b/packages/datadog-plugin-ai/test/index.spec.js index ca4d310427c..c9a4e6733be 100644 --- a/packages/datadog-plugin-ai/test/index.spec.js +++ b/packages/datadog-plugin-ai/test/index.spec.js @@ -85,8 +85,9 @@ describe('Plugin', () => { }) describe('patching behavior with experimental_telemetry options', () => { - if (semifies(realVersion, '>=6.0.0')) { - it('preserves the original model error with a dd-trace OTel tracer', async () => { + { + const v6Test = semifies(realVersion, '>=6.0.0') ? it : it.skip + v6Test('preserves the original model error with a dd-trace OTel tracer', async () => { const originalError = new Error('original model error') const model = { specificationVersion: 'v3', @@ -256,8 +257,9 @@ describe('Plugin', () => { assert.ok(result.text, 'Expected result to be truthy') }) - if (semifies(realVersion, '>=6.0.0')) { - it('delegates the complete span interface to the original span', async () => { + { + const v6Test = semifies(realVersion, '>=6.0.0') ? it : it.skip + v6Test('delegates the complete span interface to the original span', async () => { const calls = [] const context = { traceId: '0'.repeat(32), spanId: '0'.repeat(16), traceFlags: 1 } const originalError = new Error('model error') diff --git a/packages/datadog-plugin-ai/test/index.v7.spec.js b/packages/datadog-plugin-ai/test/index.v7.spec.js index 07dcd815328..0889ddf36c6 100644 --- a/packages/datadog-plugin-ai/test/index.v7.spec.js +++ b/packages/datadog-plugin-ai/test/index.v7.spec.js @@ -106,32 +106,35 @@ describe('Plugin', () => { await checkTraces }) - it('creates a span for embedMany', async function () { - if (!semifies(resolvedVersion, '>=7.0.23')) this.skip() - - const checkTraces = agent.assertSomeTraces(traces => { - const spans = traces[0] - const embedManySpan = spans.find(s => s.name === 'embedMany') - - assertObjectContains(embedManySpan, { - name: 'embedMany', - resource: 'embedMany', - meta: { - 'ai.request.model': 'text-embedding-ada-002', - 'ai.request.model_provider': 'openai', - }, + { + const embedManyTest = semifies(resolvedVersion, '>=7.0.23') ? it : it.skip + + // embedMany is only available from ai 7.0.23. + embedManyTest('creates a span for embedMany', async function () { + const checkTraces = agent.assertSomeTraces(traces => { + const spans = traces[0] + const embedManySpan = spans.find(s => s.name === 'embedMany') + + assertObjectContains(embedManySpan, { + name: 'embedMany', + resource: 'embedMany', + meta: { + 'ai.request.model': 'text-embedding-ada-002', + 'ai.request.model_provider': 'openai', + }, + }) }) - }) - const result = await ai.embedMany({ - model: openai.embedding('text-embedding-ada-002'), - values: ['hello world', 'goodbye world'], - }) + const result = await ai.embedMany({ + model: openai.embedding('text-embedding-ada-002'), + values: ['hello world', 'goodbye world'], + }) - assert.ok(result.embeddings, 'Expected result to be truthy') + assert.ok(result.embeddings, 'Expected result to be truthy') - await checkTraces - }) + await checkTraces + }) + } it('creates spans for streamText', async () => { const checkTraces = agent.assertSomeTraces(traces => { diff --git a/packages/datadog-plugin-aws-sdk/test/eventbridge.dsm.spec.js b/packages/datadog-plugin-aws-sdk/test/eventbridge.dsm.spec.js index d7e078f8ec6..f6b637d807b 100644 --- a/packages/datadog-plugin-aws-sdk/test/eventbridge.dsm.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/eventbridge.dsm.spec.js @@ -232,7 +232,7 @@ describe('EventBridge', function () { assertObjectContains(putEventsSpanMeta, { 'pathway.hash': expectedHash, }) - }) + }, { timeoutMs: 5000 }) } function getEventBridgeClient () { diff --git a/packages/datadog-plugin-bunyan/test/index.spec.js b/packages/datadog-plugin-bunyan/test/index.spec.js index cffc25d4d70..3d016e7c185 100644 --- a/packages/datadog-plugin-bunyan/test/index.spec.js +++ b/packages/datadog-plugin-bunyan/test/index.spec.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict') const { Writable } = require('node:stream') const { inspect } = require('node:util') +const { channel } = require('dc-polyfill') const { afterEach, beforeEach, describe, it } = require('mocha') const sinon = require('sinon') @@ -11,6 +12,8 @@ const agent = require('../../dd-trace/test/plugins/agent') const { withVersions } = require('../../dd-trace/test/setup/mocha') const { assertObjectContains } = require('../../../integration-tests/helpers') +const logSubmissionCh = channel('ci:log-submission:log') + describe('Plugin', () => { let logger let tracer @@ -62,6 +65,29 @@ describe('Plugin', () => { }) }) + describe('with disabled plugin', () => { + beforeEach(() => { + return agent.load('bunyan', { enabled: false }) + }) + + beforeEach(() => { + setupTest(version) + }) + + it('should not publish uncorrelated records for automatic submission', () => { + const onLog = sinon.spy() + logSubmissionCh.subscribe(onLog) + + try { + logger.info('message') + } finally { + logSubmissionCh.unsubscribe(onLog) + } + + sinon.assert.notCalled(onLog) + }) + }) + describe('with configuration', () => { beforeEach(() => { return agent.load('bunyan', { logInjection: true }) @@ -86,6 +112,66 @@ describe('Plugin', () => { }) }) + it('should publish correlated records for automatic submission', () => { + let submission + const onLog = payload => { + submission = payload + } + logSubmissionCh.subscribe(onLog) + + try { + tracer.scope().activate(span, () => { + logger.info('message') + }) + } finally { + logSubmissionCh.unsubscribe(onLog) + } + + const record = JSON.parse(submission.message) + assert.strictEqual(submission.source, 'bunyan') + assert.strictEqual(record.dd.trace_id, span.context().toTraceId(true)) + assert.strictEqual(record.dd.span_id, span.context().toSpanId()) + }) + + it('should publish correlated raw records for automatic submission', () => { + const rawStream = new Writable({ objectMode: true }) + rawStream._write = () => {} + const rawLogger = require(`../../../versions/bunyan@${version}`).get().createLogger({ + name: 'test', + streams: [{ type: 'raw', stream: rawStream }], + }) + let submission + const onLog = payload => { + submission = payload + } + logSubmissionCh.subscribe(onLog) + + try { + tracer.scope().activate(span, () => { + rawLogger.info('message') + }) + } finally { + logSubmissionCh.unsubscribe(onLog) + } + + assert.strictEqual(submission.source, 'bunyan') + assert.strictEqual(submission.message.dd.trace_id, span.context().toTraceId(true)) + assert.strictEqual(submission.message.dd.span_id, span.context().toSpanId()) + }) + + it('should not publish serialization-only emissions for automatic submission', () => { + const onLog = sinon.spy() + logSubmissionCh.subscribe(onLog) + + try { + logger._emit({ level: 30, msg: 'message' }, true) + } finally { + logSubmissionCh.unsubscribe(onLog) + } + + sinon.assert.notCalled(onLog) + }) + it('should not mutate the original record', () => { tracer.scope().activate(span, () => { const record = { foo: 'bar' } diff --git a/packages/datadog-plugin-bunyan/test/unit.spec.js b/packages/datadog-plugin-bunyan/test/unit.spec.js index 07cfbc8910f..97b9bd9ae4b 100644 --- a/packages/datadog-plugin-bunyan/test/unit.spec.js +++ b/packages/datadog-plugin-bunyan/test/unit.spec.js @@ -2,8 +2,8 @@ const assert = require('node:assert/strict') -const { describe, it } = require('mocha') const { channel } = require('dc-polyfill') +const { after, before, describe, it } = require('mocha') const { storage } = require('../../datadog-core') require('../../dd-trace/test/setup/core') @@ -24,12 +24,19 @@ const tracer = new Tracer(getConfig({ const plugin = new BunyanPlugin({ _tracer: tracer, }) -plugin.configure({ - logInjection: true, - enabled: true, -}) describe('BunyanPlugin', () => { + before(() => { + plugin.configure({ + logInjection: true, + enabled: true, + }) + }) + + after(() => { + plugin.configure(false) + }) + it('injects dd onto the record bunyan passes through _emit', () => { const record = { foo: 'bar', msg: 'hello' } logCh.publish({ message: record }) diff --git a/packages/datadog-plugin-cypress/src/support.js b/packages/datadog-plugin-cypress/src/support.js index 294dc61d3ee..de28ce03018 100644 --- a/packages/datadog-plugin-cypress/src/support.js +++ b/packages/datadog-plugin-cypress/src/support.js @@ -409,6 +409,7 @@ beforeEach(function () { rumCookiePromise = setRumCorrelationCookie(traceId) } if (shouldSkip) { + // Test Optimization requested this runtime skip through the Cypress support hook. this.skip() } if (rumCookiePromise) { diff --git a/packages/datadog-plugin-graphql/test/esm-test/esm.spec.js b/packages/datadog-plugin-graphql/test/esm-test/esm.spec.js index 3d70f04ae13..b43fc523966 100644 --- a/packages/datadog-plugin-graphql/test/esm-test/esm.spec.js +++ b/packages/datadog-plugin-graphql/test/esm-test/esm.spec.js @@ -70,8 +70,9 @@ describe('Plugin (ESM)', () => { // Extract version number from range strings like ">=0.10" or "^15.2.0" const cleanVersion = resolvedVersion.replace(/^[>=^~]+/, '') const coercedVersion = semver.coerce(cleanVersion) - if (coercedVersion && semver.gte(coercedVersion, '15.0.0')) { - it('should instrument GraphQL Yoga execution with ESM', async () => { + { + const yogaTest = coercedVersion && semver.gte(coercedVersion, '15.0.0') ? it : it.skip + yogaTest('should instrument GraphQL Yoga execution with ESM', async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) @@ -103,7 +104,7 @@ describe('Plugin (ESM)', () => { await res }).timeout(50000) - it('should instrument GraphQL Yoga subscriptions with ESM', async () => { + yogaTest('should instrument GraphQL Yoga subscriptions with ESM', async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) diff --git a/packages/datadog-plugin-http/src/client.js b/packages/datadog-plugin-http/src/client.js index 931aac9e6c6..5fc3010712f 100644 --- a/packages/datadog-plugin-http/src/client.js +++ b/packages/datadog-plugin-http/src/client.js @@ -8,8 +8,8 @@ const tags = require('../../../ext/tags') const formats = require('../../../ext/formats') const HTTP_HEADERS = formats.HTTP_HEADERS const urlFilter = require('../../dd-trace/src/plugins/util/urlfilter') +const { getClientStatusValidator } = require('../../dd-trace/src/plugins/util/status-validator') const { buildClientHttpUrl } = require('../../dd-trace/src/plugins/util/url') -const log = require('../../dd-trace/src/log') const { stripQueryAndFragment } = require('../../dd-trace/src/util') const { CLIENT_PORT_KEY, COMPONENT, ERROR_MESSAGE, ERROR_TYPE, ERROR_STACK } = require('../../dd-trace/src/constants') @@ -167,7 +167,7 @@ function addRequestHeaders (req, span, config) { } function normalizeClientConfig (config) { - const validateStatus = getStatusValidator(config) + const validateStatus = getClientStatusValidator(config) const filter = getFilter(config) const propagationFilter = getFilter({ blocklist: config.propagationBlocklist }) const headers = getHeaders(config) @@ -183,19 +183,6 @@ function normalizeClientConfig (config) { } } -function is400ErrorCode (code) { - return code < 400 || code >= 500 -} - -function getStatusValidator (config) { - if (typeof config.validateStatus === 'function') { - return config.validateStatus - } else if (config.hasOwnProperty('validateStatus')) { - log.error('Expected `validateStatus` to be a function.') - } - return is400ErrorCode -} - function getFilter (config) { config = { ...config, blocklist: config.blocklist || [] } diff --git a/packages/datadog-plugin-http/test/client.spec.js b/packages/datadog-plugin-http/test/client.spec.js index 6725190b317..5d012f3bf7e 100644 --- a/packages/datadog-plugin-http/test/client.spec.js +++ b/packages/datadog-plugin-http/test/client.spec.js @@ -387,8 +387,12 @@ describe('Plugin', () => { }) // Merging no longer happens since Node 20 - if (NODE_MAJOR < 20) { - it('should support a string URL and an options object, which merges and takes precedence', done => { + { + const mergedUrlOptionsTest = NODE_MAJOR < 20 ? it : it.skip + const mergedUrlOptionsTitle = 'should support a string URL and an options object, ' + + 'which merges and takes precedence' + + mergedUrlOptionsTest(mergedUrlOptionsTitle, done => { const app = express() app.get('/user', (req, res) => { @@ -803,8 +807,10 @@ describe('Plugin', () => { }) }) - if (satisfies(process.version, '>=20')) { - it('should not record default HTTP agent timeout as error with Node 20', done => { + { + const node20Test = satisfies(process.version, '>=20') ? it : it.skip + + node20Test('should not record default HTTP agent timeout as error with Node 20', done => { const app = express() app.get('/user', async (req, res) => { @@ -832,7 +838,7 @@ describe('Plugin', () => { }) }).timeout(10000) - it('should record error if custom Agent timeout is used with Node 20', done => { + node20Test('should record error if custom Agent timeout is used with Node 20', done => { const app = express() app.get('/user', async (req, res) => { @@ -864,7 +870,7 @@ describe('Plugin', () => { }) }).timeout(10000) - it('should record error if req.setTimeout is used with Node 20', done => { + node20Test('should record error if req.setTimeout is used with Node 20', done => { const app = express() app.get('/user', async (req, res) => { @@ -1023,8 +1029,10 @@ describe('Plugin', () => { }) }) - if (protocol === 'http') { - it('should skip requests marked as noop', done => { + { + const noopRequestTest = protocol === 'http' ? it : it.skip + + noopRequestTest('should skip requests marked as noop', done => { const app = express() app.get('/user', (req, res) => { @@ -1196,6 +1204,70 @@ describe('Plugin', () => { }) }) + describe('with configured HTTP client error statuses', () => { + beforeEach(() => { + process.env.DD_TRACE_HTTP_CLIENT_ERROR_STATUSES = '200-201,202' + + return agent.load('http', { server: false }) + .then(() => { + http = require(pluginToBeLoaded) + express = require('express') + }) + }) + + afterEach(() => { + delete process.env.DD_TRACE_HTTP_CLIENT_ERROR_STATUSES + }) + + it('should mark a configured status code as an error', done => { + const app = express() + + app.get('/user', (req, res) => { + res.status(200).send() + }) + + agent + .assertSomeTraces(traces => { + assert.strictEqual(traces[0][0].meta['http.status_code'], '200') + assert.strictEqual(traces[0][0].error, 1) + }) + .then(done) + .catch(done) + + appListener = server(app, port => { + const req = http.request(`${protocol}://localhost:${port}/user`, res => { + res.on('data', () => {}) + }) + + req.end() + }) + }) + + it('should not mark a status code outside of the configured statuses as an error', done => { + const app = express() + + app.get('/user', (req, res) => { + res.status(500).send() + }) + + agent + .assertSomeTraces(traces => { + assert.strictEqual(traces[0][0].meta['http.status_code'], '500') + assert.strictEqual(traces[0][0].error, 0) + }) + .then(done) + .catch(done) + + appListener = server(app, port => { + const req = http.request(`${protocol}://localhost:${port}/user`, res => { + res.on('data', () => {}) + }) + + req.end() + }) + }) + }) + describe('with splitByDomain configuration', () => { let config let serverPort diff --git a/packages/datadog-plugin-http2/src/client.js b/packages/datadog-plugin-http2/src/client.js index b0091d35b9d..18514bbabd9 100644 --- a/packages/datadog-plugin-http2/src/client.js +++ b/packages/datadog-plugin-http2/src/client.js @@ -4,12 +4,12 @@ const URL = require('url').URL const { storage } = require('../../datadog-core') const ClientPlugin = require('../../dd-trace/src/plugins/client') -const log = require('../../dd-trace/src/log') const tags = require('../../../ext/tags') const kinds = require('../../../ext/kinds') const formats = require('../../../ext/formats') const { COMPONENT, CLIENT_PORT_KEY } = require('../../dd-trace/src/constants') const urlFilter = require('../../dd-trace/src/plugins/util/urlfilter') +const { getClientStatusValidator } = require('../../dd-trace/src/plugins/util/status-validator') const { buildClientHttpUrl } = require('../../dd-trace/src/plugins/util/url') const HTTP_HEADERS = formats.HTTP_HEADERS @@ -165,21 +165,8 @@ function hasAmazonSignature (headers, path) { return false } -function is400ErrorCode (code) { - return code < 400 || code >= 500 -} - -function getStatusValidator (config) { - if (typeof config.validateStatus === 'function') { - return config.validateStatus - } else if (config.hasOwnProperty('validateStatus')) { - log.error('Expected `validateStatus` to be a function.') - } - return is400ErrorCode -} - function normalizeConfig (config) { - const validateStatus = getStatusValidator(config) + const validateStatus = getClientStatusValidator(config) const filter = getFilter(config) const headers = getHeaders(config) diff --git a/packages/datadog-plugin-http2/test/client.spec.js b/packages/datadog-plugin-http2/test/client.spec.js index 4a11e84837c..e617869077d 100644 --- a/packages/datadog-plugin-http2/test/client.spec.js +++ b/packages/datadog-plugin-http2/test/client.spec.js @@ -832,6 +832,77 @@ describe('Plugin', () => { }) }) + describe('with configured HTTP client error statuses', () => { + beforeEach(() => { + process.env.DD_TRACE_HTTP_CLIENT_ERROR_STATUSES = '200-201,202' + + return agent.load('http2', { server: false }) + .then(() => { + http2 = require(loadPlugin) + }) + }) + + afterEach(() => { + delete process.env.DD_TRACE_HTTP_CLIENT_ERROR_STATUSES + }) + + it('should mark a configured status code as an error', done => { + const app = (stream, headers) => { + stream.respond({ + ':status': 200, + }) + stream.end() + } + + appListener = server(app, port => { + agent + .assertSomeTraces(traces => { + assert.strictEqual(traces[0][0].meta['http.status_code'], '200') + assert.strictEqual(traces[0][0].error, 1) + }) + .then(done) + .catch(done) + + const client = http2 + .connect(`${protocol}://localhost:${port}`) + .on('error', done) + + const req = client.request({ ':path': '/user' }) + req.on('error', done) + + req.end() + }) + }) + + it('should not mark a status code outside of the configured statuses as an error', done => { + const app = (stream, headers) => { + stream.respond({ + ':status': 500, + }) + stream.end() + } + + appListener = server(app, port => { + agent + .assertSomeTraces(traces => { + assert.strictEqual(traces[0][0].meta['http.status_code'], '500') + assert.strictEqual(traces[0][0].error, 0) + }) + .then(done) + .catch(done) + + const client = http2 + .connect(`${protocol}://localhost:${port}`) + .on('error', done) + + const req = client.request({ ':path': '/user' }) + req.on('error', done) + + req.end() + }) + }) + }) + describe('with splitByDomain configuration', () => { let config let serverPort diff --git a/packages/datadog-plugin-http2/test/server.spec.js b/packages/datadog-plugin-http2/test/server.spec.js index ee4c52be7da..3358c024735 100644 --- a/packages/datadog-plugin-http2/test/server.spec.js +++ b/packages/datadog-plugin-http2/test/server.spec.js @@ -9,6 +9,7 @@ const { setImmediate } = require('node:timers/promises') const { afterEach, beforeEach, describe, it } = require('mocha') const sinon = require('sinon') +const { channel } = require('dc-polyfill') const agent = require('../../dd-trace/test/plugins/agent') const web = require('../../dd-trace/src/plugins/util/web') @@ -309,6 +310,19 @@ describe('Plugin', () => { rawExpectedSchema.server ) + it('publishes a close response event', async () => { + const emit = sinon.spy() + const emitChannel = channel('apm:http2:server:response:emit') + emitChannel.subscribe(emit) + + try { + await request(http2, `http://localhost:${port}/user`) + sinon.assert.calledWithMatch(emit, { eventName: 'close' }) + } finally { + emitChannel.unsubscribe(emit) + } + }) + it('should do automatic instrumentation', done => { agent .assertFirstTraceSpan({ diff --git a/packages/datadog-plugin-mariadb/test/bundle.spec.js b/packages/datadog-plugin-mariadb/test/bundle.spec.js new file mode 100644 index 00000000000..73c3e9a48e5 --- /dev/null +++ b/packages/datadog-plugin-mariadb/test/bundle.spec.js @@ -0,0 +1,1197 @@ +'use strict' + +const assert = require('node:assert/strict') +const { mkdtemp, rm, writeFile } = require('node:fs/promises') +const net = require('node:net') +const { tmpdir } = require('node:os') +const path = require('node:path') +const { performance } = require('node:perf_hooks') +const { setImmediate: nextImmediate } = require('node:timers/promises') +const { inspect } = require('node:util') + +const dc = require('dc-polyfill') +const { after, afterEach, before, beforeEach, describe, it } = require('mocha') +const semver = require('semver') +const sinon = require('sinon') + +const { ANY_STRING } = require('../../../integration-tests/helpers') +const { CLIENT_PORT_KEY, ERROR_MESSAGE, ERROR_STACK, ERROR_TYPE } = require('../../dd-trace/src/constants') +const agent = require('../../dd-trace/test/plugins/agent') +const { withVersions } = require('../../dd-trace/test/setup/mocha') + +const queryStartCh = dc.channel('apm:mariadb:query:start') + +const connectionOptions = { + host: 'localhost', + user: 'root', + database: 'db', +} +const noop = () => {} + +/** + * Resolves when a MariaDB callback reports success. + * + * @param {(callback: Function) => void} invoke + * @returns {Promise>} + */ +function callbackResult (invoke) { + return new Promise((resolve, reject) => { + invoke((error, ...results) => error ? reject(error) : resolve(results)) + }) +} + +/** + * Resolves after a MariaDB result stream ends. + * + * @param {import('node:stream').Readable} stream + * @returns {Promise} + */ +function consumeStream (stream) { + return new Promise((resolve, reject) => { + stream.once('error', reject) + stream.once('end', resolve) + stream.resume() + }) +} + +/** + * @returns {Promise} + */ +async function getClosedPort () { + const probe = net.createServer() + await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)) + const port = probe.address().port + await new Promise(resolve => probe.close(resolve)) + return port +} + +/** + * @param {number} start + * @param {(advanceTo: (value: number) => void) => Promise} run + * @returns {Promise} + */ +async function withFakeNow (start, run) { + const nowStub = sinon.stub(performance, 'now').returns(start) + + try { + await run(value => nowStub.returns(value)) + } finally { + nowStub.restore() + } +} + +/** + * Asserts every expected MariaDB resource in the trace containing the named root span. + * + * @param {string} rootName + * @param {Array} expectedResources + * @returns {Promise} + */ +function assertTraceResources (rootName, expectedResources) { + return agent.assertSomeTraces(traces => { + const trace = traces.find(trace => trace.some(span => span.name === rootName)) + assert.ok(trace, `${rootName} trace has not flushed yet`) + + const resources = [] + for (const span of trace) { + if (span.meta.component === 'mariadb') resources.push(span.resource) + } + + assert.deepStrictEqual(resources.sort(), expectedResources.sort()) + }) +} + +describe('Plugin', () => { + describe('mariadb CommonJS bundle', () => { + if (semver.lt(process.version, '20.0.0')) return + + withVersions('mariadb', 'mariadb', '3.5.3', version => { + const versionModule = `../../../versions/mariadb@${version}` + let importFilePath + let temporaryDirectory + + before(async () => { + temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'dd-trace-mariadb-')) + importFilePath = path.join(temporaryDirectory, 'query.sql') + await writeFile(importFilePath, 'SELECT 11 AS imported_statement;') + }) + + after(async () => { + await rm(temporaryDirectory, { recursive: true }) + }) + + describe('exports', () => { + beforeEach(() => agent.load('mariadb')) + afterEach(() => agent.close()) + + for (const entry of ['mariadb', 'mariadb/callback']) { + it(`preserves the ${entry} namespace descriptors`, () => { + const mariadb = require(versionModule).get(entry) + const esModuleDescriptor = Object.getOwnPropertyDescriptor(mariadb, '__esModule') + const factoryDescriptor = Object.getOwnPropertyDescriptor(mariadb, 'createConnection') + + assert.deepStrictEqual(esModuleDescriptor, { + value: true, + writable: false, + enumerable: false, + configurable: false, + }) + assert.strictEqual(typeof factoryDescriptor.get, 'function') + assert.strictEqual(factoryDescriptor.set, undefined) + assert.strictEqual(factoryDescriptor.enumerable, true) + assert.strictEqual(factoryDescriptor.configurable, false) + assert.strictEqual(mariadb.default.createConnection, mariadb.createConnection) + assert.strictEqual(mariadb.default.createPool, mariadb.createPool) + assert.strictEqual(mariadb.default.createPoolCluster, mariadb.createPoolCluster) + assert.strictEqual(mariadb.default.importFile, mariadb.importFile) + }) + } + }) + + describe('promise API', () => { + let connection + let mariadb + let tracer + + beforeEach(async () => { + tracer = await agent.load('mariadb') + mariadb = require(versionModule).get('mariadb') + connection = await mariadb.createConnection(connectionOptions) + }) + + afterEach(async () => { + await connection.end() + await agent.close() + }) + + it('traces query, execute, prepared, and streaming commands', async () => { + const statement = await connection.prepare('SELECT ? AS prepared_query') + const assertion = assertTraceResources('bundle.promise.commands', [ + 'SELECT 1 AS object_query', + 'SELECT ? AS execute_query', + 'SELECT ? AS prepared_query', + 'SELECT 2 AS query_stream', + 'SELECT ? AS prepared_query', + ]) + + await tracer.trace('bundle.promise.commands', async () => { + await connection.query({ sql: 'SELECT 1 AS object_query' }) + await connection.execute('SELECT ? AS execute_query', [2]) + await statement.execute([3]) + await consumeStream(connection.queryStream('SELECT 2 AS query_stream')) + await consumeStream(statement.executeStream([4])) + }) + + statement.close() + await assertion + }) + + it('tags bundled stream errors', async () => { + const sql = 'SELECT * FROM definitely_missing_stream_table' + + await Promise.all([ + agent.assertFirstTraceSpan({ + resource: sql, + meta: { + [ERROR_TYPE]: ANY_STRING, + [ERROR_MESSAGE]: ANY_STRING, + [ERROR_STACK]: ANY_STRING, + }, + }, { spanResourceMatch: /definitely_missing_stream_table/ }), + assert.rejects(consumeStream(connection.queryStream(sql))), + ]) + }) + + it('traces transaction helpers only when MariaDB sends a command', async () => { + const assertion = assertTraceResources('bundle.promise.transactions', [ + 'START TRANSACTION', + 'SELECT 3 AS committed_query', + 'COMMIT', + 'START TRANSACTION', + 'ROLLBACK', + ]) + + await tracer.trace('bundle.promise.transactions', async () => { + await connection.beginTransaction() + await connection.query('SELECT 3 AS committed_query') + await connection.commit() + await connection.commit() + await connection.beginTransaction() + await connection.rollback() + }) + + await assertion + }) + + it('traces transactions queued behind untraced promise commands', async () => { + const assertion = assertTraceResources('bundle.promise.queued_transactions', ['COMMIT', 'COMMIT']) + + await tracer.trace('bundle.promise.queued_transactions', async () => { + const ping = connection.ping() + const pingCommit = connection.commit() + await Promise.all([ping, pingCommit]) + + const prepare = connection.prepare('SELECT ? AS queued_promise_prepare') + const prepareCommit = connection.commit() + const [statement] = await Promise.all([prepare, prepareCommit]) + statement.close() + }) + + await assertion + }) + + it('traces batch and importFile operations', async () => { + const importFile = mariadb.importFile + const assertion = assertTraceResources('bundle.promise.bulk', [ + 'INSERT INTO dd_bundle_batch VALUES (?)', + 'IMPORT FILE', + 'IMPORT FILE', + ]) + + await connection.query('CREATE TEMPORARY TABLE dd_bundle_batch (value INT)') + await tracer.trace('bundle.promise.bulk', async () => { + await connection.batch('INSERT INTO dd_bundle_batch VALUES (?)', [[1], [2]]) + await connection.importFile({ file: importFilePath }) + await importFile({ ...connectionOptions, file: importFilePath }) + }) + + await assertion + }) + + it('traces pools, acquired connections, and connection-event wrappers', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + const eventQuery = new Promise((resolve, reject) => { + pool.prependOnceListener('connection', eventConnection => { + eventConnection.query('SELECT 4 AS event_query').then(resolve, reject) + }) + }) + const eventAssertion = agent.assertFirstTraceSpan( + { resource: 'SELECT 4 AS event_query' }, + { spanResourceMatch: /event_query/ } + ) + const assertion = assertTraceResources('bundle.promise.pool', [ + 'SELECT 5 AS pool_query', + 'SELECT ? AS pool_execute', + 'INSERT INTO dd_bundle_pool_batch VALUES (?)', + 'IMPORT FILE', + 'mariadb.pool.acquire', + 'SELECT 6 AS acquired_query', + ]) + + try { + await pool.query('CREATE TEMPORARY TABLE dd_bundle_pool_batch (value INT)') + await tracer.trace('bundle.promise.pool', async () => { + await Promise.all([pool.query('SELECT 5 AS pool_query'), eventQuery, eventAssertion]) + await pool.execute('SELECT ? AS pool_execute', [6]) + await pool.batch('INSERT INTO dd_bundle_pool_batch VALUES (?)', [[1], [2]]) + await pool.importFile({ file: importFilePath, database: 'db' }) + const acquired = await pool.getConnection() + await acquired.query('SELECT 6 AS acquired_query') + await acquired.release() + }) + + await assertion + } finally { + await pool.end() + } + }) + + for (const [method, sql] of [ + ['query', 'SELECT 23 AS bundle_pool_wait'], + ['execute', 'SELECT 24 AS bundle_execute_pool_wait'], + ]) { + it(`records the pool acquire wait time on the bundled ${method} span`, async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + + try { + await Promise.all([ + agent.assertSomeTraces(traces => { + const span = traces[0].find(span => span.resource === sql) + + assert.ok(span, `missing query span: ${inspect(traces[0].map(span => span.resource))}`) + assert.strictEqual(typeof span.metrics['mariadb.pool.wait_time'], 'number') + assert.ok(span.metrics['mariadb.pool.wait_time'] >= 0) + assert.strictEqual(traces[0].find(span => span.name === 'mariadb.pool.acquire'), undefined) + }, { spanResourceMatch: new RegExp(`^${sql}$`) }), + pool[method](sql), + ]) + } finally { + await pool.end() + } + }) + } + + it('starts a bundled pooled promise command only after acquiring its connection', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + const connection = await pool.getConnection() + const sql = 'SELECT 38 AS bundle_delayed_pool_start' + let query + let queryStarts = 0 + let released = false + const onStart = ctx => { + if (ctx.sql === sql) queryStarts++ + } + queryStartCh.subscribe(onStart) + + try { + query = pool.query(sql) + assert.strictEqual(queryStarts, 0) + + await connection.release() + released = true + await query + + assert.strictEqual(queryStarts, 1) + } finally { + queryStartCh.unsubscribe(onStart) + if (!released) await connection.release() + await query?.catch(noop) + await pool.end() + } + }) + + it('uses zero wait without clock reads for a recent bundled pool connection', async () => { + const pool = mariadb.createPool({ + ...connectionOptions, + connectionLimit: 1, + minDelayValidation: Number.MAX_SAFE_INTEGER, + }) + + try { + await pool.query('SELECT 1') + + const assertion = agent.assertSomeTraces(traces => { + const span = traces.flat().find(span => span.resource === 'SELECT 25 AS bundle_recent_idle') + + assert.ok(span, `missing query span: ${inspect(traces.flat().map(span => span.resource))}`) + assert.strictEqual(span.metrics['mariadb.pool.wait_time'], 0) + }, { spanResourceMatch: /^SELECT 25 AS bundle_recent_idle$/ }) + const nowStub = sinon.stub(performance, 'now').returns(100) + + try { + const query = pool.query('SELECT 25 AS bundle_recent_idle') + + sinon.assert.notCalled(nowStub) + await Promise.all([assertion, query]) + } finally { + nowStub.restore() + } + } finally { + await pool.end() + } + }) + + it('includes bundled idle validation in the pool wait time', async () => { + const pool = mariadb.createPool({ + ...connectionOptions, + connectionLimit: 1, + minDelayValidation: 0, + }) + + try { + await pool.query('SELECT 1') + + const assertion = agent.assertSomeTraces(traces => { + const span = traces.flat().find(span => span.resource === 'SELECT 26 AS bundle_validation') + + assert.ok(span, `missing query span: ${inspect(traces.flat().map(span => span.resource))}`) + assert.strictEqual(span.metrics['mariadb.pool.wait_time'], 50) + }, { spanResourceMatch: /^SELECT 26 AS bundle_validation$/ }) + + await withFakeNow(100, async advanceTo => { + const query = pool.query('SELECT 26 AS bundle_validation') + advanceTo(150) + await Promise.all([assertion, query]) + }) + } finally { + await pool.end() + } + }) + + it('creates an acquire span for an explicit bundled promise getConnection', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + const parent = tracer.startSpan('bundle-promise-acquire-parent') + + try { + await Promise.all([ + agent.assertSomeTraces(traces => { + const acquireSpan = traces[0].find(span => span.name === 'mariadb.pool.acquire') + + assert.ok(acquireSpan, `missing acquire span: ${inspect(traces[0].map(span => span.name))}`) + assert.strictEqual(acquireSpan.parent_id.toString(), parent.context().toSpanId()) + assert.strictEqual(typeof acquireSpan.metrics['mariadb.pool.wait_time'], 'number') + }, { spanResourceMatch: /^mariadb\.pool\.acquire$/ }), + tracer.scope().activate(parent, async () => { + const acquired = await pool.getConnection() + await acquired.release() + parent.finish() + }), + ]) + } finally { + await pool.end() + } + }) + + it('does not classify reentrant bundled pool operations as each other', async () => { + const pool = mariadb.createPool({ + ...connectionOptions, + connectionLimit: 1, + minDelayValidation: Number.MAX_SAFE_INTEGER, + }) + + try { + await pool.query('CREATE TEMPORARY TABLE dd_bundle_reentrant (value INT)') + let batch + pool.once('acquire', () => { + batch = pool.batch('INSERT INTO dd_bundle_reentrant VALUES (?)', [[1]]) + }) + const nowStub = sinon.stub(performance, 'now').returns(100) + + try { + await pool.query('SELECT 27 AS bundle_reentrant') + assert.ok(batch, 'reentrant batch did not start') + await batch + sinon.assert.notCalled(nowStub) + } finally { + nowStub.restore() + } + } finally { + await pool.end() + } + }) + + it('preserves bundled pool acquisition order across queue compaction', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + const connection = await pool.getConnection() + const queries = new Array(1025) + let released = false + + try { + for (let index = 0; index < queries.length; index++) { + queries[index] = pool.query('SELECT 1 AS compaction_probe') + } + + await connection.release() + released = true + const results = await Promise.all(queries) + + assert.strictEqual(results.length, 1025) + assert.strictEqual(results[0][0].compaction_probe, 1) + assert.strictEqual(results[1024][0].compaction_probe, 1) + } finally { + if (!released) await connection.release() + await pool.end() + } + }) + + it('forwards bundled pool operations without subscribers', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + + tracer.use('mariadb', false) + try { + const rows = await pool.query('SELECT 28 AS bundle_untraced_pool') + const acquired = await pool.getConnection() + + await acquired.release() + assert.strictEqual(rows[0].bundle_untraced_pool, 28) + } finally { + tracer.use('mariadb', true) + await pool.end() + } + }) + + it('records errors for explicit and pooled bundled acquisition failures', async () => { + const pool = mariadb.createPool({ + ...connectionOptions, + acquireTimeout: 500, + connectTimeout: 100, + host: '127.0.0.1', + port: await getClosedPort(), + }) + pool.on('error', noop) + const forbiddenResources = new Set([ + 'SELECT 29 AS bundle_query_acquire_failure', + 'SELECT 30 AS bundle_execute_acquire_failure', + ]) + const noQuerySpans = agent.assertNoTraces(traces => { + const span = traces.flat().find(span => forbiddenResources.has(span.resource)) + assert.strictEqual(span, undefined, `unexpected query span for failed acquisition: ${span?.resource}`) + }) + + try { + for (const [method, args] of [ + ['getConnection', []], + ['query', ['SELECT 29 AS bundle_query_acquire_failure']], + ['execute', ['SELECT 30 AS bundle_execute_acquire_failure']], + ]) { + await Promise.all([ + agent.assertSomeTraces(traces => { + const acquireSpan = traces[0].find(span => span.name === 'mariadb.pool.acquire') + + assert.ok(acquireSpan, `missing acquire span: ${inspect(traces[0].map(span => span.name))}`) + assert.strictEqual(acquireSpan.error, 1) + assert.strictEqual(typeof acquireSpan.metrics['mariadb.pool.wait_time'], 'number') + }), + assert.rejects(pool[method](...args)), + ]) + } + await noQuerySpans + } finally { + noQuerySpans.cancel() + await pool.end() + } + }) + + it('keeps bundled pool acquisition tracking after acquire listeners are removed', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + const rootName = 'bundle.promise.removed_acquire_listeners' + const sql = 'SELECT * FROM dd_missing_bundle_listener_probe' + + try { + await pool.query('SELECT 1') + pool.on('removeListener', () => {}) + pool.removeAllListeners('acquire') + + const assertion = agent.assertSomeTraces(traces => { + const trace = traces.find(trace => trace.some(span => span.name === rootName)) + assert.ok(trace, `${rootName} trace has not flushed yet`) + + const querySpan = trace.find(span => span.resource === sql) + assert.ok(querySpan, `missing query span: ${inspect(trace.map(span => span.resource))}`) + assert.strictEqual(typeof querySpan.metrics['mariadb.pool.wait_time'], 'number') + assert.strictEqual(trace.find(span => span.name === 'mariadb.pool.acquire'), undefined) + }, { spanResourceMatch: new RegExp(`^${rootName}$`) }) + + await assert.rejects(tracer.trace(rootName, () => pool.query(sql))) + await assertion + } finally { + await pool.end() + } + }) + + it('reports failed bundled promise cluster node acquisitions', async () => { + const cluster = mariadb.createPoolCluster({ canRetry: false }) + const rootName = 'bundle.promise.cluster_acquire_failure' + cluster.add('failing', { + ...connectionOptions, + acquireTimeout: 500, + connectTimeout: 100, + host: '127.0.0.1', + minimumIdle: 0, + port: await getClosedPort(), + }) + + try { + const assertion = agent.assertSomeTraces(traces => { + const trace = traces.find(trace => trace.some(span => span.name === rootName)) + assert.ok(trace, `${rootName} trace has not flushed yet`) + + const acquireSpan = trace.find(span => span.name === 'mariadb.pool.acquire') + assert.ok(acquireSpan, `missing acquire span: ${inspect(trace.map(span => span.name))}`) + assert.strictEqual(acquireSpan.error, 1) + assert.strictEqual(typeof acquireSpan.metrics['mariadb.pool.wait_time'], 'number') + }, { spanResourceMatch: new RegExp(`^${rootName}$`) }) + + await assert.rejects(tracer.trace(rootName, () => cluster.of('failing').query('SELECT 36'))) + await assertion + } finally { + await cluster.end() + } + }) + + it('traces pool clusters, falsy selectors, and selected node metadata', async () => { + const cluster = mariadb.createPoolCluster() + cluster.add('primary', { ...connectionOptions, minimumIdle: 0 }) + cluster.add('secondary', { ...connectionOptions, host: '127.0.0.1', minimumIdle: 0 }) + const filteredCluster = cluster.of(/^(primary|secondary)$/, 'RR') + const filteredAssertion = agent.assertFirstTraceSpan({ + resource: 'SELECT 7 AS cluster_query', + meta: { + 'db.name': 'db', + 'db.user': 'root', + 'out.host': '127.0.0.1', + }, + metrics: { [CLIENT_PORT_KEY]: 3306 }, + }, { spanResourceMatch: /cluster_query/ }) + const falsyAssertion = agent.assertFirstTraceSpan({ + resource: 'SELECT 8 AS falsy_cluster_query', + meta: { + 'db.name': 'db', + 'db.user': 'root', + 'out.host': 'localhost', + }, + metrics: { [CLIENT_PORT_KEY]: 3306 }, + }, { spanResourceMatch: /falsy_cluster_query/ }) + + try { + const firstConnection = await filteredCluster.getConnection() + await firstConnection.release() + await Promise.all([ + filteredAssertion, + filteredCluster.query('SELECT 7 AS cluster_query'), + ]) + const acquired = await cluster.getConnection(false) + await Promise.all([ + falsyAssertion, + acquired.query('SELECT 8 AS falsy_cluster_query'), + ]) + await acquired.release() + } finally { + await cluster.end() + } + }) + + it('retains metadata when an automatically removed node is immediately re-added', async () => { + const cluster = mariadb.createPoolCluster({ canRetry: false, removeNodeErrorCount: 1 }) + cluster.add('primary', { + ...connectionOptions, + host: '127.0.0.1', + port: 1, + acquireTimeout: 100, + connectTimeout: 50, + minimumIdle: 0, + }) + + try { + await assert.rejects(cluster.getConnection('primary')) + cluster.add('primary', { ...connectionOptions, minimumIdle: 0 }) + await nextImmediate() + + const assertion = agent.assertFirstTraceSpan({ + resource: 'SELECT 21 AS readded_cluster_query', + meta: { + 'db.name': 'db', + 'db.user': 'root', + 'out.host': 'localhost', + }, + metrics: { [CLIENT_PORT_KEY]: 3306 }, + }, { spanResourceMatch: /readded_cluster_query/ }) + const acquired = await cluster.getConnection('primary') + + try { + await Promise.all([ + assertion, + acquired.query('SELECT 21 AS readded_cluster_query'), + ]) + } finally { + await acquired.release() + } + } finally { + await cluster.end() + } + }) + + it('preserves promise continuation context and tags errors', async () => { + const parent = tracer.startSpan('bundle.promise.parent') + await tracer.scope().activate(parent, () => { + return connection.query('SELECT 8 AS context_query').then(() => { + assert.strictEqual(tracer.scope().active(), parent) + }) + }) + parent.finish() + + await Promise.all([ + agent.assertFirstTraceSpan({ + resource: 'SELECT * FROM definitely_missing_bundle_table', + meta: { + [ERROR_TYPE]: ANY_STRING, + [ERROR_MESSAGE]: ANY_STRING, + [ERROR_STACK]: ANY_STRING, + }, + }, { spanResourceMatch: /definitely_missing_bundle_table/ }), + connection.query('SELECT * FROM definitely_missing_bundle_table').catch(() => {}), + ]) + }) + }) + + describe('callback API', () => { + let connection + let mariadb + let tracer + + beforeEach(async () => { + tracer = await agent.load('mariadb') + mariadb = require(versionModule).get('mariadb/callback') + connection = mariadb.createConnection(connectionOptions) + await callbackResult(callback => connection.connect(callback)) + }) + + afterEach(async () => { + await callbackResult(callback => connection.end(callback)) + await agent.close() + }) + + it('traces query, execute, prepared, and streaming commands', async () => { + const [statement] = await callbackResult(callback => { + connection.prepare('SELECT ? AS callback_prepared', callback) + }) + const assertion = assertTraceResources('bundle.callback.commands', [ + 'SELECT 9 AS callback_query', + 'SELECT ? AS callback_execute', + 'SELECT ? AS callback_prepared', + 'SELECT ? AS callback_prepared', + 'SELECT 10 AS callback_stream', + 'SELECT ? AS callback_prepared', + ]) + + await tracer.trace('bundle.callback.commands', async () => { + await callbackResult(callback => connection.query('SELECT 9 AS callback_query', callback)) + await callbackResult(callback => connection.execute('SELECT ? AS callback_execute', [10], callback)) + await callbackResult(callback => statement.execute([11], callback)) + await statement.execute([12]) + await consumeStream(connection.queryStream('SELECT 10 AS callback_stream')) + await consumeStream(statement.executeStream([13])) + }) + + statement.close() + await assertion + }) + + it('traces transaction helpers only when MariaDB sends a command', async () => { + const assertion = assertTraceResources('bundle.callback.transactions', [ + 'START TRANSACTION', + 'SELECT 12 AS callback_committed_query', + 'COMMIT', + 'START TRANSACTION', + 'ROLLBACK', + ]) + + await tracer.trace('bundle.callback.transactions', async () => { + await callbackResult(callback => connection.beginTransaction(callback)) + await callbackResult(callback => connection.query('SELECT 12 AS callback_committed_query', callback)) + await callbackResult(callback => connection.commit(callback)) + await callbackResult(callback => connection.commit(callback)) + await callbackResult(callback => connection.beginTransaction(callback)) + await callbackResult(callback => connection.rollback(callback)) + }) + + await assertion + }) + + it('traces transactions queued behind untraced callback commands', async () => { + const assertion = assertTraceResources('bundle.callback.queued_transactions', ['COMMIT', 'COMMIT']) + + await tracer.trace('bundle.callback.queued_transactions', async () => { + const ping = callbackResult(callback => connection.ping(callback)) + const pingCommit = callbackResult(callback => connection.commit(callback)) + await Promise.all([ping, pingCommit]) + + const prepare = callbackResult(callback => { + connection.prepare('SELECT ? AS queued_callback_prepare', callback) + }) + const prepareCommit = callbackResult(callback => connection.commit(callback)) + const [[statement]] = await Promise.all([prepare, prepareCommit]) + statement.close() + }) + + await assertion + }) + + it('traces batch and importFile operations', async () => { + const importFile = mariadb.importFile + const assertion = assertTraceResources('bundle.callback.bulk', [ + 'INSERT INTO dd_bundle_callback_batch VALUES (?)', + 'IMPORT FILE', + 'IMPORT FILE', + ]) + + await callbackResult(callback => { + connection.query('CREATE TEMPORARY TABLE dd_bundle_callback_batch (value INT)', callback) + }) + await tracer.trace('bundle.callback.bulk', async () => { + await callbackResult(callback => { + connection.batch('INSERT INTO dd_bundle_callback_batch VALUES (?)', [[1], [2]], callback) + }) + await callbackResult(callback => connection.importFile({ file: importFilePath }, callback)) + await callbackResult(callback => { + importFile({ ...connectionOptions, file: importFilePath }, callback) + }) + }) + + await assertion + }) + + it('replaces explicit empty callback slots', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + const assertion = assertTraceResources('bundle.callback.empty_callbacks', [ + 'SELECT 18 AS callback_empty_query', + 'IMPORT FILE', + 'START TRANSACTION', + 'SELECT 19 AS callback_empty_pool_query', + 'SELECT 20 AS callback_empty_pool_barrier', + ]) + + try { + await tracer.trace('bundle.callback.empty_callbacks', async () => { + connection.query('SELECT 18 AS callback_empty_query', [], undefined) + connection.importFile({ file: importFilePath }, null) + connection.beginTransaction(undefined) + await callbackResult(callback => connection.ping(callback)) + + pool.query('SELECT 19 AS callback_empty_pool_query', [], undefined) + await callbackResult(callback => pool.query('SELECT 20 AS callback_empty_pool_barrier', callback)) + }) + + await assertion + } finally { + await callbackResult(callback => pool.end(callback)) + } + }) + + it('traces pools, acquired connections, and connection-event wrappers', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + const eventQuery = new Promise((resolve, reject) => { + pool.prependOnceListener('connection', eventConnection => { + eventConnection.query('SELECT 13 AS callback_event_query', error => error ? reject(error) : resolve()) + }) + }) + const eventAssertion = agent.assertFirstTraceSpan( + { resource: 'SELECT 13 AS callback_event_query' }, + { spanResourceMatch: /callback_event_query/ } + ) + const assertion = assertTraceResources('bundle.callback.pool', [ + 'SELECT 14 AS callback_pool_query', + 'SELECT ? AS callback_pool_execute', + 'INSERT INTO dd_bundle_callback_pool_batch VALUES (?)', + 'IMPORT FILE', + 'mariadb.pool.acquire', + 'SELECT 15 AS callback_acquired_query', + ]) + + try { + await callbackResult(callback => { + pool.query('CREATE TEMPORARY TABLE dd_bundle_callback_pool_batch (value INT)', callback) + }) + await tracer.trace('bundle.callback.pool', async () => { + await Promise.all([ + callbackResult(callback => pool.query('SELECT 14 AS callback_pool_query', callback)), + eventQuery, + eventAssertion, + ]) + await callbackResult(callback => pool.execute('SELECT ? AS callback_pool_execute', [15], callback)) + await callbackResult(callback => { + pool.batch('INSERT INTO dd_bundle_callback_pool_batch VALUES (?)', [[1], [2]], callback) + }) + await callbackResult(callback => pool.importFile({ file: importFilePath, database: 'db' }, callback)) + const [acquired] = await callbackResult(callback => pool.getConnection(callback)) + await callbackResult(callback => acquired.query('SELECT 15 AS callback_acquired_query', callback)) + await callbackResult(callback => acquired.release(callback)) + }) + + await assertion + } finally { + await callbackResult(callback => pool.end(callback)) + } + }) + + it('records the pool acquire wait time on a bundled callback query span', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + const sql = 'SELECT 31 AS bundle_callback_pool_wait' + + try { + await Promise.all([ + agent.assertSomeTraces(traces => { + const span = traces[0].find(span => span.resource === sql) + + assert.ok(span, `missing query span: ${inspect(traces[0].map(span => span.resource))}`) + assert.strictEqual(typeof span.metrics['mariadb.pool.wait_time'], 'number') + assert.ok(span.metrics['mariadb.pool.wait_time'] >= 0) + assert.strictEqual(traces[0].find(span => span.name === 'mariadb.pool.acquire'), undefined) + }, { spanResourceMatch: new RegExp(`^${sql}$`) }), + callbackResult(callback => pool.query(sql, callback)), + ]) + } finally { + await callbackResult(callback => pool.end(callback)) + } + }) + + it('starts a bundled pooled callback command only after acquiring its connection', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + const [connection] = await callbackResult(callback => pool.getConnection(callback)) + const sql = 'SELECT 39 AS bundle_delayed_callback_pool_start' + let query + let queryStarts = 0 + let released = false + const onStart = ctx => { + if (ctx.sql === sql) queryStarts++ + } + queryStartCh.subscribe(onStart) + + try { + query = callbackResult(callback => pool.query(sql, callback)) + assert.strictEqual(queryStarts, 0) + + await callbackResult(callback => connection.release(callback)) + released = true + await query + + assert.strictEqual(queryStarts, 1) + } finally { + queryStartCh.unsubscribe(onStart) + if (!released) await callbackResult(callback => connection.release(callback)) + await query?.catch(noop) + await callbackResult(callback => pool.end(callback)) + } + }) + + it('forwards bundled callback pool operations without subscribers', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + + tracer.use('mariadb', false) + try { + const [rows] = await callbackResult(callback => { + pool.query('SELECT 34 AS bundle_untraced_callback_pool', callback) + }) + const [acquired] = await callbackResult(callback => pool.getConnection(callback)) + + await callbackResult(callback => acquired.release(callback)) + assert.strictEqual(rows[0].bundle_untraced_callback_pool, 34) + } finally { + tracer.use('mariadb', true) + await callbackResult(callback => pool.end(callback)) + } + }) + + it('creates an acquire span for an explicit bundled callback getConnection', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + const parent = tracer.startSpan('bundle-callback-acquire-parent') + + try { + await Promise.all([ + agent.assertSomeTraces(traces => { + const acquireSpan = traces[0].find(span => span.name === 'mariadb.pool.acquire') + + assert.ok(acquireSpan, `missing acquire span: ${inspect(traces[0].map(span => span.name))}`) + assert.strictEqual(acquireSpan.parent_id.toString(), parent.context().toSpanId()) + assert.strictEqual(typeof acquireSpan.metrics['mariadb.pool.wait_time'], 'number') + }, { spanResourceMatch: /^mariadb\.pool\.acquire$/ }), + tracer.scope().activate(parent, async () => { + const [acquired] = await callbackResult(callback => pool.getConnection(callback)) + await callbackResult(callback => acquired.release(callback)) + parent.finish() + }), + ]) + } finally { + await callbackResult(callback => pool.end(callback)) + } + }) + + it('records errors for bundled callback pool acquisition failures', async () => { + const pool = mariadb.createPool({ + ...connectionOptions, + acquireTimeout: 500, + connectTimeout: 100, + host: '127.0.0.1', + port: await getClosedPort(), + }) + pool.on('error', noop) + const forbiddenResources = new Set([ + 'SELECT 32 AS bundle_callback_query_acquire_failure', + 'SELECT 33 AS bundle_callback_execute_acquire_failure', + ]) + const noQuerySpans = agent.assertNoTraces(traces => { + const span = traces.flat().find(span => forbiddenResources.has(span.resource)) + assert.strictEqual(span, undefined, `unexpected query span for failed acquisition: ${span?.resource}`) + }) + + try { + for (const [method, args] of [ + ['getConnection', []], + ['query', ['SELECT 32 AS bundle_callback_query_acquire_failure']], + ['execute', ['SELECT 33 AS bundle_callback_execute_acquire_failure']], + ]) { + await Promise.all([ + agent.assertSomeTraces(traces => { + const acquireSpan = traces[0].find(span => span.name === 'mariadb.pool.acquire') + + assert.ok(acquireSpan, `missing acquire span: ${inspect(traces[0].map(span => span.name))}`) + assert.strictEqual(acquireSpan.error, 1) + assert.strictEqual(typeof acquireSpan.metrics['mariadb.pool.wait_time'], 'number') + }), + assert.rejects(callbackResult(callback => pool[method](...args, callback))), + ]) + } + await noQuerySpans + } finally { + noQuerySpans.cancel() + await callbackResult(callback => pool.end(callback)) + } + }) + + it('records a synchronous bundled callback pool acquisition failure', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + await callbackResult(callback => pool.end(callback)) + + await Promise.all([ + agent.assertSomeTraces(traces => { + const acquireSpan = traces[0].find(span => span.name === 'mariadb.pool.acquire') + + assert.ok(acquireSpan, `missing acquire span: ${inspect(traces[0].map(span => span.name))}`) + assert.strictEqual(acquireSpan.error, 1) + assert.strictEqual(acquireSpan.metrics['mariadb.pool.wait_time'], 0) + }), + assert.rejects(callbackResult(callback => { + pool.query('SELECT 35 AS bundle_closed_pool_acquire_failure', callback) + })), + ]) + }) + + it('keeps bundled callback pool acquisition tracking after acquire listeners are removed', async () => { + const pool = mariadb.createPool({ ...connectionOptions, connectionLimit: 1, minimumIdle: 0 }) + const rootName = 'bundle.callback.removed_acquire_listeners' + const sql = 'SELECT * FROM dd_missing_callback_listener_probe' + + try { + await callbackResult(callback => pool.query('SELECT 1', callback)) + pool.removeAllListeners('acquire') + + const assertion = agent.assertSomeTraces(traces => { + const trace = traces.find(trace => trace.some(span => span.name === rootName)) + assert.ok(trace, `${rootName} trace has not flushed yet`) + + const querySpan = trace.find(span => span.resource === sql) + assert.ok(querySpan, `missing query span: ${inspect(trace.map(span => span.resource))}`) + assert.strictEqual(typeof querySpan.metrics['mariadb.pool.wait_time'], 'number') + assert.strictEqual(trace.find(span => span.name === 'mariadb.pool.acquire'), undefined) + }, { spanResourceMatch: new RegExp(`^${rootName}$`) }) + + await assert.rejects(tracer.trace(rootName, () => { + return callbackResult(callback => pool.query(sql, callback)) + })) + await assertion + } finally { + await callbackResult(callback => pool.end(callback)) + } + }) + + it('reports failed bundled callback cluster acquisitions without a matching node', async () => { + const cluster = mariadb.createPoolCluster({ canRetry: false }) + const rootName = 'bundle.callback.cluster_acquire_failure' + + try { + const assertion = agent.assertSomeTraces(traces => { + const trace = traces.find(trace => trace.some(span => span.name === rootName)) + assert.ok(trace, `${rootName} trace has not flushed yet`) + + const acquireSpan = trace.find(span => span.name === 'mariadb.pool.acquire') + assert.ok(acquireSpan, `missing acquire span: ${inspect(trace.map(span => span.name))}`) + assert.strictEqual(acquireSpan.error, 1) + assert.strictEqual(typeof acquireSpan.metrics['mariadb.pool.wait_time'], 'number') + }, { spanResourceMatch: new RegExp(`^${rootName}$`) }) + + await assert.rejects(tracer.trace(rootName, () => { + return callbackResult(callback => cluster.of('missing').query('SELECT 37', callback)) + })) + await assertion + } finally { + await callbackResult(callback => cluster.end(callback)) + } + }) + + it('traces pool clusters and uses selected node metadata', async () => { + const cluster = mariadb.createPoolCluster() + cluster.add('primary', { ...connectionOptions, minimumIdle: 0 }) + cluster.add('secondary', { ...connectionOptions, host: '127.0.0.1', minimumIdle: 0 }) + const filteredCluster = cluster.of(/^(primary|secondary)$/, 'RR') + const assertion = agent.assertFirstTraceSpan({ + resource: 'SELECT 16 AS callback_cluster_query', + meta: { + 'db.name': 'db', + 'db.user': 'root', + 'out.host': '127.0.0.1', + }, + metrics: { [CLIENT_PORT_KEY]: 3306 }, + }) + + try { + const [firstConnection] = await callbackResult(callback => filteredCluster.getConnection(callback)) + await callbackResult(callback => firstConnection.release(callback)) + await Promise.all([ + assertion, + callbackResult(callback => { + filteredCluster.query('SELECT 16 AS callback_cluster_query', callback) + }), + ]) + } finally { + await callbackResult(callback => cluster.end(callback)) + } + }) + + it('retains metadata when an automatically removed node is immediately re-added', async () => { + const cluster = mariadb.createPoolCluster({ canRetry: false, removeNodeErrorCount: 1 }) + cluster.add('primary', { + ...connectionOptions, + host: '127.0.0.1', + port: 1, + acquireTimeout: 100, + connectTimeout: 50, + minimumIdle: 0, + }) + + try { + await assert.rejects(callbackResult(callback => cluster.getConnection('primary', callback))) + cluster.add('primary', { ...connectionOptions, minimumIdle: 0 }) + await nextImmediate() + + const assertion = agent.assertFirstTraceSpan({ + resource: 'SELECT 22 AS callback_readded_cluster_query', + meta: { + 'db.name': 'db', + 'db.user': 'root', + 'out.host': 'localhost', + }, + metrics: { [CLIENT_PORT_KEY]: 3306 }, + }, { spanResourceMatch: /callback_readded_cluster_query/ }) + const [acquired] = await callbackResult(callback => cluster.getConnection('primary', callback)) + + try { + await Promise.all([ + assertion, + callbackResult(callback => { + acquired.query('SELECT 22 AS callback_readded_cluster_query', callback) + }), + ]) + } finally { + await callbackResult(callback => acquired.release(callback)) + } + } finally { + await callbackResult(callback => cluster.end(callback)) + } + }) + + it('preserves callback context and tags errors', async () => { + const parent = tracer.startSpan('bundle.callback.parent') + await new Promise((resolve, reject) => { + tracer.scope().activate(parent, () => { + connection.query('SELECT 17 AS callback_context_query', error => { + if (error) return reject(error) + try { + assert.strictEqual(tracer.scope().active(), parent) + resolve() + } catch (assertionError) { + reject(assertionError) + } + }) + }) + }) + parent.finish() + + const errorPromise = callbackResult(callback => { + connection.query('SELECT * FROM definitely_missing_callback_bundle_table', callback) + }).catch(() => {}) + await Promise.all([ + agent.assertFirstTraceSpan({ + resource: 'SELECT * FROM definitely_missing_callback_bundle_table', + meta: { + [ERROR_TYPE]: ANY_STRING, + [ERROR_MESSAGE]: ANY_STRING, + [ERROR_STACK]: ANY_STRING, + }, + }, { spanResourceMatch: /definitely_missing_callback_bundle_table/ }), + errorPromise, + ]) + }) + }) + }) + }) +}) diff --git a/packages/datadog-plugin-mariadb/test/integration-test/client.spec.js b/packages/datadog-plugin-mariadb/test/integration-test/client.spec.js index 283cfae3bc9..1964c1fcf6f 100644 --- a/packages/datadog-plugin-mariadb/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-mariadb/test/integration-test/client.spec.js @@ -29,7 +29,7 @@ describe('esm', () => { let agent let proc - const range = '>=3.0.0 <3.5.3' + const range = semver.gte(process.version, '20.0.0') ? '>=3.0.0' : '>=3.0.0 <3.5.3' withVersions('mariadb', 'mariadb', range, (version, _, resolvedVersion) => { useSandbox([`'mariadb@${version}'`], false, [ './packages/datadog-plugin-mariadb/test/integration-test/*']) @@ -56,7 +56,7 @@ describe('esm', () => { for (const variant of importVariants) { it(`is instrumented ${variant}`, async () => { - const resources = new Set() + const resources = [] const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) @@ -64,12 +64,12 @@ describe('esm', () => { for (const trace of payload) { for (const span of trace) { if (span.name === 'mariadb.query' && expectedResourceSet.has(span.resource)) { - resources.add(span.resource) + resources.push(span.resource) } } } - assert.deepStrictEqual([...resources].sort(), expectedResources) + assert.deepStrictEqual(resources.sort(), expectedResources) }) proc = await spawnPluginIntegrationTestProcAndExpectExit(sandboxCwd(), variants[variant], agent.port) diff --git a/packages/datadog-plugin-mongodb-core/test/core.spec.js b/packages/datadog-plugin-mongodb-core/test/core.spec.js index 8fcf8e93855..39975e5e082 100644 --- a/packages/datadog-plugin-mongodb-core/test/core.spec.js +++ b/packages/datadog-plugin-mongodb-core/test/core.spec.js @@ -1,7 +1,7 @@ 'use strict' const assert = require('node:assert/strict') -const { inspect } = require('node:util') +const { inspect, promisify } = require('node:util') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') const ddpv = require('mocha/package.json').version @@ -14,20 +14,22 @@ const { ERROR_MESSAGE, ERROR_TYPE, ERROR_STACK } = require('../../dd-trace/src/c const MongodbCorePlugin = require('../../datadog-plugin-mongodb-core/src/query') const { expectedSchema, rawExpectedSchema } = require('./naming') +const traceTimeoutMs = 2_000 + const withTopologies = fn => { - withVersions('mongodb-core', ['mongodb-core', 'mongodb'], '<4', (version, moduleName) => { + withVersions('mongodb-core', ['mongodb-core', 'mongodb'], '<4', (version, moduleName, resolvedVersion) => { describe('using the server topology', () => { fn(() => { const { CoreServer, Server } = require(`../../../versions/${moduleName}@${version}`).get() return CoreServer || Server - }) + }, resolvedVersion) }) // TODO: use semver.subset when we can update semver if (moduleName === 'mongodb-core' && !semver.intersects(version, '<3.2')) { describe('using the unified topology', () => { - fn(() => require(`../../../versions/${moduleName}@${version}`).get().Topology) + fn(() => require(`../../../versions/${moduleName}@${version}`).get().Topology, resolvedVersion) }) } }) @@ -41,7 +43,14 @@ describe('Plugin', () => { let injectCommentSpy describe('mongodb-core (core)', () => { - withTopologies(getServer => { + withTopologies((getServer, resolvedVersion) => { + /** + * @param {Promise} promise + */ + const expectCommandCompletion = promise => semver.satisfies(resolvedVersion, '<2.1') + ? promise + : assert.rejects(promise) + const next = (cursor, cb = () => {}) => { return cursor._next ? cursor._next(cb) @@ -89,63 +98,67 @@ describe('Plugin', () => { }) describe('server', () => { - it('should do automatic instrumentation', done => { - agent - .assertFirstTraceSpan({ - name: expectedSchema.outbound.opName, - service: expectedSchema.outbound.serviceName, - resource: `insert test.${collection}`, - type: 'mongodb', - meta: { - 'span.kind': 'client', - 'db.name': `test.${collection}`, - 'out.host': '127.0.0.1', - component: 'mongodb', - '_dd.integration': 'mongodb', - }, - }) - .then(done) - .catch(done) + it('should do automatic instrumentation', async () => { + const tracePromise = agent.assertFirstTraceSpan({ + name: expectedSchema.outbound.opName, + service: expectedSchema.outbound.serviceName, + resource: `insert test.${collection}`, + type: 'mongodb', + meta: { + 'span.kind': 'client', + 'db.name': `test.${collection}`, + 'out.host': '127.0.0.1', + component: 'mongodb', + '_dd.integration': 'mongodb', + }, + }, { timeoutMs: traceTimeoutMs }) - server.insert(`test.${collection}`, [{ a: 1 }], {}, () => {}) + await Promise.all([ + tracePromise, + promisify(server.insert.bind(server))(`test.${collection}`, [{ a: 1 }], {}), + ]) }) - it('should use the correct resource name for arbitrary commands', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `planCacheListPlans test.${collection}` - - assert.strictEqual(span.resource, resource) - }) - .then(done) - .catch(done) - - server.command(`test.${collection}`, { - planCacheListPlans: `test.${collection}`, - query: {}, - }, () => {}) + it('should use the correct resource name for arbitrary commands', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `planCacheListPlans test.${collection}` + + assert.strictEqual(span.resource, resource) + }, { timeoutMs: traceTimeoutMs }) + + await Promise.all([ + tracePromise, + expectCommandCompletion( + promisify(server.command.bind(server))(`test.${collection}`, { + planCacheListPlans: `test.${collection}`, + query: {}, + }) + ), + ]) }) - it('should sanitize buffers as values and not as objects', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `find test.${collection}` - const query = '{"_id":"?"}' - - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) - - server.command(`test.${collection}`, { - find: `test.${collection}`, - query: { - _id: Buffer.from('1234'), - }, - }, () => {}) + it('should sanitize buffers as values and not as objects', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `find test.${collection}` + const query = '{"_id":"?"}' + + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) + + await Promise.all([ + tracePromise, + expectCommandCompletion( + promisify(server.command.bind(server))(`test.${collection}`, { + find: `test.${collection}`, + query: { + _id: Buffer.from('1234'), + }, + }) + ), + ]) }) it('should serialize BigInt without erroring', done => { @@ -183,50 +196,54 @@ describe('Plugin', () => { .catch(done) }) - it('should stringify BSON objects', done => { + it('should stringify BSON objects', async () => { const BSON = require('../../../versions/bson@4.0.0').get() const id = '123456781234567812345678' - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `find test.${collection}` - const query = `{"_id":"${id}"}` - - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) - - server.command(`test.${collection}`, { - find: `test.${collection}`, - query: { - _id: new BSON.ObjectID(id), - }, - }, () => {}) + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `find test.${collection}` + const query = `{"_id":"${id}"}` + + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) + + await Promise.all([ + tracePromise, + expectCommandCompletion( + promisify(server.command.bind(server))(`test.${collection}`, { + find: `test.${collection}`, + query: { + _id: new BSON.ObjectID(id), + }, + }) + ), + ]) }) - it('should skip functions when sanitizing', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `find test.${collection}` - const query = '{"_id":"1234"}' - - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) - - server.command(`test.${collection}`, { - find: `test.${collection}`, - query: { - _id: '1234', - foo: () => {}, - }, - }, () => {}) + it('should skip functions when sanitizing', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `find test.${collection}` + const query = '{"_id":"1234"}' + + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) + + await Promise.all([ + tracePromise, + expectCommandCompletion( + promisify(server.command.bind(server))(`test.${collection}`, { + find: `test.${collection}`, + query: { + _id: '1234', + foo: () => {}, + }, + }) + ), + ]) }) it('should run the callback in the parent context', done => { @@ -267,49 +284,48 @@ describe('Plugin', () => { }) describe('cursor', () => { - it('should do automatic instrumentation', done => { - let cursor - - Promise.all([ + it('should do automatic instrumentation', async () => { + const tracePromise = Promise.all([ agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].resource, `find test.${collection}`) - }), + }, { timeoutMs: traceTimeoutMs }), agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].resource, `getMore test.${collection}`) - }), + }, { timeoutMs: traceTimeoutMs }), agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].resource, `killCursors test.${collection}`) - }), + }, { timeoutMs: traceTimeoutMs }), ]) - .then(() => done()) - .catch(done) - server.insert(`test.${collection}`, [{ a: 1 }, { a: 2 }, { a: 3 }], {}, () => { - cursor = server.cursor(`test.${collection}`, { + const operationPromise = (async () => { + await promisify(server.insert.bind(server))(`test.${collection}`, [{ a: 1 }, { a: 2 }, { a: 3 }], {}) + + const cursor = server.cursor(`test.${collection}`, { find: `test.${collection}`, query: {}, batchSize: 1, }, { batchSize: 1 }) - next(cursor, () => next(cursor, () => cursor.kill(() => {}))) - }) + await promisify(next)(cursor) + await promisify(next)(cursor) + await promisify(cursor.kill.bind(cursor))() + })() + + await Promise.all([tracePromise, operationPromise]) }) - it('should sanitize the query as the resource', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `find test.${collection}` - const query = '{"foo":1,"bar":{"baz":[1,2,3]}}' + it('should sanitize the query as the resource', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `find test.${collection}` + const query = '{"foo":1,"bar":{"baz":[1,2,3]}}' - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) const cursor = server.cursor(`test.${collection}`, { find: `test.${collection}`, @@ -321,7 +337,10 @@ describe('Plugin', () => { }, }) - next(cursor) + await Promise.all([ + tracePromise, + promisify(next)(cursor), + ]) }) it('should run the callback in the parent context', done => { @@ -390,16 +409,16 @@ describe('Plugin', () => { server.connect() }) - it('should be configured with the correct values', done => { - agent - .assertSomeTraces(traces => { - assert.strictEqual(traces[0][0].name, expectedSchema.outbound.opName) - assert.strictEqual(traces[0][0].service, 'custom') - }) - .then(done) - .catch(done) + it('should be configured with the correct values', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + assert.strictEqual(traces[0][0].name, expectedSchema.outbound.opName) + assert.strictEqual(traces[0][0].service, 'custom') + }, { timeoutMs: traceTimeoutMs }) - server.insert(`test.${collection}`, [{ a: 1 }], () => {}) + await Promise.all([ + tracePromise, + promisify(server.insert.bind(server))(`test.${collection}`, [{ a: 1 }]), + ]) }) withNamingSchema( @@ -447,17 +466,17 @@ describe('Plugin', () => { injectCommentSpy?.restore() }) - it('DBM propagation should not inject comment', done => { - agent - .assertSomeTraces(traces => { - assert.strictEqual(injectCommentSpy.called, true) - assert.strictEqual(injectCommentSpy.getCall(0).args[1], undefined) - assert.strictEqual(injectCommentSpy.getCall(0).returnValue, undefined) - }) - .then(done) - .catch(done) + it('DBM propagation should not inject comment', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + assert.strictEqual(injectCommentSpy.called, true) + assert.strictEqual(injectCommentSpy.getCall(0).args[1], undefined) + assert.strictEqual(injectCommentSpy.getCall(0).returnValue, undefined) + }, { timeoutMs: traceTimeoutMs }) - server.insert(`test.${collection}`, [{ a: 1 }], () => {}) + await Promise.all([ + tracePromise, + promisify(server.insert.bind(server))(`test.${collection}`, [{ a: 1 }]), + ]) }) }) @@ -491,36 +510,38 @@ describe('Plugin', () => { injectCommentSpy?.restore() }) - it('DBM propagation should not inject comment', done => { - agent - .assertSomeTraces(traces => { - assert.strictEqual(injectCommentSpy.called, true) - const comment = injectCommentSpy.getCall(0).returnValue - assert.strictEqual(comment, undefined) - }) - .then(done) - .catch(done) + it('DBM propagation should not inject comment', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue + assert.strictEqual(comment, undefined) + }, { timeoutMs: traceTimeoutMs }) - server.insert(`test.${collection}`, [{ a: 1 }], () => {}) + await Promise.all([ + tracePromise, + promisify(server.insert.bind(server))(`test.${collection}`, [{ a: 1 }]), + ]) }) - it('DBM propagation should not alter existing comment', done => { - agent - .assertSomeTraces(traces => { - assert.strictEqual(injectCommentSpy.called, true) - const comment = injectCommentSpy.getCall(0).returnValue - assert.strictEqual(comment, 'test comment') - }) - .then(done) - .catch(done) + it('DBM propagation should not alter existing comment', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue + assert.strictEqual(comment, 'test comment') + }, { timeoutMs: traceTimeoutMs }) - server.command(`test.${collection}`, { - find: `test.${collection}`, - query: { - _id: Buffer.from('1234'), - }, - comment: 'test comment', - }, () => {}) + await Promise.all([ + tracePromise, + expectCommandCompletion( + promisify(server.command.bind(server))(`test.${collection}`, { + find: `test.${collection}`, + query: { + _id: Buffer.from('1234'), + }, + comment: 'test comment', + }) + ), + ]) }) }) @@ -560,21 +581,21 @@ describe('Plugin', () => { injectCommentSpy?.restore() }) - it('DBM propagation should inject full mode comment with traceparent', done => { - agent - .assertFirstTraceSpan(span => { - const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') - const spanId = span.span_id.toString(16).padStart(16, '0') + it('DBM propagation should inject full mode comment with traceparent', async () => { + const tracePromise = agent.assertFirstTraceSpan(span => { + const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') + const spanId = span.span_id.toString(16).padStart(16, '0') - assert.strictEqual(injectCommentSpy.called, true) - const comment = injectCommentSpy.getCall(0).returnValue - assert.ok(comment.includes(`traceparent='00-${traceId}-${spanId}-01'`), `Got: ${inspect(comment)}`) - assert.strictEqual(span.meta['_dd.dbm_trace_injected'], 'true') - }) - .then(done) - .catch(done) + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue + assert.ok(comment.includes(`traceparent='00-${traceId}-${spanId}-01'`), `Got: ${inspect(comment)}`) + assert.strictEqual(span.meta['_dd.dbm_trace_injected'], 'true') + }, { timeoutMs: traceTimeoutMs }) - server.insert(`test.${collection}`, [{ a: 1 }], () => {}) + await Promise.all([ + tracePromise, + promisify(server.insert.bind(server))(`test.${collection}`, [{ a: 1 }]), + ]) }) }) @@ -608,87 +629,91 @@ describe('Plugin', () => { injectCommentSpy?.restore() }) - it('DBM propagation should inject service mode as comment', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - - assert.strictEqual(injectCommentSpy.called, true) - const comment = injectCommentSpy.getCall(0).returnValue - assert.strictEqual(comment, - `dddb='${encodeURIComponent(span.meta['db.name'])}',` + - 'dddbs=\'test-mongodb\',' + - 'dde=\'tester\',' + - `ddh='${encodeURIComponent(span.meta['out.host'])}',` + - `ddps='${encodeURIComponent(span.meta.service)}',` + - `ddpv='${ddpv}',` + - `ddprs='${encodeURIComponent(span.meta['peer.service'])}'` - ) - }) - .then(done) - .catch(done) - - server.insert(`test.${collection}`, [{ a: 1 }], () => {}) - }) - - it('DBM propagation should inject service mode after eixsting str comment', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - - assert.strictEqual(injectCommentSpy.called, true) - const comment = injectCommentSpy.getCall(0).returnValue - assert.strictEqual(comment, - 'test comment,' + - `dddb='${encodeURIComponent(span.meta['db.name'])}',` + - 'dddbs=\'test-mongodb\',' + - 'dde=\'tester\',' + - `ddh='${encodeURIComponent(span.meta['out.host'])}',` + - `ddps='${encodeURIComponent(span.meta.service)}',` + - `ddpv='${ddpv}',` + - `ddprs='${encodeURIComponent(span.meta['peer.service'])}'` - ) - }) - .then(done) - .catch(done) - - server.command(`test.${collection}`, { - find: `test.${collection}`, - query: { - _id: Buffer.from('1234'), - }, - comment: 'test comment', - }, () => {}) - }) - - it('DBM propagation should inject service mode after existing array comment', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - - assert.strictEqual(injectCommentSpy.called, true) - const comment = injectCommentSpy.getCall(0).returnValue - assert.deepStrictEqual(comment, [ - 'test comment', - `dddb='${encodeURIComponent(span.meta['db.name'])}',` + - 'dddbs=\'test-mongodb\',' + - 'dde=\'tester\',' + - `ddh='${encodeURIComponent(span.meta['out.host'])}',` + - `ddps='${encodeURIComponent(span.meta.service)}',` + - `ddpv='${ddpv}',` + - `ddprs='${encodeURIComponent(span.meta['peer.service'])}'`, - ]) - }) - .then(done) - .catch(done) + it('DBM propagation should inject service mode as comment', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue + assert.strictEqual(comment, + `dddb='${encodeURIComponent(span.meta['db.name'])}',` + + 'dddbs=\'test-mongodb\',' + + 'dde=\'tester\',' + + `ddh='${encodeURIComponent(span.meta['out.host'])}',` + + `ddps='${encodeURIComponent(span.meta.service)}',` + + `ddpv='${ddpv}',` + + `ddprs='${encodeURIComponent(span.meta['peer.service'])}'` + ) + }, { timeoutMs: traceTimeoutMs }) + + await Promise.all([ + tracePromise, + promisify(server.insert.bind(server))(`test.${collection}`, [{ a: 1 }]), + ]) + }) + + it('DBM propagation should inject service mode after eixsting str comment', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue + assert.strictEqual(comment, + 'test comment,' + + `dddb='${encodeURIComponent(span.meta['db.name'])}',` + + 'dddbs=\'test-mongodb\',' + + 'dde=\'tester\',' + + `ddh='${encodeURIComponent(span.meta['out.host'])}',` + + `ddps='${encodeURIComponent(span.meta.service)}',` + + `ddpv='${ddpv}',` + + `ddprs='${encodeURIComponent(span.meta['peer.service'])}'` + ) + }, { timeoutMs: traceTimeoutMs }) + + await Promise.all([ + tracePromise, + expectCommandCompletion( + promisify(server.command.bind(server))(`test.${collection}`, { + find: `test.${collection}`, + query: { + _id: Buffer.from('1234'), + }, + comment: 'test comment', + }) + ), + ]) + }) + + it('DBM propagation should inject service mode after existing array comment', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue + assert.deepStrictEqual(comment, [ + 'test comment', + `dddb='${encodeURIComponent(span.meta['db.name'])}',` + + 'dddbs=\'test-mongodb\',' + + 'dde=\'tester\',' + + `ddh='${encodeURIComponent(span.meta['out.host'])}',` + + `ddps='${encodeURIComponent(span.meta.service)}',` + + `ddpv='${ddpv}',` + + `ddprs='${encodeURIComponent(span.meta['peer.service'])}'`, + ]) + }, { timeoutMs: traceTimeoutMs }) - server.command(`test.${collection}`, { - find: `test.${collection}`, - query: { - _id: Buffer.from('1234'), - }, - comment: ['test comment'], - }, () => {}) + await Promise.all([ + tracePromise, + expectCommandCompletion( + promisify(server.command.bind(server))(`test.${collection}`, { + find: `test.${collection}`, + query: { + _id: Buffer.from('1234'), + }, + comment: ['test comment'], + }) + ), + ]) }) }) @@ -722,29 +747,29 @@ describe('Plugin', () => { injectCommentSpy?.restore() }) - it('DBM propagation should inject full mode with traceparent as comment', done => { - agent - .assertFirstTraceSpan(span => { - const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') - const spanId = span.span_id.toString(16).padStart(16, '0') - - assert.strictEqual(injectCommentSpy.called, true) - const comment = injectCommentSpy.getCall(0).returnValue - assert.strictEqual(comment, - `dddb='${encodeURIComponent(span.meta['db.name'])}',` + - 'dddbs=\'test-mongodb\',' + - 'dde=\'tester\',' + - `ddh='${encodeURIComponent(span.meta['out.host'])}',` + - `ddps='${encodeURIComponent(span.meta.service)}',` + - `ddpv='${ddpv}',` + - `ddprs='${encodeURIComponent(span.meta['peer.service'])}',` + - `traceparent='00-${traceId}-${spanId}-01'` - ) - }) - .then(done) - .catch(done) - - server.insert(`test.${collection}`, [{ a: 1 }], () => {}) + it('DBM propagation should inject full mode with traceparent as comment', async () => { + const tracePromise = agent.assertFirstTraceSpan(span => { + const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') + const spanId = span.span_id.toString(16).padStart(16, '0') + + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue + assert.strictEqual(comment, + `dddb='${encodeURIComponent(span.meta['db.name'])}',` + + 'dddbs=\'test-mongodb\',' + + 'dde=\'tester\',' + + `ddh='${encodeURIComponent(span.meta['out.host'])}',` + + `ddps='${encodeURIComponent(span.meta.service)}',` + + `ddpv='${ddpv}',` + + `ddprs='${encodeURIComponent(span.meta['peer.service'])}',` + + `traceparent='00-${traceId}-${spanId}-01'` + ) + }, { timeoutMs: traceTimeoutMs }) + + await Promise.all([ + tracePromise, + promisify(server.insert.bind(server))(`test.${collection}`, [{ a: 1 }]), + ]) }) }) @@ -780,24 +805,24 @@ describe('Plugin', () => { it( 'DBM propagation should inject full mode with traceparent as comment and the rejected sampling decision', - done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') - const spanId = span.span_id.toString(16).padStart(16, '0') - - assert.strictEqual(injectCommentSpy.called, true) - const comment = injectCommentSpy.getCall(0).returnValue - assert.match( - comment, - new RegExp(String.raw`traceparent='00-${traceId}-${spanId}-00'`) - ) - }) - .then(done) - .catch(done) + async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') + const spanId = span.span_id.toString(16).padStart(16, '0') - server.insert(`test.${collection}`, [{ a: 1 }], () => {}) + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue + assert.match( + comment, + new RegExp(String.raw`traceparent='00-${traceId}-${spanId}-00'`) + ) + }, { timeoutMs: traceTimeoutMs }) + + await Promise.all([ + tracePromise, + promisify(server.insert.bind(server))(`test.${collection}`, [{ a: 1 }]), + ]) }) }) }) diff --git a/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js b/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js index c489d6d63cc..dbed63d2e5b 100644 --- a/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js +++ b/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js @@ -17,6 +17,10 @@ const traceTimeoutMs = 2_000 const withTopologies = fn => { withVersions('mongodb-core', 'mongodb', '>=2', (version, moduleName, resolvedVersion) => { + const getBSON = () => semver.satisfies(resolvedVersion, '>=5') + ? require(`../../../versions/${moduleName}@${version}`).get() + : require('../../../versions/bson@4.0.0').get() + describe('using the default topology', () => { fn(async () => { // Different warnings for different versions of mongodb-core @@ -42,7 +46,7 @@ const withTopologies = fn => { await client.connect() return client - }, version) + }, version, getBSON) }) // unified topology is now the only topology and thus the default since 4.x @@ -56,7 +60,7 @@ const withTopologies = fn => { await client.connect() return client - }) + }, version, getBSON) }) } }) @@ -74,14 +78,14 @@ describe('Plugin', () => { let usesDelete describe('mongodb-core', () => { - withTopologies((createClient, version) => { + withTopologies((createClient, version, getBSON) => { beforeEach(() => { id = require('../../dd-trace/src/id') tracer = require('../../dd-trace') usesDelete = version ? semver.intersects(version, '>=4') : false collectionName = id().toString() - BSON = require('../../../versions/bson@4.0.0').get() + BSON = getBSON() }) afterEach(() => { @@ -391,134 +395,139 @@ describe('Plugin', () => { }, { spanResourceMatch: usesDelete ? /^delete test\./ : /^remove test\./ }) }) - it('should use the correct resource name for arbitrary commands', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = 'planCacheListPlans test.$cmd' - const query = '{}' + it('should use the correct resource name for arbitrary commands', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = 'planCacheListPlans test.$cmd' + const query = '{}' - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) - db.command({ - planCacheListPlans: `test.${collectionName}`, - query: {}, - }, () => {}) + const operationPromise = new Promise((resolve, reject) => { + const promise = db.command({ + planCacheListPlans: `test.${collectionName}`, + query: {}, + }, error => error ? reject(error) : resolve()) + promise?.then(resolve, reject) + }) + + await Promise.all([ + tracePromise, + assert.rejects(operationPromise), + ]) }) - it('should sanitize buffers as values and not as objects', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `find test.${collectionName}` - const query = '{"_id":"?"}' + it('should sanitize buffers as values and not as objects', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `find test.${collectionName}` + const query = '{"_id":"?"}' - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) - collection.find({ - _id: Buffer.from('1234'), - }).toArray() + await Promise.all([ + tracePromise, + collection.find({ + _id: Buffer.from('1234'), + }).toArray(), + ]) }) - it('should sanitize BSON binary', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `find test.${collectionName}` - const query = '{"_bin":"?"}' + it('should sanitize BSON binary', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `find test.${collectionName}` + const query = '{"_bin":"?"}' - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) - collection.find({ - _bin: new BSON.Binary(), - }).toArray() + await Promise.all([ + tracePromise, + collection.find({ + _bin: new BSON.Binary(), + }).toArray(), + ]) }) - it('should stringify BSON primitives', done => { + it('should stringify BSON primitives', async () => { const id = '123456781234567812345678' - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `find test.${collectionName}` - const query = `{"_id":"${id}"}` + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `find test.${collectionName}` + const query = `{"_id":"${id}"}` - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) - collection.find({ - _id: new BSON.ObjectID(id), - }).toArray() + await Promise.all([ + tracePromise, + collection.find({ + _id: new BSON.ObjectId(id), + }).toArray(), + ]) }) - it('should stringify BSON objects', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `find test.${collectionName}` - const query = '{"_time":{"$timestamp":"0"}}' + it('should stringify BSON objects', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `find test.${collectionName}` + const query = '{"_time":{"$timestamp":"0"}}' - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) - collection.find({ - _time: new BSON.Timestamp(), - }).toArray() + await Promise.all([ + tracePromise, + collection.find({ + _time: new BSON.Timestamp(), + }).toArray(), + ]) }) - it('should stringify BSON internal types', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `find test.${collectionName}` - const query = '{"_id":"?"}' + it('should stringify BSON internal types', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `find test.${collectionName}` + const query = '{"_id":"?"}' - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) - collection.find({ - _id: new BSON.MinKey(), - }).toArray() + await Promise.all([ + tracePromise, + collection.find({ + _id: new BSON.MinKey(), + }).toArray(), + ]) }) - it('should collapse beyond max depth', done => { + it('should collapse beyond max depth', async () => { let nested = { a: 1 } for (let i = 0; i < 12; i++) { nested = { a: nested } } - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - assert.strictEqual(span.resource, `find test.${collectionName}`) - // 10 levels of `{"a":` then `"?"`, then 10 closing braces. - assert.strictEqual(span.meta['mongodb.query'], `${'{"a":'.repeat(10)}"?"${'}'.repeat(10)}`) - }) - .then(done) - .catch(done) + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + assert.strictEqual(span.resource, `find test.${collectionName}`) + // 10 levels of `{"a":` then `"?"`, then 10 closing braces. + assert.strictEqual(span.meta['mongodb.query'], `${'{"a":'.repeat(10)}"?"${'}'.repeat(10)}`) + }, { timeoutMs: traceTimeoutMs }) - collection.find(nested).toArray().catch(() => {}) + await Promise.all([ + tracePromise, + collection.find(nested).toArray(), + ]) }) it('should collapse cyclic queries to ?', done => { @@ -538,62 +547,62 @@ describe('Plugin', () => { collection.find(cyclic).toArray().catch(() => {}) }) - it('should skip functions when sanitizing', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `find test.${collectionName}` - const query = '{"_id":"1234"}' + it('should skip functions when sanitizing', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `find test.${collectionName}` + const query = '{"_id":"1234"}' - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) - collection.find({ - _id: '1234', - foo: () => {}, - }).toArray() + await Promise.all([ + tracePromise, + collection.find({ + _id: '1234', + foo: () => {}, + }).toArray(), + ]) }) - it('should log the aggregate pipeline in mongodb.query', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = 'aggregate test.$cmd' - const query = '[{"$match":{"_id":"1234"}},{"$project":{"_id":1}}]' + it('should log the aggregate pipeline in mongodb.query', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = 'aggregate test.$cmd' + const query = '[{"$match":{"_id":"1234"}},{"$project":{"_id":1}}]' - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) - collection.aggregate([ - { $match: { _id: '1234' } }, - { $project: { _id: 1 } }, - ]).toArray() + await Promise.all([ + tracePromise, + collection.aggregate([ + { $match: { _id: '1234' } }, + { $project: { _id: 1 } }, + ]).toArray(), + ]) }) - it('should use the toJSON method of objects if it exists', done => { + it('should use the toJSON method of objects if it exists', async () => { const id = '123456781234567812345678' - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `find test.${collectionName}` - const query = `{"_id":"${id}"}` + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `find test.${collectionName}` + const query = `{"_id":"${id}"}` - assert.strictEqual(span.resource, resource) - assert.strictEqual(span.meta['mongodb.query'], query) - }) - .then(done) - .catch(done) + assert.strictEqual(span.resource, resource) + assert.strictEqual(span.meta['mongodb.query'], query) + }, { timeoutMs: traceTimeoutMs }) - collection.find({ - _id: { toJSON: () => id }, - }).toArray() + await Promise.all([ + tracePromise, + collection.find({ + _id: { toJSON: () => id }, + }).toArray(), + ]) }) it('should run the callback in the parent context', done => { @@ -646,20 +655,20 @@ describe('Plugin', () => { collection.insertOne({ a: 1 }, {}, () => {}) }) - it('should include sanitized query in resource when configured', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const resource = `find test.${collectionName} {"_bin":"?"}` + it('should include sanitized query in resource when configured', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const resource = `find test.${collectionName} {"_bin":"?"}` - assert.strictEqual(span.resource, resource) - }) - .then(done) - .catch(done) + assert.strictEqual(span.resource, resource) + }, { timeoutMs: traceTimeoutMs }) - collection.find({ - _bin: new BSON.Binary(), - }).toArray() + await Promise.all([ + tracePromise, + collection.find({ + _bin: new BSON.Binary(), + }).toArray(), + ]) }) it('should sanitize query in resource when configured and doing a multi statement update', async () => { @@ -794,29 +803,29 @@ describe('Plugin', () => { injectCommentSpy?.restore() }) - it('DBM propagation should inject service mode as comment', done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - - assert.strictEqual(injectCommentSpy.called, true) - const comment = injectCommentSpy.getCall(0).returnValue - assert.strictEqual(comment, - `dddb='${encodeURIComponent(span.meta['db.name'])}',` + - 'dddbs=\'test-mongodb\',' + - 'dde=\'tester\',' + - `ddh='${encodeURIComponent(span.meta['out.host'])}',` + - `ddps='${encodeURIComponent(span.meta.service)}',` + - `ddpv='${ddpv}',` + - `ddprs='${encodeURIComponent(span.meta['peer.service'])}'` - ) - }) - .then(done) - .catch(done) - - collection.find({ - _id: Buffer.from('1234'), - }).toArray() + it('DBM propagation should inject service mode as comment', async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue + assert.strictEqual(comment, + `dddb='${encodeURIComponent(span.meta['db.name'])}',` + + 'dddbs=\'test-mongodb\',' + + 'dde=\'tester\',' + + `ddh='${encodeURIComponent(span.meta['out.host'])}',` + + `ddps='${encodeURIComponent(span.meta.service)}',` + + `ddpv='${ddpv}',` + + `ddprs='${encodeURIComponent(span.meta['peer.service'])}'` + ) + }, { timeoutMs: traceTimeoutMs }) + + await Promise.all([ + tracePromise, + collection.find({ + _id: Buffer.from('1234'), + }).toArray(), + ]) }) }) @@ -841,31 +850,31 @@ describe('Plugin', () => { injectCommentSpy?.restore() }) - it('DBM propagation should inject full mode with traceparent as comment', done => { - agent - .assertFirstTraceSpan(span => { - const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') - const spanId = span.span_id.toString(16).padStart(16, '0') - - assert.strictEqual(injectCommentSpy.called, true) - const comment = injectCommentSpy.getCall(0).returnValue - assert.strictEqual(comment, - `dddb='${encodeURIComponent(span.meta['db.name'])}',` + - 'dddbs=\'test-mongodb\',' + - 'dde=\'tester\',' + - `ddh='${encodeURIComponent(span.meta['out.host'])}',` + - `ddps='${encodeURIComponent(span.meta.service)}',` + - `ddpv='${ddpv}',` + - `ddprs='${encodeURIComponent(span.meta['peer.service'])}',` + - `traceparent='00-${traceId}-${spanId}-01'` - ) - }) - .then(done) - .catch(done) - - collection.find({ - _id: Buffer.from('1234'), - }).toArray() + it('DBM propagation should inject full mode with traceparent as comment', async () => { + const tracePromise = agent.assertFirstTraceSpan(span => { + const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') + const spanId = span.span_id.toString(16).padStart(16, '0') + + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue + assert.strictEqual(comment, + `dddb='${encodeURIComponent(span.meta['db.name'])}',` + + 'dddbs=\'test-mongodb\',' + + 'dde=\'tester\',' + + `ddh='${encodeURIComponent(span.meta['out.host'])}',` + + `ddps='${encodeURIComponent(span.meta.service)}',` + + `ddpv='${ddpv}',` + + `ddprs='${encodeURIComponent(span.meta['peer.service'])}',` + + `traceparent='00-${traceId}-${spanId}-01'` + ) + }, { timeoutMs: traceTimeoutMs }) + + await Promise.all([ + tracePromise, + collection.find({ + _id: Buffer.from('1234'), + }).toArray(), + ]) }) }) @@ -892,26 +901,26 @@ describe('Plugin', () => { it( 'DBM propagation should inject full mode with traceparent as comment and the rejected sampling decision', - done => { - agent - .assertSomeTraces(traces => { - const span = traces[0][0] - const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') - const spanId = span.span_id.toString(16).padStart(16, '0') - - assert.strictEqual(injectCommentSpy.called, true) - const comment = injectCommentSpy.getCall(0).returnValue - assert.match( - comment, - new RegExp(String.raw`traceparent='00-${traceId}-${spanId}-00'`) - ) - }) - .then(done) - .catch(done) + async () => { + const tracePromise = agent.assertSomeTraces(traces => { + const span = traces[0][0] + const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') + const spanId = span.span_id.toString(16).padStart(16, '0') - collection.find({ - _id: Buffer.from('1234'), - }).toArray() + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue + assert.match( + comment, + new RegExp(String.raw`traceparent='00-${traceId}-${spanId}-00'`) + ) + }, { timeoutMs: traceTimeoutMs }) + + await Promise.all([ + tracePromise, + collection.find({ + _id: Buffer.from('1234'), + }).toArray(), + ]) }) }) diff --git a/packages/datadog-plugin-openai-agents/src/integration.js b/packages/datadog-plugin-openai-agents/src/integration.js index b5427d3eb53..6a43ba1b9b9 100644 --- a/packages/datadog-plugin-openai-agents/src/integration.js +++ b/packages/datadog-plugin-openai-agents/src/integration.js @@ -63,6 +63,7 @@ class OpenAIAgentsIntegration { #tracer #config #enabled = false + #llmobsEnabled = true #service /** * LLMObs is gated independently of APM tracing: when DD_LLMOBS_ENABLED is @@ -95,10 +96,11 @@ class OpenAIAgentsIntegration { /** * Apply plugin lifecycle configuration. * - * @param {{ enabled?: boolean, service?: string }} [config] + * @param {{ enabled?: boolean, llmobs?: boolean, service?: string }} [config] */ configure (config) { this.#enabled = !!config?.enabled + this.#llmobsEnabled = config?.llmobs !== false this.#service = config?.service } @@ -210,15 +212,17 @@ class OpenAIAgentsIntegration { completionRequested: false, }) - this.#tagger.registerLLMObsSpan(ddSpan, { - kind: 'workflow', - name, - integration: COMPONENT, - parent: llmobsParentStore?.span, - sessionId: oaiTrace.groupId || undefined, - }) - if (LLMObsTagger.tagMap.has(ddSpan)) { - llmobsStorage.enterWith({ ...llmobsParentStore, span: ddSpan }) + if (this.#isLLMObsEnabled()) { + this.#tagger.registerLLMObsSpan(ddSpan, { + kind: 'workflow', + name, + integration: COMPONENT, + parent: llmobsParentStore?.span, + sessionId: oaiTrace.groupId || undefined, + }) + if (LLMObsTagger.tagMap.has(ddSpan)) { + llmobsStorage.enterWith({ ...llmobsParentStore, span: ddSpan }) + } } } @@ -680,7 +684,7 @@ class OpenAIAgentsIntegration { * @returns {boolean} */ #isLLMObsEnabled () { - return !!this.#config.llmobs?.DD_LLMOBS_ENABLED + return this.#llmobsEnabled && !!this.#config.llmobs?.DD_LLMOBS_ENABLED } /** diff --git a/packages/datadog-plugin-openai-agents/test/integration.spec.js b/packages/datadog-plugin-openai-agents/test/integration.spec.js index 7387481de79..ea3efd97299 100644 --- a/packages/datadog-plugin-openai-agents/test/integration.spec.js +++ b/packages/datadog-plugin-openai-agents/test/integration.spec.js @@ -62,6 +62,26 @@ describe('OpenAIAgentsIntegration', () => { integration.configure({ enabled: false }) assert.strictEqual(integration.enabled, false) }) + + it('keeps APM tracing enabled while opting out of LLM Observability', () => { + const workflowSpan = makeFakeSpan('workflow') + const { integration, tracer } = build({ + tracerSpans: [workflowSpan], + config: { + llmobs: { + DD_LLMOBS_ENABLED: true, + mlApp: 'test', + sampleRate: 1, + }, + }, + }) + + integration.configure({ enabled: true, llmobs: false }) + integration.startTrace({ traceId: 't1' }) + + sinon.assert.calledOnce(tracer.startSpan) + assert.strictEqual(LLMObsTagger.tagMap.get(workflowSpan), undefined) + }) }) describe('startTrace', () => { diff --git a/packages/datadog-plugin-openai/test/index.spec.js b/packages/datadog-plugin-openai/test/index.spec.js index b4827f33d30..74f7d22d836 100644 --- a/packages/datadog-plugin-openai/test/index.spec.js +++ b/packages/datadog-plugin-openai/test/index.spec.js @@ -25,7 +25,6 @@ describe('Plugin', () => { let clock let metricStub let externalLoggerStub - let realVersion let tracer let globalFile @@ -34,7 +33,7 @@ describe('Plugin', () => { }) describe('openai', () => { - withVersions('openai', 'openai', version => { + withVersions('openai', 'openai', (version, _, realVersion) => { const moduleRequirePath = `../../../versions/openai@${version}` before(async () => { @@ -56,7 +55,6 @@ describe('Plugin', () => { const requiredModule = require(moduleRequirePath) const module = requiredModule.get() - realVersion = requiredModule.version() if (semver.satisfies(realVersion, '>=5.0.0') && NODE_MAJOR < 20) { /** @@ -160,11 +158,10 @@ describe('Plugin', () => { sinon.assert.neverCalledWith(metricStub, 'openai.ratelimit.remaining.tokens') }) - it('logs edit instructions', async function () { - if (semver.satisfies(realVersion, '>=4.0.0')) { - this.skip() - } + const editInstructionsTest = semver.satisfies(realVersion, '>=4.0.0') ? it.skip : it + // The edits API was removed in OpenAI 4.0.0. + editInstructionsTest('logs edit instructions', async function () { const nock = require('nock') if (!nock.isActive()) nock.activate() @@ -218,11 +215,10 @@ describe('Plugin', () => { }) }) - it('should maintain the context with a streamed call', async function () { - if (semver.satisfies(realVersion, '<4.1.0')) { - this.skip() - } + const streamedContextTest = semver.satisfies(realVersion, '>=4.1.0') ? it : it.skip + // This streaming API is only available from OpenAI 4.1.0. + streamedContextTest('should maintain the context with a streamed call', async function () { await tracer.trace('outer', async (outerSpan) => { const stream = await openai.chat.completions.create({ model: 'gpt-3.5-turbo', @@ -338,14 +334,11 @@ describe('Plugin', () => { await checkTraces }) + // These streaming response semantics require OpenAI newer than 4.1.0. describe('streamed responses', function () { - beforeEach(function () { - if (semver.satisfies(realVersion, '<=4.1.0')) { - this.skip() - } - }) + const streamingResponseTest = semver.satisfies(realVersion, '>4.1.0') ? it : it.skip - it('makes a successful call', async () => { + streamingResponseTest('makes a successful call', async () => { const checkTraces = agent .assertSomeTraces(traces => { assert.ok( @@ -376,7 +369,7 @@ describe('Plugin', () => { await checkTraces }) - it('makes a successful call with usage included', async () => { + streamingResponseTest('makes a successful call with usage included', async () => { const checkTraces = agent .assertSomeTraces(traces => { assert.ok( @@ -412,7 +405,7 @@ describe('Plugin', () => { await checkTraces }) - it('tags multiple responses', async () => { + streamingResponseTest('tags multiple responses', async () => { const checkTraces = agent .assertSomeTraces(traces => { // Multiple response choice tags removed - basic span validation @@ -635,11 +628,10 @@ describe('Plugin', () => { await checkTraces }) - it('create file', async function () { - if (!semver.satisfies(realVersion, '>=4.0.0')) { - this.skip() - } + const createFileTest = semver.satisfies(realVersion, '>=4.0.0') ? it : it.skip + // The files API shape exercised here starts in OpenAI 4.0.0. + createFileTest('create file', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -816,12 +808,10 @@ describe('Plugin', () => { await checkTraces }) - it('create fine-tune', async function () { - if (semver.satisfies(realVersion, '<4.17.0')) { - // fine tuning endpoints used in lower versions of the OpenAI SDK have been deprecated - this.skip() - } + const createFineTuneTest = semver.satisfies(realVersion, '>=4.17.0') ? it : it.skip + // fine tuning endpoints used in lower versions of the OpenAI SDK have been deprecated + createFineTuneTest('create fine-tune', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -859,11 +849,10 @@ describe('Plugin', () => { await checkTraces }) - it('retrieve fine-tune', async function () { - if (semver.satisfies(realVersion, '<4.17.0')) { - this.skip() - } + const retrieveFineTuneTest = semver.satisfies(realVersion, '>=4.17.0') ? it : it.skip + // This fine-tuning API is only available from OpenAI 4.17.0. + retrieveFineTuneTest('retrieve fine-tune', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -897,11 +886,10 @@ describe('Plugin', () => { await checkTraces }) - it('cancel fine-tune', async function () { - if (semver.satisfies(realVersion, '<4.17.0')) { - this.skip() - } + const cancelFineTuneTest = semver.satisfies(realVersion, '>=4.17.0') ? it : it.skip + // This fine-tuning API is only available from OpenAI 4.17.0. + cancelFineTuneTest('cancel fine-tune', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -932,11 +920,10 @@ describe('Plugin', () => { await checkTraces }) - it('list fine-tune events', async function () { - if (semver.satisfies(realVersion, '<4.17.0')) { - this.skip() - } + const listFineTuneEventsTest = semver.satisfies(realVersion, '>=4.17.0') ? it : it.skip + // This fine-tuning API is only available from OpenAI 4.17.0. + listFineTuneEventsTest('list fine-tune events', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -967,11 +954,10 @@ describe('Plugin', () => { await checkTraces }) - it('list fine-tunes', async function () { - if (semver.satisfies(realVersion, '<4.17.0')) { - this.skip() - } + const listFineTunesTest = semver.satisfies(realVersion, '>=4.17.0') ? it : it.skip + // This fine-tuning API is only available from OpenAI 4.17.0. + listFineTunesTest('list fine-tunes', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1002,11 +988,10 @@ describe('Plugin', () => { await checkTraces }) - it('create moderation', async function () { - if (semver.satisfies(realVersion, '<3.0.1')) { - this.skip() - } + const createModerationTest = semver.satisfies(realVersion, '>=3.0.1') ? it : it.skip + // This moderation API is only available from OpenAI 3.0.1. + createModerationTest('create moderation', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1049,11 +1034,10 @@ describe('Plugin', () => { }) for (const responseFormat of ['url', 'b64_json']) { - it(`create image ${responseFormat}`, async function () { - if (semver.satisfies(realVersion, '<3.1.0')) { - this.skip() - } + const createImageTest = semver.satisfies(realVersion, '>=3.1.0') ? it : it.skip + // This image response format is only available from OpenAI 3.1.0. + createImageTest(`create image ${responseFormat}`, async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1108,17 +1092,15 @@ describe('Plugin', () => { }) } - it('create image edit', async function () { - if (semver.satisfies(realVersion, '<4.33.1')) { - /** - * lower versions will fail with - * - * Error: 400 Invalid file 'image': unsupported mimetype ('application/octet-stream'). - * Supported file formats are 'image/png'. - */ - this.skip() - } + const createImageEditTest = semver.satisfies(realVersion, '>=4.33.1') ? it : it.skip + /** + * lower versions will fail with + * + * Error: 400 Invalid file 'image': unsupported mimetype ('application/octet-stream'). + * Supported file formats are 'image/png'. + */ + createImageEditTest('create image edit', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1156,16 +1138,14 @@ describe('Plugin', () => { await checkTraces }) - it('create image variation', async function () { - if (semver.satisfies(realVersion, '<4.0.0')) { - /** - * lower versions fail with - * - * Error: Request failed with status code 400 - */ - this.skip() - } + const createImageVariationTest = semver.satisfies(realVersion, '>=4.0.0') ? it : it.skip + /** + * lower versions fail with + * + * Error: Request failed with status code 400 + */ + createImageVariationTest('create image variation', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1204,16 +1184,14 @@ describe('Plugin', () => { await checkTraces }) - it('create transcription', async function () { - if (semver.satisfies(realVersion, '<4.0.0')) { - /** - * lower versions fail with - * - * Error: Request failed with status code 400 - */ - this.skip() - } + const createTranscriptionTest = semver.satisfies(realVersion, '>=4.0.0') ? it : it.skip + /** + * Lower versions fail with + * + * Error: Request failed with status code 400 + */ + createTranscriptionTest('create transcription', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1249,16 +1227,14 @@ describe('Plugin', () => { sinon.assert.called(externalLoggerStub) }) - it('create translation', async function () { - if (semver.satisfies(realVersion, '<4.0.0')) { - /** - * lower versions fail with - * - * Error: Request failed with status code 400 - */ - this.skip() - } + const createTranslationTest = semver.satisfies(realVersion, '>=4.0.0') ? it : it.skip + /** + * Lower versions fail with + * + * Error: Request failed with status code 400 + */ + createTranslationTest('create translation', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1305,13 +1281,10 @@ describe('Plugin', () => { sinon.assert.called(externalLoggerStub) }) - describe('chat completions', function () { - beforeEach(function () { - if (semver.satisfies(realVersion, '<3.2.0')) { - this.skip() - } - }) + const chatCompletionsDescribe = semver.satisfies(realVersion, '>=3.2.0') ? describe : describe.skip + // Chat completions are only available from OpenAI 3.2.0. + chatCompletionsDescribe('chat completions', function () { it('makes a successful call', async () => { const checkTraces = agent .assertSomeTraces(traces => { @@ -1470,11 +1443,10 @@ describe('Plugin', () => { await checkTraces }) - it('should make a successful call with tools', async function () { - if (semver.satisfies(realVersion, '<3.2.0')) { - this.skip() - } + const chatCompletionsToolsTest = semver.satisfies(realVersion, '>=3.2.0') ? it : it.skip + // Tool calls are only available from OpenAI 3.2.0. + chatCompletionsToolsTest('should make a successful call with tools', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1513,14 +1485,11 @@ describe('Plugin', () => { sinon.assert.called(externalLoggerStub) }) + // These streaming response semantics require OpenAI newer than 4.1.0. describe('streamed responses', function () { - beforeEach(function () { - if (semver.satisfies(realVersion, '<=4.1.0')) { - this.skip() - } - }) + const chatStreamingResponseTest = semver.satisfies(realVersion, '>4.1.0') ? it : it.skip - it('makes a successful call', async () => { + chatStreamingResponseTest('makes a successful call', async () => { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1563,7 +1532,7 @@ describe('Plugin', () => { await checkTraces }) - it('tags multiple responses', async () => { + chatStreamingResponseTest('tags multiple responses', async () => { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1606,7 +1575,7 @@ describe('Plugin', () => { await checkTraces }) - it('makes a successful call with usage included', async () => { + chatStreamingResponseTest('makes a successful call with usage included', async () => { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1654,7 +1623,7 @@ describe('Plugin', () => { await checkTraces }) - it('tags multiple responses 2', async () => { + chatStreamingResponseTest('tags multiple responses 2', async () => { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1697,7 +1666,7 @@ describe('Plugin', () => { await checkTraces }) - it('excludes image_url from usage', async () => { + chatStreamingResponseTest('excludes image_url from usage', async () => { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1737,11 +1706,10 @@ describe('Plugin', () => { await checkTraces }) - it('makes a successful call with tools', async function () { - if (semver.satisfies(realVersion, '<=4.16.0')) { - this.skip() - } + const streamedToolsTest = semver.satisfies(realVersion, '>4.16.0') ? it : it.skip + // This tool-call response shape requires OpenAI newer than 4.16.0. + streamedToolsTest('makes a successful call with tools', async function () { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') @@ -1781,11 +1749,10 @@ describe('Plugin', () => { }) }) - it('makes a successful call with chat.completions.parse', async function () { - if (semver.satisfies(realVersion, '<4.59.0')) { - this.skip() - } + const chatCompletionsParseTest = semver.satisfies(realVersion, '>=4.59.0') ? it : it.skip + // chat.completions.parse is only available from OpenAI 4.59.0. + chatCompletionsParseTest('makes a successful call with chat.completions.parse', async function () { const checkTraces = agent .assertSomeTraces(traces => { const span = traces[0][0] diff --git a/packages/datadog-plugin-pino/test/index.spec.js b/packages/datadog-plugin-pino/test/index.spec.js index 6aeb8bfa6ce..cc76244be8d 100644 --- a/packages/datadog-plugin-pino/test/index.spec.js +++ b/packages/datadog-plugin-pino/test/index.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const { Writable } = require('node:stream') +const { channel } = require('dc-polyfill') const { afterEach, beforeEach, describe, it } = require('mocha') const semver = require('semver') const sinon = require('sinon') @@ -12,6 +13,8 @@ const agent = require('../../dd-trace/test/plugins/agent') const { withExports, withVersions } = require('../../dd-trace/test/setup/mocha') const { assertObjectContains } = require('../../../integration-tests/helpers') +const logSubmissionCh = channel('ci:log-submission:log') + describe('Plugin', () => { let logger let tracer @@ -98,6 +101,35 @@ describe('Plugin', () => { } }) + describe('with disabled plugin', () => { + beforeEach(async () => { + tracer = await agent.load('pino', { enabled: false }) + }) + + it('should not submit logs', function () { + const submittedLogs = [] + const onLogSubmission = payload => { + submittedLogs.push(payload) + } + logSubmissionCh.subscribe(onLogSubmission) + + try { + setupTest() + + if (!logger) { + this.skip() + } + + logger.info('message') + } finally { + logSubmissionCh.unsubscribe(onLogSubmission) + } + + assert.strictEqual(submittedLogs.length, 0) + sinon.assert.called(stream.write) + }) + }) + describe('with configuration', () => { beforeEach(() => { return agent.load('pino', { logInjection: true }) @@ -112,21 +144,34 @@ describe('Plugin', () => { }) it('should add the trace identifiers to logger instances', () => { - tracer.scope().activate(span, () => { - logger.info('message') + let submittedLog + const onLogSubmission = payload => { + submittedLog = payload + } + logSubmissionCh.subscribe(onLogSubmission) - sinon.assert.called(stream.write) + try { + tracer.scope().activate(span, () => { + logger.info('message') - const record = JSON.parse(stream.write.firstCall.args[0].toString()) + sinon.assert.called(stream.write) - assertObjectContains(record.dd, { - trace_id: span.context().toTraceId(true), - span_id: span.context().toSpanId(), - }) + const record = JSON.parse(stream.write.firstCall.args[0].toString()) - assert.ok('msg' in record) - assert.deepStrictEqual(record.msg, 'message') - }) + assertObjectContains(record.dd, { + trace_id: span.context().toTraceId(true), + span_id: span.context().toSpanId(), + }) + + assert.ok('msg' in record) + assert.deepStrictEqual(record.msg, 'message') + + assert.strictEqual(submittedLog.source, 'pino') + assert.deepStrictEqual(JSON.parse(submittedLog.message), record) + }) + } finally { + logSubmissionCh.unsubscribe(onLogSubmission) + } }) it('should support errors', () => { @@ -171,15 +216,28 @@ describe('Plugin', () => { }) it('should not overwrite a caller-supplied dd field', () => { - tracer.scope().activate(span, () => { - logger.info({ dd: { custom: 'value' } }, 'message') + let submittedLog + const onLogSubmission = payload => { + submittedLog = payload + } + logSubmissionCh.subscribe(onLogSubmission) - sinon.assert.called(stream.write) + try { + tracer.scope().activate(span, () => { + logger.info({ dd: { custom: 'value' } }, 'message') - const record = JSON.parse(stream.write.firstCall.args[0].toString()) + sinon.assert.called(stream.write) - assert.deepStrictEqual(record.dd, { custom: 'value' }) - }) + const record = JSON.parse(stream.write.firstCall.args[0].toString()) + + assert.deepStrictEqual(record.dd, { custom: 'value' }) + + assert.strictEqual(submittedLog.source, 'pino') + assert.deepStrictEqual(JSON.parse(submittedLog.message).dd, { custom: 'value' }) + }) + } finally { + logSubmissionCh.unsubscribe(onLogSubmission) + } }) it('should not inject trace_id or span_id without an active span', () => { diff --git a/packages/datadog-plugin-pino/test/unit.spec.js b/packages/datadog-plugin-pino/test/unit.spec.js index 4a8e300ef56..3ed72adb5fc 100644 --- a/packages/datadog-plugin-pino/test/unit.spec.js +++ b/packages/datadog-plugin-pino/test/unit.spec.js @@ -2,7 +2,7 @@ const assert = require('node:assert/strict') -const { describe, it } = require('mocha') +const { after, before, describe, it } = require('mocha') const { channel } = require('dc-polyfill') const { storage } = require('../../datadog-core') @@ -25,12 +25,19 @@ const tracer = new Tracer(getConfig({ const plugin = new PinoPlugin({ _tracer: tracer, }) -plugin.configure({ - logInjection: true, - enabled: true, -}) describe('PinoPlugin', () => { + before(() => { + plugin.configure({ + logInjection: true, + enabled: true, + }) + }) + + after(() => { + plugin.configure(false) + }) + it('splices trace correlation into pino JSON output', () => { const data = { line: '{"level":30,"msg":"hello"}' } jsonCh.publish(data) diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index b5a22446a2f..6b6b7420409 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -523,7 +523,15 @@ class PlaywrightPlugin extends CiPlugin { finishAllTraceSpans(span) if (this._tracerConfig.DD_PLAYWRIGHT_WORKER) { - this.tracer._exporter.flush(onDone) + try { + this.tracer._exporter.flush(onDone) + } catch (error) { + // A synchronous flush failure must still release the worker, otherwise it hangs. + // Log rather than rethrow: an exception from this diagnostics-channel subscriber + // would surface as an uncaught error in the worker and crash the user's test run. + onDone?.() + log.error('Error flushing traces at Playwright worker shutdown', error) + } } }) diff --git a/packages/datadog-plugin-undici/src/index.js b/packages/datadog-plugin-undici/src/index.js index 90f50f077ed..fc380e17fd5 100644 --- a/packages/datadog-plugin-undici/src/index.js +++ b/packages/datadog-plugin-undici/src/index.js @@ -5,7 +5,7 @@ const { storage } = require('../../datadog-core') const tags = require('../../../ext/tags') const formats = require('../../../ext/formats') const HTTP_HEADERS = formats.HTTP_HEADERS -const log = require('../../dd-trace/src/log') +const { getClientStatusValidator } = require('../../dd-trace/src/plugins/util/status-validator') const { buildClientHttpUrl } = require('../../dd-trace/src/plugins/util/url') const { stripQueryAndFragment } = require('../../dd-trace/src/util') const { CLIENT_PORT_KEY } = require('../../dd-trace/src/constants') @@ -302,7 +302,7 @@ function normalizeHeaders (headers) { } function normalizeConfig (config) { - const validateStatus = getStatusValidator(config) + const validateStatus = getClientStatusValidator(config) const hooks = getHooks(config) return { @@ -312,19 +312,6 @@ function normalizeConfig (config) { } } -function getStatusValidator (config) { - if (typeof config.validateStatus === 'function') { - return config.validateStatus - } else if (Object.hasOwn(config, 'validateStatus')) { - log.error('Expected `validateStatus` to be a function.') - } - return defaultValidateStatus -} - -function defaultValidateStatus (code) { - return code < 400 || code >= 500 -} - function getHooks (config) { const request = config.hooks?.request ?? noop diff --git a/packages/datadog-plugin-undici/test/index.spec.js b/packages/datadog-plugin-undici/test/index.spec.js index 0bc2b6db0d6..2fa1cfc69a9 100644 --- a/packages/datadog-plugin-undici/test/index.spec.js +++ b/packages/datadog-plugin-undici/test/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { once } = require('node:events') const semver = require('semver') const satisfies = require('../../../vendor/dist/semifies') @@ -41,11 +42,16 @@ describe('Plugin', () => { describe('undici-fetch', () => { withVersions('undici', 'undici', NODE_MAJOR < 20 ? '<7.11.0' : '*', (version, moduleName, resolvedVersion) => { + /** + * @param {import('express').Application} app + * @param {(port: number) => void} [listener] + * @returns {import('node:http').Server} + */ function server (app, listener) { const server = require('http').createServer(app) - server.listen(0, 'localhost', () => listener( - (/** @type {import('net').AddressInfo} */ (server.address())).port) - ) + server.listen(0, 'localhost', () => { + listener?.((/** @type {import('net').AddressInfo} */ (server.address())).port) + }) return server } @@ -387,8 +393,9 @@ describe('Plugin', () => { // Tests for undici.request() using native diagnostic channels // Only run for undici >= 4.7.0 where diagnostic channels were added - if (semver.satisfies(resolvedVersion, '>=4.7.0')) { - it('should do automatic instrumentation for undici.request()', function (done) { + { + const requestTest = semver.satisfies(resolvedVersion, '>=4.7.0') ? it : it.skip + requestTest('should do automatic instrumentation for undici.request()', function (done) { const app = express() app.get('/user', (req, res) => { res.status(200).send('OK') @@ -417,7 +424,7 @@ describe('Plugin', () => { }) }) - it('should support POST requests with undici.request()', done => { + requestTest('should support POST requests with undici.request()', done => { const app = express() app.post('/user', (req, res) => { res.status(201).send('Created') @@ -440,7 +447,7 @@ describe('Plugin', () => { }) }) - it('should inject trace headers in undici.request()', done => { + requestTest('should inject trace headers in undici.request()', done => { const app = express() app.get('/user', (req, res) => { @@ -466,7 +473,7 @@ describe('Plugin', () => { }) }) - it('should handle connection errors in undici.request()', done => { + requestTest('should handle connection errors in undici.request()', done => { let error agent @@ -489,7 +496,7 @@ describe('Plugin', () => { }) }) - it('should record HTTP 4XX responses as errors in undici.request()', done => { + requestTest('should record HTTP 4XX responses as errors in undici.request()', done => { const app = express() app.get('/user', (req, res) => { @@ -510,7 +517,7 @@ describe('Plugin', () => { }) }) - it('should not record HTTP 5XX responses as errors in undici.request()', done => { + requestTest('should not record HTTP 5XX responses as errors in undici.request()', done => { const app = express() app.get('/user', (req, res) => { @@ -566,6 +573,62 @@ describe('Plugin', () => { }) }) }) + describe('with configured HTTP client error statuses', () => { + beforeEach(() => { + process.env.DD_TRACE_HTTP_CLIENT_ERROR_STATUSES = '200-201,202' + + return agent.load('undici', { service: 'test' }) + .then(() => { + express = require('express') + fetch = require(`../../../versions/undici@${version}`, {}).get() + }) + }) + + afterEach(() => { + express = null + delete process.env.DD_TRACE_HTTP_CLIENT_ERROR_STATUSES + }) + + it('should mark a configured status code as an error', done => { + const app = express() + + app.get('/user', (req, res) => { + res.status(200).send() + }) + + appListener = server(app, port => { + agent + .assertSomeTraces(traces => { + assert.strictEqual(traces[0][0].meta['http.status_code'], '200') + assert.strictEqual(traces[0][0].error, 1) + }) + .then(done) + .catch(done) + + fetch.fetch(`http://localhost:${port}/user`).catch(() => {}) + }) + }) + + it('should not mark a status code outside of the configured statuses as an error', done => { + const app = express() + + app.get('/user', (req, res) => { + res.status(500).send() + }) + + appListener = server(app, port => { + agent + .assertSomeTraces(traces => { + assert.strictEqual(traces[0][0].meta['http.status_code'], '500') + assert.strictEqual(traces[0][0].error, 0) + }) + .then(done) + .catch(done) + + fetch.fetch(`http://localhost:${port}/user`).catch(() => {}) + }) + }) + }) describe('with headers configuration', () => { let config @@ -732,10 +795,11 @@ describe('Plugin', () => { express = null }) - it('should preserve custom dispatcher option and trace the request', function (done) { + it('should preserve custom dispatcher option and trace the request', async function () { // Skip for versions that use fetch wrapping instead of native DC // Those versions have the dispatcher issue described in #6439 if (!satisfies(resolvedVersion, '>=4.7.0 <5.0.0 || >=5.1.0')) { + // These versions wrap fetch and cannot preserve a custom dispatcher. this.skip() return } @@ -745,33 +809,29 @@ describe('Plugin', () => { res.status(200).send('OK') }) - appListener = server(app, port => { - // Create a custom Agent with specific settings - // This is the use case from issue #6439 - const customAgent = new fetch.Agent({ - connect: { keepAlive: false }, - }) + appListener = server(app) + await once(appListener, 'listening') + const port = (/** @type {import('net').AddressInfo} */ (appListener.address())).port - agent - .assertFirstTraceSpan({ - service: 'test', - type: 'http', - resource: 'GET', - }) - .then(done) - .catch(done) + // Create a custom Agent with specific settings + // This is the use case from issue #6439 + const customAgent = new fetch.Agent({ + connect: { keepAlive: false }, + }) + const tracePromise = agent.assertFirstTraceSpan({ + service: 'test', + type: 'http', + resource: 'GET', + }) - // Make request with custom dispatcher - // For native DC versions, dispatcher is preserved because we don't wrap fetch at all - fetch.fetch(`http://localhost:${port}/user`, { - dispatcher: customAgent, - }).then(res => { - assert.strictEqual(res.status, 200) - return res.text() - }).then(body => { - assert.strictEqual(body, 'OK') - }).catch(done) + // Make request with custom dispatcher + // For native DC versions, dispatcher is preserved because we don't wrap fetch at all + const response = await fetch.fetch(`http://localhost:${port}/user`, { + dispatcher: customAgent, }) + assert.strictEqual(response.status, 200) + const [, body] = await Promise.all([tracePromise, response.text()]) + assert.strictEqual(body, 'OK') }) }) @@ -800,8 +860,9 @@ describe('Plugin', () => { // the tunnel-setup request, but never :headers/:trailers/:error. Before the fix the // CONNECT span was started and never finished, which kept the parent trace pinned in // span_processor and prevented the surrounding express.request span from exporting. - it('finishes the CONNECT tunnel span established via ProxyAgent', function (done) { + it('finishes the CONNECT tunnel span established via ProxyAgent', async function () { if (!satisfies(resolvedVersion, '>=5.1.0')) { + // ProxyAgent is only available from undici 5.1.0. this.skip() return } @@ -812,53 +873,53 @@ describe('Plugin', () => { const app = express() app.get('/data', (req, res) => res.status(200).send('OK')) - appListener = server(app, downstreamPort => { - const proxy = http.createServer((_req, res) => { - res.writeHead(405) - res.end() + appListener = server(app) + await once(appListener, 'listening') + const downstreamPort = (/** @type {import('net').AddressInfo} */ (appListener.address())).port + + const proxy = http.createServer((_req, res) => { + res.writeHead(405) + res.end() + }) + proxy.on('connect', (req, clientSocket, head) => { + const [hostname, portStr] = req.url.split(':') + const upstream = net.connect(Number.parseInt(portStr, 10) || 80, hostname, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + upstream.write(head) + upstream.pipe(clientSocket) + clientSocket.pipe(upstream) }) - proxy.on('connect', (req, clientSocket, head) => { - const [hostname, portStr] = req.url.split(':') - const upstream = net.connect(Number.parseInt(portStr, 10) || 80, hostname, () => { - clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n') - upstream.write(head) - upstream.pipe(clientSocket) - clientSocket.pipe(upstream) - }) - upstream.on('error', () => clientSocket.end()) - clientSocket.on('error', () => upstream.end()) + upstream.on('error', () => clientSocket.end()) + clientSocket.on('error', () => upstream.end()) + }) + proxy.listen(0, 'localhost') + await once(proxy, 'listening') + + proxyListener = proxy + const proxyPort = (/** @type {import('net').AddressInfo} */ (proxy.address())).port + const tracePromise = agent.assertSomeTraces(traces => { + const connectSpan = traces.flat().find(s => s.resource === 'CONNECT') + assert.ok(connectSpan, 'expected a finished CONNECT span to be exported') + assertObjectContains(connectSpan, { + name: 'undici.request', + service: 'test', + type: 'http', + resource: 'CONNECT', + meta: { 'http.method': 'CONNECT' }, }) - - proxy.listen(0, 'localhost', () => { - proxyListener = proxy - const proxyPort = (/** @type {import('net').AddressInfo} */ (proxy.address())).port - - agent - .assertSomeTraces(traces => { - const connectSpan = traces.flat().find(s => s.resource === 'CONNECT') - assert.ok(connectSpan, 'expected a finished CONNECT span to be exported') - assertObjectContains(connectSpan, { - name: 'undici.request', - service: 'test', - type: 'http', - resource: 'CONNECT', - meta: { 'http.method': 'CONNECT' }, - }) - }, { timeoutMs: 3000 }) - .then(done) - .catch(done) - - // proxyTunnel forces a CONNECT tunnel for the plain-HTTP-over-HTTP-proxy case. - // undici 8.7.0 (nodejs/undici#5116) made that case forward an absolute-form - // request instead of tunneling by default, so without this the proxy never sees - // a CONNECT and there is no CONNECT span to assert on. The option is a no-op on - // undici < 6.22.0, where CONNECT was always used. - const dispatcher = new fetch.ProxyAgent({ uri: `http://localhost:${proxyPort}`, proxyTunnel: true }) - fetch.request(`http://localhost:${downstreamPort}/data`, { dispatcher }) - .then(({ body }) => body.text()) - .catch(done) - }) - }) + }, { timeoutMs: 3000 }) + + // proxyTunnel forces a CONNECT tunnel for the plain-HTTP-over-HTTP-proxy case. + // undici 8.7.0 (nodejs/undici#5116) made that case forward an absolute-form + // request instead of tunneling by default, so without this the proxy never sees + // a CONNECT and there is no CONNECT span to assert on. The option is a no-op on + // undici < 6.22.0, where CONNECT was always used. + const dispatcher = new fetch.ProxyAgent({ + uri: `http://localhost:${proxyPort}`, + proxyTunnel: true, + }) + const { body } = await fetch.request(`http://localhost:${downstreamPort}/data`, { dispatcher }) + await Promise.all([body.text(), tracePromise]) }) }) }) diff --git a/packages/dd-trace/src/aiguard/evaluation.js b/packages/dd-trace/src/aiguard/evaluation.js index c4517a7c4d7..f3abbcf303d 100644 --- a/packages/dd-trace/src/aiguard/evaluation.js +++ b/packages/dd-trace/src/aiguard/evaluation.js @@ -8,11 +8,14 @@ const { keepTrace } = require('../priority_sampler') const { extractIp } = require('../plugins/util/ip_extractor') const { AI_GUARD } = require('../standalone/product') const telemetryMetrics = require('../telemetry/metrics') +const { normalizeRedactionReplacements, redactMessages } = require('./redaction') const TAGS = require('./tags') const ALLOW = 'ALLOW' +/** @typedef {import('../../../../index').aiguard.ContentPart} ContentPart */ /** @typedef {import('../../../../index').aiguard.Message} Message */ +/** @typedef {import('../../../../index').aiguard.RedactionReplacement} RedactionReplacement */ /** @typedef {import('../opentracing/span')} Span */ /** @@ -24,19 +27,22 @@ const ALLOW = 'ALLOW' * @property {Record} tagProbabilities * @property {boolean} hasTagProbabilities * @property {boolean} blockingEnabled + * @property {unknown} redactionReplacements */ /** * @typedef {object} EvaluationOutcome * @property {{ action: string, reason: string|undefined, tags: Array, - * tagProbabilities: Record, sds: Array }} result + * tagProbabilities: Record, sds: Array, messages: Message[], + * redactionReplacements: RedactionReplacement[] }} result * @property {boolean} shouldBlock * @property {boolean} hasTagProbabilities + * @property {{ enabled: boolean, applied: boolean, failures: number }} redaction */ /** * @typedef {object} EvaluationMetaStruct - * @property {Message[]} messages + * @property {Message[]} [messages] * @property {Array} [attack_categories] * @property {Array} [sds] * @property {Record} [tag_probs] @@ -45,6 +51,7 @@ const ALLOW = 'ALLOW' /** * @typedef {object} EvaluationReport * @property {Span} span + * @property {Message[]} messages * @property {EvaluationMetaStruct} metaStruct * @property {{ source: string, integration: string }} telemetryTags */ @@ -79,6 +86,7 @@ function parseEvaluationResponse (body) { tagProbabilities: isRecord(attributes.tag_probs) ? attributes.tag_probs : {}, hasTagProbabilities: isRecord(attributes.tag_probs), blockingEnabled: attributes.is_blocking_enabled === true, + redactionReplacements: attributes.redaction_replacements, } } @@ -96,11 +104,16 @@ function shouldBlockEvaluation (block, evaluation) { /** * Applies local evaluation policy and creates the result returned to the caller. * + * @param {Message[]} messages * @param {EvaluationResponse} evaluation - * @param {boolean} block + * @param {{ block: boolean, redactionEnabled: boolean }} options * @returns {EvaluationOutcome} */ -function createEvaluationOutcome (evaluation, block) { +function createEvaluationOutcome (messages, evaluation, options) { + const redaction = options.redactionEnabled + ? redactMessages(messages, evaluation.redactionReplacements) + : { messages, redacted: false, failures: 0 } + return { result: { action: evaluation.action, @@ -108,9 +121,16 @@ function createEvaluationOutcome (evaluation, block) { tags: evaluation.tags, tagProbabilities: evaluation.tagProbabilities, sds: evaluation.sdsFindings, + messages: redaction.messages, + redactionReplacements: normalizeRedactionReplacements(evaluation.redactionReplacements), }, - shouldBlock: shouldBlockEvaluation(block, evaluation), + shouldBlock: shouldBlockEvaluation(options.block, evaluation), hasTagProbabilities: evaluation.hasTagProbabilities, + redaction: { + enabled: options.redactionEnabled, + applied: redaction.redacted, + failures: redaction.failures, + }, } } @@ -130,6 +150,7 @@ class EvaluationReporter { #config #maxMessagesLength #maxContentSize + #redactionEnabled /** * @param {import('../config/config-base')} config @@ -138,6 +159,7 @@ class EvaluationReporter { this.#config = config this.#maxMessagesLength = config.experimental.aiguard.maxMessagesLength this.#maxContentSize = config.experimental.aiguard.maxContentSize + this.#redactionEnabled = config.experimental.aiguard.redactionEnabled } /** @@ -150,19 +172,18 @@ class EvaluationReporter { */ start (span, messages, options) { const telemetryTags = { source: options.source, integration: options.integration } - const last = messages.at(-1) + const evaluatedMessages = this.#redactionEnabled ? clone(messages) : messages + const last = evaluatedMessages.at(-1) const target = this.#isToolCall(last) ? 'tool' : 'prompt' span.setTag(TAGS.TARGET_TAG_KEY, target) if (target === 'tool') { - const name = this.#getToolName(last, messages) + const name = this.#getToolName(last, evaluatedMessages) if (name) { span.setTag(TAGS.TOOL_NAME_TAG_KEY, name) } } - const metaStruct = { - messages: this.#buildMessagesForMetaStruct(messages, telemetryTags), - } + const metaStruct = {} span.meta_struct = { [TAGS.META_STRUCT_KEY]: metaStruct, } @@ -178,6 +199,7 @@ class EvaluationReporter { return { span, + messages: evaluatedMessages, metaStruct, telemetryTags, } @@ -191,6 +213,7 @@ class EvaluationReporter { * @returns {void} */ fail (report, errorType) { + report.metaStruct.messages = this.#buildMessagesForMetaStruct(report.messages, report.telemetryTags) aiguardMetrics.count(TAGS.TELEMETRY_REQUESTS, { error: true, ...report.telemetryTags }).inc(1) aiguardMetrics.count(TAGS.TELEMETRY_ERROR, { type: errorType, ...report.telemetryTags }).inc(1) } @@ -203,18 +226,38 @@ class EvaluationReporter { * @returns {void} */ finish (report, outcome) { - const { result, shouldBlock } = outcome + const { result, redaction, shouldBlock } = outcome if (result.tags.length > 0) report.metaStruct.attack_categories = result.tags if (result.sds.length > 0) report.metaStruct.sds = result.sds if (outcome.hasTagProbabilities) report.metaStruct.tag_probs = result.tagProbabilities - const requestTelemetryTags = { - action: result.action, - error: false, - block: shouldBlock, - ...report.telemetryTags, + if (redaction.enabled) { + report.span.setTag(TAGS.REDACTED_TAG_KEY, redaction.applied ? 'true' : 'false') + if (redaction.failures > 0) { + aiguardMetrics.count(TAGS.TELEMETRY_ERROR, { + type: TAGS.ERROR_TYPE_REDACTION, + ...report.telemetryTags, + }).inc(redaction.failures) + } } + + report.metaStruct.messages = this.#buildMessagesForMetaStruct(result.messages, report.telemetryTags) + + const requestTelemetryTags = redaction.enabled + ? { + action: result.action, + error: false, + block: shouldBlock, + redacted: redaction.applied, + ...report.telemetryTags, + } + : { + action: result.action, + error: false, + block: shouldBlock, + ...report.telemetryTags, + } aiguardMetrics.count(TAGS.TELEMETRY_REQUESTS, requestTelemetryTags).inc(1) report.span.setTag(TAGS.ACTION_TAG_KEY, result.action) @@ -242,10 +285,7 @@ class EvaluationReporter { let contentTruncated = false for (let i = messages.length - size; i < messages.length; i++) { const message = clone(messages[i]) - if (message.content?.length > this.#maxContentSize) { - contentTruncated = true - message.content = message.content.slice(0, this.#maxContentSize) - } + if (this.#truncateMessageContent(message)) contentTruncated = true result.push(message) } if (contentTruncated) { @@ -254,6 +294,40 @@ class EvaluationReporter { return result } + /** + * Truncates text in a cloned message to one shared content-size limit. + * + * @param {{ content?: string|ContentPart[] }} message + * @returns {boolean} + */ + #truncateMessageContent (message) { + const { content } = message + if (typeof content === 'string') { + if (content.length <= this.#maxContentSize) return false + + message.content = content.slice(0, this.#maxContentSize) + return true + } + + if (!Array.isArray(content)) return false + + let remainingContentSize = this.#maxContentSize + let truncated = false + for (const part of content) { + const text = part?.text + if (typeof text !== 'string') continue + + if (text.length > remainingContentSize) { + part.text = text.slice(0, remainingContentSize) + truncated = true + remainingContentSize = 0 + } else { + remainingContentSize -= text.length + } + } + return truncated + } + /** * Returns whether a message represents a tool call or tool output. * diff --git a/packages/dd-trace/src/aiguard/integrations/vercel-ai.js b/packages/dd-trace/src/aiguard/integrations/vercel-ai.js index 3d950320ccc..b1458a14101 100644 --- a/packages/dd-trace/src/aiguard/integrations/vercel-ai.js +++ b/packages/dd-trace/src/aiguard/integrations/vercel-ai.js @@ -74,18 +74,17 @@ function onStreamAfter (ctx) { */ function getStreamContent (chunks) { const toolCalls = [] - const textParts = [] + let text = '' for (const chunk of chunks) { if (chunk?.type === 'tool-call') { toolCalls.push(chunk) } else if (chunk?.type === 'text-delta') { - textParts.push(chunk.textDelta) + text += chunk.delta ?? chunk.textDelta ?? '' } } if (toolCalls.length) return toolCalls - const text = textParts.join('') return text ? [{ type: 'text', text }] : [] } diff --git a/packages/dd-trace/src/aiguard/messages/anthropic.js b/packages/dd-trace/src/aiguard/messages/anthropic.js index 526bfe381f1..6f153dccd46 100644 --- a/packages/dd-trace/src/aiguard/messages/anthropic.js +++ b/packages/dd-trace/src/aiguard/messages/anthropic.js @@ -269,27 +269,39 @@ function convertServerToolResultContent (content) { return content.error_message ? `${content.error_code}: ${content.error_message}` : content.error_code } - const lines = [] + let lines if (Array.isArray(content)) { for (const item of content) { if (!item || typeof item !== 'object') continue // text blocks (MCP / generic), then web-search title + url. - if (typeof item.text === 'string') lines.push(item.text) - if (typeof item.title === 'string') lines.push(item.title) - if (typeof item.url === 'string') lines.push(item.url) + if (typeof item.text === 'string') { + lines = lines === undefined ? item.text : `${lines}\n${item.text}` + } + if (typeof item.title === 'string') { + lines = lines === undefined ? item.title : `${lines}\n${item.title}` + } + if (typeof item.url === 'string') { + lines = lines === undefined ? item.url : `${lines}\n${item.url}` + } } } else { - if (typeof content.stdout === 'string' && content.stdout) lines.push(content.stdout) - if (typeof content.stderr === 'string' && content.stderr) lines.push(content.stderr) - if (typeof content.content === 'string' && content.content) lines.push(content.content) + if (typeof content.stdout === 'string' && content.stdout) lines = content.stdout + if (typeof content.stderr === 'string' && content.stderr) { + lines = lines === undefined ? content.stderr : `${lines}\n${content.stderr}` + } + if (typeof content.content === 'string' && content.content) { + lines = lines === undefined ? content.content : `${lines}\n${content.content}` + } if (Array.isArray(content.lines)) { for (const line of content.lines) { - if (typeof line === 'string') lines.push(line) + if (typeof line === 'string') { + lines = lines === undefined ? line : `${lines}\n${line}` + } } } } - return lines.join('\n') || '[tool result]' + return lines || '[tool result]' } /** diff --git a/packages/dd-trace/src/aiguard/noop.js b/packages/dd-trace/src/aiguard/noop.js index c49bb983b68..cd8eb302010 100644 --- a/packages/dd-trace/src/aiguard/noop.js +++ b/packages/dd-trace/src/aiguard/noop.js @@ -2,7 +2,15 @@ class NoopAIGuard { evaluate (messages, opts) { - return Promise.resolve({ action: 'ALLOW', reason: 'AI Guard is not enabled', tags: [], sds: [] }) + return Promise.resolve({ + action: 'ALLOW', + reason: 'AI Guard is not enabled', + tags: [], + tagProbabilities: {}, + sds: [], + messages, + redactionReplacements: [], + }) } } diff --git a/packages/dd-trace/src/aiguard/redaction.js b/packages/dd-trace/src/aiguard/redaction.js new file mode 100644 index 00000000000..51d92019a88 --- /dev/null +++ b/packages/dd-trace/src/aiguard/redaction.js @@ -0,0 +1,183 @@ +'use strict' + +/** @typedef {import('../../../../index').aiguard.RedactionReplacement} RedactionReplacement */ + +const SEGMENT_PATTERN = /^([A-Za-z0-9_]+)(?:\[([0-9]+)\])?$/ + +/** + * Converts the raw backend replacements into the public, typed contract. + * + * @param {unknown} replacements + * @returns {RedactionReplacement[]} + */ +function normalizeRedactionReplacements (replacements) { + if (!Array.isArray(replacements)) return [] + + const result = [] + for (const entry of replacements) { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue + + const { path, replacement } = entry + if (typeof path !== 'string' || path.length === 0 || typeof replacement !== 'string') continue + + result.push({ path, replacement }) + } + return result +} + +/** + * Parses an AI Guard location path into named segments and optional indexes. + * + * @param {string} path + * @returns {Array<{ name: string, index: number|undefined }>|undefined} + */ +function parsePath (path) { + const segments = [] + for (const rawSegment of path.split('.')) { + const match = SEGMENT_PATTERN.exec(rawSegment) + if (!match) return + + segments.push({ + name: match[1], + index: match[2] === undefined ? undefined : Number(match[2]), + }) + } + return segments +} + +/** + * Reports whether a parsed path targets an allowed message string. + * + * @param {Array<{ name: string, index: number|undefined }>} segments + * @returns {boolean} + */ +function isRedactablePath (segments) { + const first = segments[0] + if (first?.name !== 'messages' || first.index === undefined) return false + + if (segments.length === 2) { + const content = segments[1] + return content.name === 'content' && content.index === undefined + } + + if (segments.length === 3) { + const content = segments[1] + const text = segments[2] + return content.name === 'content' && content.index !== undefined && + text.name === 'text' && text.index === undefined + } + + if (segments.length === 4) { + const toolCalls = segments[1] + const functionSegment = segments[2] + const argumentsSegment = segments[3] + return toolCalls.name === 'tool_calls' && toolCalls.index !== undefined && + functionSegment.name === 'function' && functionSegment.index === undefined && + argumentsSegment.name === 'arguments' && argumentsSegment.index === undefined + } + + return false +} + +/** + * Resolves a location path to a writable string container and key. + * + * @param {{ messages: Array }} root + * @param {string} path + * @returns {{ container: object|Array, key: string|number }|undefined} + */ +function resolveWritableString (root, path) { + const segments = parsePath(path) + if (!segments || !isRedactablePath(segments)) return + + const terminal = segments.at(-1) + let node = root + for (let i = 0; i < segments.length - 1; i++) { + const { name, index } = segments[i] + if (!node || typeof node !== 'object' || !Object.hasOwn(node, name)) return + + node = node[name] + if (index !== undefined) { + if (!Array.isArray(node) || index >= node.length) return + node = node[index] + } + } + + if (!node || typeof node !== 'object') return + if (!Object.hasOwn(node, terminal.name) || typeof node[terminal.name] !== 'string') return + return { container: node, key: terminal.name } +} + +/** + * Applies redaction replacements to an AI Guard message list. + * The caller transfers ownership of a private snapshot. Successful replacements mutate that snapshot in place. + * + * @param {Array} messages + * @param {unknown} replacements + * @returns {{ messages: Array, redacted: boolean, failures: number }} + */ +function redactMessages (messages, replacements) { + if (!Array.isArray(messages) || replacements === undefined || replacements === null) { + return { messages, redacted: false, failures: 0 } + } + + try { + if (!Array.isArray(replacements)) { + return { messages, redacted: false, failures: 1 } + } + if (replacements.length === 0) return { messages, redacted: false, failures: 0 } + + const replacementsByPath = new Map() + const conflictingPaths = new Set() + let failures = 0 + for (const entry of replacements) { + if (!entry || typeof entry !== 'object') { + failures++ + continue + } + + const { path, replacement } = entry + if (typeof path !== 'string' || path.length === 0 || typeof replacement !== 'string') { + failures++ + continue + } + + if (conflictingPaths.has(path)) continue + if (replacementsByPath.has(path) && replacementsByPath.get(path) !== replacement) { + replacementsByPath.delete(path) + conflictingPaths.add(path) + failures++ + continue + } + replacementsByPath.set(path, replacement) + } + + if (replacementsByPath.size === 0) return { messages, redacted: false, failures } + + const root = { messages } + const targets = [] + + for (const [path, replacement] of replacementsByPath) { + const resolved = resolveWritableString(root, path) + if (!resolved) { + failures++ + continue + } + + targets.push({ ...resolved, replacement }) + } + + for (const { container, key, replacement } of targets) { + container[key] = replacement + } + + return { messages, redacted: targets.length > 0, failures } + } catch { + return { messages, redacted: false, failures: 1 } + } +} + +module.exports = { + normalizeRedactionReplacements, + redactMessages, +} diff --git a/packages/dd-trace/src/aiguard/sdk.js b/packages/dd-trace/src/aiguard/sdk.js index a93aad40ac8..b7f03bf3f2c 100644 --- a/packages/dd-trace/src/aiguard/sdk.js +++ b/packages/dd-trace/src/aiguard/sdk.js @@ -12,6 +12,7 @@ class AIGuard extends NoopAIGuard { #tracer #client #reporter + #redactionEnabled #meta /** @@ -29,6 +30,7 @@ class AIGuard extends NoopAIGuard { this.#tracer = tracer this.#client = new AIGuardClient(config) this.#reporter = new EvaluationReporter(config) + this.#redactionEnabled = config.experimental.aiguard.redactionEnabled this.#meta = { service: config.service, env: config.env } this.#initialized = true } @@ -45,13 +47,16 @@ class AIGuard extends NoopAIGuard { const report = this.#reporter.start(span, messages, { source, integration }) let evaluation try { - evaluation = await this.#client.evaluate(messages, this.#meta) + evaluation = await this.#client.evaluate(report.messages, this.#meta) } catch (error) { this.#reporter.fail(report, error.telemetryType ?? TAGS.ERROR_TYPE_CLIENT) throw error } - const outcome = createEvaluationOutcome(evaluation, block) + const outcome = createEvaluationOutcome(report.messages, evaluation, { + block, + redactionEnabled: this.#redactionEnabled, + }) this.#reporter.finish(report, outcome) if (outcome.shouldBlock) { diff --git a/packages/dd-trace/src/aiguard/tags.js b/packages/dd-trace/src/aiguard/tags.js index dfa1e87de4d..78388fe93d5 100644 --- a/packages/dd-trace/src/aiguard/tags.js +++ b/packages/dd-trace/src/aiguard/tags.js @@ -7,6 +7,7 @@ module.exports = { ACTION_TAG_KEY: 'ai_guard.action', REASON_TAG_KEY: 'ai_guard.reason', BLOCKED_TAG_KEY: 'ai_guard.blocked', + REDACTED_TAG_KEY: 'ai_guard.redacted', EVENT_TAG_KEY: 'ai_guard.event', META_STRUCT_KEY: 'ai_guard', @@ -27,4 +28,5 @@ module.exports = { ERROR_TYPE_CLIENT: 'client_error', ERROR_TYPE_STATUS: 'bad_status', ERROR_TYPE_RESPONSE: 'bad_response', + ERROR_TYPE_REDACTION: 'redaction_error', } 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 b0535ffe3e5..f400ed363b4 100644 --- a/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js +++ b/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js @@ -108,7 +108,7 @@ function stringifyWithRanges (obj, objRanges, loadSensitiveRanges = false) { value = JSON.stringify(cloneObj, null, 2) if (counter > 0) { - const segments = [] + let formattedValue = '' let outputLength = 0 let pos = 0 let rangeKeyIndex = value.indexOf(STRINGIFY_RANGE_KEY) @@ -133,13 +133,14 @@ function stringifyWithRanges (obj, objRanges, loadSensitiveRanges = false) { start, end: start + originalValue.length, }) - segments.push(value.slice(pos, rangeKeyIndex), originalValue) + formattedValue += value.slice(pos, rangeKeyIndex) + formattedValue += originalValue outputLength += cleanLength + originalValue.length pos = rangeKeyIndex + matchValue.length } else { // can't happen, the only way to this to happen is // if the JSON has a value starting with the value of STRINGIFY_SENSITIVE_NOT_STRING_KEY - segments.push(value.slice(pos, rangeKeyIndex + STRINGIFY_SENSITIVE_NOT_STRING_KEY.length + 1)) + formattedValue += value.slice(pos, rangeKeyIndex + STRINGIFY_SENSITIVE_NOT_STRING_KEY.length + 1) outputLength += cleanLength + STRINGIFY_SENSITIVE_NOT_STRING_KEY.length + 1 pos = rangeKeyIndex + STRINGIFY_SENSITIVE_NOT_STRING_KEY.length + 1 } @@ -152,13 +153,13 @@ function stringifyWithRanges (obj, objRanges, loadSensitiveRanges = false) { start, end: start + Number.parseInt(regexRes[1], 10), }) - segments.push(value.slice(pos, rangeKeyIndex)) + formattedValue += value.slice(pos, rangeKeyIndex) outputLength += cleanLength pos = rangeKeyIndex + regexRes[0].length } else { // can't happen, the only way to this to happen is // if the JSON has a value starting with the value of STRINGIFY_SENSITIVE_KEY - segments.push(value.slice(pos, rangeKeyIndex + STRINGIFY_SENSITIVE_KEY.length)) + formattedValue += value.slice(pos, rangeKeyIndex + STRINGIFY_SENSITIVE_KEY.length) outputLength += cleanLength + STRINGIFY_SENSITIVE_KEY.length pos = rangeKeyIndex + STRINGIFY_SENSITIVE_KEY.length } @@ -175,13 +176,13 @@ function stringifyWithRanges (obj, objRanges, loadSensitiveRanges = false) { })) ranges.push(...updatedRanges) - segments.push(value.slice(pos, rangeKeyIndex)) + formattedValue += value.slice(pos, rangeKeyIndex) outputLength += cleanLength pos = rangeKeyIndex + regexRes[0].length } else { // can't happen, the only way to this to happen is // if the JSON has a value starting with the value of STRINGIFY_RANGE_KEY - segments.push(value.slice(pos, rangeKeyIndex + STRINGIFY_RANGE_KEY.length)) + formattedValue += value.slice(pos, rangeKeyIndex + STRINGIFY_RANGE_KEY.length) outputLength += cleanLength + STRINGIFY_RANGE_KEY.length pos = rangeKeyIndex + STRINGIFY_RANGE_KEY.length } @@ -190,8 +191,8 @@ function stringifyWithRanges (obj, objRanges, loadSensitiveRanges = false) { rangeKeyIndex = value.indexOf(STRINGIFY_RANGE_KEY, pos) } - segments.push(value.slice(pos)) - value = segments.join('') + formattedValue += value.slice(pos) + value = formattedValue } } else { value = JSON.stringify(obj, null, 2) diff --git a/packages/dd-trace/src/appsec/rule_manager.js b/packages/dd-trace/src/appsec/rule_manager.js index 8830af69062..2e80cdcc319 100644 --- a/packages/dd-trace/src/appsec/rule_manager.js +++ b/packages/dd-trace/src/appsec/rule_manager.js @@ -82,6 +82,8 @@ function updateWafFromRC (transaction) { const asmFile = /** @type {AsmConfigFile} */ (item.file) if (asmFile?.actions?.length) { newActions.set(item.id, asmFile.actions) + } else { + newActions.delete(item.id) } } } catch (e) { diff --git a/packages/dd-trace/src/ci-visibility/exporters/agentless/request-tracker.js b/packages/dd-trace/src/ci-visibility/exporters/agentless/request-tracker.js index a9b2d571c4a..c64c2538cb3 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/agentless/request-tracker.js +++ b/packages/dd-trace/src/ci-visibility/exporters/agentless/request-tracker.js @@ -1,22 +1,21 @@ 'use strict' const BaseWriter = require('../../../exporters/common/writer') +const { createFinalFlushTimeoutError } = require('../../final-flush') -const FINAL_FLUSH_TIMEOUT_CODE = 'ERR_DD_TEST_OPTIMIZATION_FLUSH_TIMEOUT' - -class TestOptimizationRequestTracker { - #writer +class FinalFlushRequestTracker { + #flush #pendingRequests = new Set() #finalFlushes = new Set() #activeFinalFlush /** - * Creates request tracking for a Test Optimization writer. + * Creates request tracking for a Test Optimization exporter. * - * @param {BaseWriter} writer + * @param {(done?: (error?: Error) => void, options?: { deadline?: number }) => void} flush */ - constructor (writer) { - this.#writer = writer + constructor (flush) { + this.#flush = flush } /** @@ -30,7 +29,7 @@ class TestOptimizationRequestTracker { */ flush (done, options) { if (options?.deadline === undefined) { - BaseWriter.prototype.flush.call(this.#writer, done, options) + this.#flush(done, options) return } @@ -50,8 +49,7 @@ class TestOptimizationRequestTracker { const remaining = Math.max(0, options.deadline - Date.now()) finalFlush.timeoutId = setTimeout(() => { - const error = new Error('Timed out flushing Test Optimization data') - error.code = FINAL_FLUSH_TIMEOUT_CODE + const error = createFinalFlushTimeoutError() finalFlush.error ||= error finalFlush.writerDone = true @@ -72,7 +70,7 @@ class TestOptimizationRequestTracker { const previousFinalFlush = this.#activeFinalFlush this.#activeFinalFlush = finalFlush try { - BaseWriter.prototype.flush.call(this.#writer, (error) => { + this.#flush((error) => { finalFlush.error ||= error finalFlush.writerDone = true this.#finishFinalFlush(finalFlush) @@ -100,22 +98,37 @@ class TestOptimizationRequestTracker { this.#pendingRequests.add(pendingRequest) if (this.#activeFinalFlush) this.#attachRequest(this.#activeFinalFlush, pendingRequest) - request(data, requestOptions, (error, result, statusCode, headers) => { - if (error) { - for (const finalFlush of pendingRequest.finalFlushes) finalFlush.error ||= error - } + try { + request(data, requestOptions, (error, result, statusCode, headers) => { + if (error) { + for (const finalFlush of pendingRequest.finalFlushes) finalFlush.error ||= error + } - try { - callback(error, result, statusCode, headers) - } finally { - this.#pendingRequests.delete(pendingRequest) - for (const finalFlush of pendingRequest.finalFlushes) { - finalFlush.requests.delete(pendingRequest) - this.#finishFinalFlush(finalFlush) + try { + callback(error, result, statusCode, headers) + } finally { + this.#dropRequest(pendingRequest) } - pendingRequest.finalFlushes.clear() - } - }) + }) + } catch (error) { + this.#dropRequest(pendingRequest) + throw error + } + } + + /** + * Stops tracking a settled request and releases final flushes waiting for it. + * + * @param {object} pendingRequest + * @returns {void} + */ + #dropRequest (pendingRequest) { + this.#pendingRequests.delete(pendingRequest) + for (const finalFlush of pendingRequest.finalFlushes) { + finalFlush.requests.delete(pendingRequest) + this.#finishFinalFlush(finalFlush) + } + pendingRequest.finalFlushes.clear() } /** @@ -161,4 +174,16 @@ class TestOptimizationRequestTracker { } } +class TestOptimizationRequestTracker extends FinalFlushRequestTracker { + /** + * Creates request tracking for a Test Optimization writer. + * + * @param {BaseWriter} writer + */ + constructor (writer) { + super((done, options) => BaseWriter.prototype.flushDirect.call(writer, done, options)) + } +} + module.exports = TestOptimizationRequestTracker +module.exports.FinalFlushRequestTracker = FinalFlushRequestTracker 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 dde3b47b093..30950ea4ed8 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 @@ -22,6 +22,11 @@ const { getSegment } = require('../../util') const BufferingExporter = require('../../exporters/common/buffering-exporter') const { GIT_REPOSITORY_URL, GIT_COMMIT_SHA } = require('../../plugins/util/tags') const { TEST_STATUS } = require('../../plugins/util/test') +const { + createFinalFlushTimeoutError, + FINAL_FLUSH_FALLBACK_DELAY, + FINAL_FLUSH_TIMEOUT, +} = require('../final-flush') const { sendGitMetadata: sendGitMetadataRequest } = require('./git/git_metadata') const hostname = getHostname() @@ -60,17 +65,6 @@ function getIsTestSessionTrace (trace) { const GIT_UPLOAD_TIMEOUT = 60_000 // 60 seconds const CAN_USE_CI_VIS_PROTOCOL_TIMEOUT = GIT_UPLOAD_TIMEOUT const MAX_COVERAGE_REPORT_FLAGS = 32 -const FINAL_FLUSH_TIMEOUT = 10_000 -const FINAL_FLUSH_FALLBACK_DELAY = 100 - -/** - * @returns {Error} - */ -function createFinalFlushTimeoutError () { - const error = new Error('Timed out waiting for Test Optimization to flush') - error.code = 'ERR_DD_TEST_OPTIMIZATION_FLUSH_TIMEOUT' - return error -} function appendLogTag (tags, key, value) { if (value !== undefined) { diff --git a/packages/dd-trace/src/ci-visibility/final-flush.js b/packages/dd-trace/src/ci-visibility/final-flush.js new file mode 100644 index 00000000000..c1b63347c74 --- /dev/null +++ b/packages/dd-trace/src/ci-visibility/final-flush.js @@ -0,0 +1,20 @@ +'use strict' + +const FINAL_FLUSH_TIMEOUT = 10_000 +const FINAL_FLUSH_FALLBACK_DELAY = 100 +const FINAL_FLUSH_TIMEOUT_CODE = 'ERR_DD_TEST_OPTIMIZATION_FLUSH_TIMEOUT' + +/** + * @returns {Error & { code: string }} + */ +function createFinalFlushTimeoutError () { + const error = new Error('Timed out waiting for Test Optimization to flush') + error.code = FINAL_FLUSH_TIMEOUT_CODE + return error +} + +module.exports = { + createFinalFlushTimeoutError, + FINAL_FLUSH_FALLBACK_DELAY, + FINAL_FLUSH_TIMEOUT, +} diff --git a/packages/dd-trace/src/ci-visibility/log-submission/log-submission-plugin.js b/packages/dd-trace/src/ci-visibility/log-submission/log-submission-plugin.js index 86695d0ebb4..2d5a4307368 100644 --- a/packages/dd-trace/src/ci-visibility/log-submission/log-submission-plugin.js +++ b/packages/dd-trace/src/ci-visibility/log-submission/log-submission-plugin.js @@ -1,53 +1,220 @@ 'use strict' -const Plugin = require('../../plugins/plugin') +const { Writable } = require('node:stream') + +const request = require('../../exporters/common/request') const log = require('../../log') +const Plugin = require('../../plugins/plugin') +const { FinalFlushRequestTracker } = require('../exporters/agentless/request-tracker') +const { FINAL_FLUSH_TIMEOUT } = require('../final-flush') -function getWinstonLogSubmissionParameters (config) { - const { site, service, DD_API_KEY, DD_AGENTLESS_LOG_SUBMISSION_URL } = config +const MAX_BATCH_BYTES = 5 * 1024 * 1024 +const MAX_BATCH_LOGS = 1000 +const BATCH_FLUSH_INTERVAL = 1000 - const defaultParameters = { - host: `http-intake.logs.${site}`, - path: `/api/v2/logs?ddsource=winston&service=${service}`, - ssl: true, - headers: { - 'DD-API-KEY': DD_API_KEY, - }, - } +/** + * @param {import('../../config/config-base')} config + * @returns {URL | undefined} + */ +function getLogSubmissionUrl (config) { + if (config.DD_AGENTLESS_LOG_SUBMISSION_URL) { + try { + const url = new URL(config.DD_AGENTLESS_LOG_SUBMISSION_URL) + if (url.protocol === 'http:' || url.protocol === 'https:') return url - if (!DD_AGENTLESS_LOG_SUBMISSION_URL) { - return defaultParameters + log.error('Unsupported automatic log submission URL protocol: %s', url.protocol) + } catch { + log.error('Could not parse DD_AGENTLESS_LOG_SUBMISSION_URL') + } + return } + const hostname = `http-intake.logs.${config.site}`.toLowerCase() try { - const url = new URL(DD_AGENTLESS_LOG_SUBMISSION_URL) - return { - host: url.hostname, - port: url.port, - ssl: url.protocol === 'https:', - path: defaultParameters.path, - headers: defaultParameters.headers, - } - } catch { - log.error('Could not parse DD_AGENTLESS_LOG_SUBMISSION_URL') - return defaultParameters - } + const url = new URL(`https://${hostname}`) + if (url.hostname === hostname) return url + } catch {} + + log.error('Could not parse automatic log submission site: %s', config.site) +} + +/** + * @param {import('../../config/config-base')} config + * @param {string} source + * @returns {string} + */ +function getLogSubmissionPath (config, source) { + return `/api/v2/logs?${new URLSearchParams({ ddsource: source, service: config.service })}` } class LogSubmissionPlugin extends Plugin { static id = 'log-submission' + /** @type {string[]} */ + #batch = [] + #batchBytes = 2 + #batchSource + #logSubmissionUrl + #requestTracker + #timer + #beforeExitHandler = () => this.#flushLogs() + #createWinstonJsonFormat + #winstonStreamClass + // Winston formats records inside its transports, not at logger.write time, so (unlike Bunyan/Pino) + // the instrumentation can't publish a post-format line. A Stream transport pipes format.json() + // output through this Writable into the shared sender, reusing Winston's own cycle-safe serializer. + #winstonOutput = new Writable({ + decodeStrings: false, + write: (message, encoding, callback) => { + this.#enqueueLog({ source: 'winston', message }) + callback() + }, + }) + constructor (...args) { super(...args) + this.#requestTracker = new FinalFlushRequestTracker((done) => { + this.#flushLogs() + done?.() + }) + + // The main-module hook (configure) and the logger.js hook (add-transport) can fire in either + // order depending on how Winston is required, so buffer loggers that arrive before configure. + const pendingWinstonLoggers = new Set() + const addWinstonTransport = (logger) => { + if (!this.#winstonStreamClass || !this.#createWinstonJsonFormat) { + pendingWinstonLoggers.add(logger) + return + } + + logger.add(new this.#winstonStreamClass({ + format: this.#createWinstonJsonFormat(), + stream: this.#winstonOutput, + })) + } + + this.addSub('ci:log-submission:winston:configure', ({ StreamTransport, createJsonFormat }) => { + this.#winstonStreamClass = StreamTransport + this.#createWinstonJsonFormat = createJsonFormat - this.addSub('ci:log-submission:winston:configure', (httpClass) => { - this.HttpClass = httpClass + for (const logger of pendingWinstonLoggers) { + addWinstonTransport(logger) + } + pendingWinstonLoggers.clear() }) - this.addSub('ci:log-submission:winston:add-transport', (logger) => { - logger.add(new this.HttpClass(getWinstonLogSubmissionParameters(this.config))) + this.addSub('ci:log-submission:winston:add-transport', addWinstonTransport) + + this.addSub('ci:log-submission:log', (payload) => { + this.#enqueueLog(payload) + }) + this.addSub('ci:log-submission:flush', ({ onDone } = {}) => { + if (!onDone) { + this.#flushLogs() + return + } + + this.#requestTracker.flush(onDone, { + deadline: Date.now() + FINAL_FLUSH_TIMEOUT, + }) }) } + + /** + * @param {boolean | (Record & { enabled: boolean })} config + * @returns {void} + */ + configure (config) { + if (this._enabled) this.#flushLogs() + + const isEnabled = typeof config === 'boolean' ? config : config.enabled + this.#logSubmissionUrl = isEnabled && typeof config !== 'boolean' + ? getLogSubmissionUrl(config) + : undefined + super.configure(config) + + const beforeExitHandlers = globalThis[Symbol.for('dd-trace')].beforeExitHandlers + if (this._enabled) { + beforeExitHandlers.add(this.#beforeExitHandler) + } else { + beforeExitHandlers.delete(this.#beforeExitHandler) + } + } + + /** + * @param {{ source: string, message: string | Record }} payload + * @returns {void} + */ + #enqueueLog ({ source, message }) { + if (!this.#logSubmissionUrl) return + + let serializedMessage + try { + serializedMessage = typeof message === 'string' ? message : JSON.stringify(message) + } catch (error) { + log.error('Could not serialize %s log for automatic submission', source, error) + return + } + if (serializedMessage === undefined) return + + const messageBytes = Buffer.byteLength(serializedMessage) + if (messageBytes + 2 > MAX_BATCH_BYTES) { + log.error('Could not submit %s log because it exceeds the %d byte payload limit', source, MAX_BATCH_BYTES) + return + } + + if (this.#batch.length > 0 && + (this.#batchSource !== source || this.#batchBytes + messageBytes + 1 > MAX_BATCH_BYTES)) { + this.#flushLogs() + if (!this.#logSubmissionUrl) return + } + + this.#batchSource = source + if (this.#batch.length > 0) this.#batchBytes++ + this.#batch.push(serializedMessage) + this.#batchBytes += messageBytes + + if (this.#batch.length === MAX_BATCH_LOGS || this.#batchBytes === MAX_BATCH_BYTES) { + this.#flushLogs() + } else if (this.#timer === undefined) { + this.#timer = setTimeout(() => this.#flushLogs(), BATCH_FLUSH_INTERVAL) + this.#timer.unref?.() + } + } + + /** + * @returns {void} + */ + #flushLogs () { + clearTimeout(this.#timer) + this.#timer = undefined + + if (this.#batch.length === 0 || !this.#logSubmissionUrl) return + + const source = this.#batchSource + const data = `[${this.#batch.join(',')}]` + this.#batch = [] + this.#batchBytes = 2 + this.#batchSource = undefined + const options = { + path: getLogSubmissionPath(this.config, source), + method: 'POST', + headers: { + 'DD-API-KEY': this.config.DD_API_KEY, + 'Content-Type': 'application/json', + }, + url: this.#logSubmissionUrl, + } + + try { + this.#requestTracker.send(request, data, options, error => { + if (error) log.error('Error submitting %s logs', source, error) + }) + } catch (error) { + this.#logSubmissionUrl = undefined + log.error('Error submitting %s logs', source, error) + } + } } module.exports = LogSubmissionPlugin diff --git a/packages/dd-trace/src/config/generated-config-types.d.ts b/packages/dd-trace/src/config/generated-config-types.d.ts index c888617043f..584ca5778f1 100644 --- a/packages/dd-trace/src/config/generated-config-types.d.ts +++ b/packages/dd-trace/src/config/generated-config-types.d.ts @@ -272,6 +272,7 @@ export interface GeneratedConfig { DD_TRACE_HAPI_ENABLED: boolean; DD_TRACE_HAPI_HAPI_ENABLED: boolean; DD_TRACE_HONO_ENABLED: boolean; + DD_TRACE_HTTP_CLIENT_ERROR_STATUSES: string; DD_TRACE_HTTP_ENABLED: boolean; DD_TRACE_HTTP_SERVER_ERROR_STATUSES: string; DD_TRACE_HTTP2_ENABLED: boolean; @@ -418,6 +419,7 @@ export interface GeneratedConfig { endpoint: string | undefined; maxContentSize: number; maxMessagesLength: number; + redactionEnabled: boolean; timeout: number; }; appsec: { @@ -473,6 +475,7 @@ export interface GeneratedConfig { agentlessEnabled: boolean | undefined; DD_LLMOBS_ENABLED: boolean; mlApp: string | undefined; + projectName: string | undefined; sampleRate: number; }; logInjection: boolean; @@ -614,6 +617,7 @@ export interface GeneratedEnvVarConfig { DD_AI_GUARD_ENDPOINT: string | undefined; DD_AI_GUARD_MAX_CONTENT_SIZE: number; DD_AI_GUARD_MAX_MESSAGES_LENGTH: number; + DD_AI_GUARD_REDACTION_ENABLED: boolean; DD_AI_GUARD_TIMEOUT: number; DD_API_KEY: string | undefined; DD_API_SECURITY_DOWNSTREAM_BODY_ANALYSIS_SAMPLE_RATE: number; @@ -758,6 +762,7 @@ export interface GeneratedEnvVarConfig { DD_LLMOBS_AGENTLESS_ENABLED: boolean | undefined; DD_LLMOBS_ENABLED: boolean; DD_LLMOBS_ML_APP: string | undefined; + DD_LLMOBS_PROJECT_NAME: string | undefined; DD_LLMOBS_SAMPLE_RATE: number; DD_LOG_LEVEL: "debug" | "info" | "warn" | "error"; DD_LOGS_INJECTION: boolean; @@ -970,6 +975,7 @@ export interface GeneratedEnvVarConfig { DD_TRACE_HAPI_HAPI_ENABLED: boolean; DD_TRACE_HEADER_TAGS: string[]; DD_TRACE_HONO_ENABLED: boolean; + DD_TRACE_HTTP_CLIENT_ERROR_STATUSES: string; DD_TRACE_HTTP_ENABLED: boolean; DD_TRACE_HTTP_SERVER_ERROR_STATUSES: string; DD_TRACE_HTTP2_ENABLED: boolean; diff --git a/packages/dd-trace/src/config/index.js b/packages/dd-trace/src/config/index.js index 7a1df2f12d6..e2a6a3b2ce8 100644 --- a/packages/dd-trace/src/config/index.js +++ b/packages/dd-trace/src/config/index.js @@ -510,7 +510,8 @@ class Config extends ConfigBase { if (!this.llmobs.DD_LLMOBS_ENABLED && !trackedConfigOrigins.has('llmobs.DD_LLMOBS_ENABLED') && (trackedConfigOrigins.has('llmobs.agentlessEnabled') || - trackedConfigOrigins.has('llmobs.mlApp'))) { + trackedConfigOrigins.has('llmobs.mlApp') || + trackedConfigOrigins.has('llmobs.projectName'))) { setAndTrack(this, 'llmobs.DD_LLMOBS_ENABLED', true) } diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index 88ba35eae3d..468feedc70d 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -93,6 +93,16 @@ "default": "16" } ], + "DD_AI_GUARD_REDACTION_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "configurationNames": [ + "experimental.aiguard.redactionEnabled" + ], + "default": "true" + } + ], "DD_AI_GUARD_TIMEOUT": [ { "implementation": "A", @@ -1341,6 +1351,16 @@ "default": null } ], + "DD_LLMOBS_PROJECT_NAME": [ + { + "implementation": "A", + "type": "string", + "configurationNames": [ + "llmobs.projectName" + ], + "default": null + } + ], "DD_LLMOBS_SAMPLE_RATE": [ { "implementation": "A", @@ -2999,6 +3019,13 @@ "default": "true" } ], + "DD_TRACE_HTTP_CLIENT_ERROR_STATUSES": [ + { + "implementation": "A", + "type": "string", + "default": "400-499" + } + ], "DD_TRACE_HTTP_ENABLED": [ { "implementation": "A", diff --git a/packages/dd-trace/src/debugger/devtools_client/send.js b/packages/dd-trace/src/debugger/devtools_client/send.js index 460af4f268b..6f2f011403f 100644 --- a/packages/dd-trace/src/debugger/devtools_client/send.js +++ b/packages/dd-trace/src/debugger/devtools_client/send.js @@ -141,7 +141,7 @@ function setInputPath (newPath) { * @returns {string} The serialized tags. */ function buildTags (tags) { - const serializedTags = [] + let serializedTags = '' for (const [key, rawValue] of tags) { if (rawValue === undefined) continue @@ -151,8 +151,9 @@ function buildTags (tags) { continue } - serializedTags.push(`${key}:${rawValue}`) + if (serializedTags) serializedTags += ',' + serializedTags += `${key}:${rawValue}` } - return serializedTags.join(',') + return serializedTags } diff --git a/packages/dd-trace/src/dogstatsd.js b/packages/dd-trace/src/dogstatsd.js index 19a580ad2db..1833d00a2be 100644 --- a/packages/dd-trace/src/dogstatsd.js +++ b/packages/dd-trace/src/dogstatsd.js @@ -8,6 +8,8 @@ const request = require('./exporters/common/request') const log = require('./log') const Histogram = require('./histogram') const { entityId } = require('./exporters/common/docker') +const { registerTelemetryFlusher } = require('./flush') +const { createServerlessDeliveryTracker } = require('./serverless') const legacyStorage = storage('legacy') @@ -25,6 +27,8 @@ const TYPE_HISTOGRAM = 'h' class DogStatsDClient { #lookup #tagsPrefix + #serverlessDeliveryTracker + constructor (options) { this.#lookup = options.lookup if (options.metricsProxyUrl) { @@ -41,6 +45,7 @@ class DogStatsDClient { this._tags = options.tags this.#tagsPrefix = this._tags?.length ? `|#${this._tags.join(',')}` : '' this._queue = [] + this.#serverlessDeliveryTracker = createServerlessDeliveryTracker() this._buffer = '' this._offset = 0 this._udp4 = this._socket('udp4') @@ -67,23 +72,30 @@ class DogStatsDClient { this._add(stat, value, TYPE_HISTOGRAM, tags) } - flush () { + flush (done) { const queue = this._enqueue() - if (queue.length === 0) return + if (queue.length === 0) { + if (this.#serverlessDeliveryTracker) return this.#serverlessDeliveryTracker.waitForIdle(done) + return done?.() + } log.debug('Flushing %s metrics via %s', queue.length, this._httpOptions ? 'HTTP' : 'UDP') this._queue = [] - if (this._httpOptions) { - this._sendHttp(queue) - } else { - this._sendUdp(queue) + const send = complete => { + if (this._httpOptions) this._sendHttp(queue, complete) + else this._sendUdp(queue, complete) + } + if (this.#serverlessDeliveryTracker) { + this.#serverlessDeliveryTracker.track(send) + return this.#serverlessDeliveryTracker.waitForIdle(done) } + send(done) } - _sendHttp (queue) { + _sendHttp (queue, done) { const buffer = Buffer.concat(queue) request(buffer, this._httpOptions, (err) => { if (err) { @@ -95,32 +107,46 @@ class DogStatsDClient { // options. Either way, we can give UDP a try. this._httpOptions = undefined } - this._sendUdp(queue) + this._sendUdp(queue, done) + } else { + done?.() } }) } - _sendUdp (queue) { + _sendUdp (queue, done) { // dgram resolves the local address via the instrumented dns.lookup when it // binds on first send; the noop store keeps that self-traffic off the trace. legacyStorage.run({ noop: true }, () => { if (this._family === 0) { this.#lookup(this._host, (error, address, family) => { - if (error) return log.error('DogStatsDClient: Host not found', error) - this._sendUdpFromQueue(queue, address, family) + if (error) { + log.error('DogStatsDClient: Host not found', error) + return done?.() + } + this._sendUdpFromQueue(queue, address, family, done) }) } else { - this._sendUdpFromQueue(queue, this._host, this._family) + this._sendUdpFromQueue(queue, this._host, this._family, done) } }) } - _sendUdpFromQueue (queue, address, family) { + _sendUdpFromQueue (queue, address, family, done) { const socket = family === 6 ? this._udp6 : this._udp4 + let pending = queue.length + const complete = () => { + if (--pending === 0) done?.() + } for (const buffer of queue) { log.debug('Sending to DogStatsD: %s', buffer) - socket.send(buffer, 0, buffer.length, this._port, address) + try { + socket.send(buffer, 0, buffer.length, this._port, address, complete) + } catch (error) { + log.error('DogStatsDClient: UDP error sending metrics', error) + complete() + } } } @@ -212,12 +238,12 @@ class MetricsAggregationClient { this.reset() } - flush () { + flush (done) { this._captureCounters() this._captureGauges() this._captureHistograms() - this._client.flush() + this._client.flush(done) } reset () { @@ -370,6 +396,7 @@ class CustomMetrics { setInterval(flush, 10 * 1000).unref?.() globalThis[Symbol.for('dd-trace')].beforeExitHandlers.add(flush) + registerTelemetryFlusher(done => this.flush(done)) } increment (stat, value = 1, tags) { @@ -392,8 +419,8 @@ class CustomMetrics { this.#client.histogram(stat, value, CustomMetrics.tagTranslator(tags)) } - flush () { - return this.#client.flush() + flush (done) { + return this.#client.flush(done) } /** diff --git a/packages/dd-trace/src/encode/agentless-json.js b/packages/dd-trace/src/encode/agentless-json.js index 37edabc72df..b8d1be953f6 100644 --- a/packages/dd-trace/src/encode/agentless-json.js +++ b/packages/dd-trace/src/encode/agentless-json.js @@ -167,17 +167,18 @@ class AgentlessJSONEncoder { const metadataPrefix = metadataJson.slice(0, -1) const hasMetadata = metadataPrefix.length > 1 // more than just '{' - const traceParts = [] + let tracesJson = '' for (const spanStrings of this._traces) { const spansJson = '[' + spanStrings.join(',') + ']' + if (tracesJson) tracesJson += ',' if (hasMetadata) { - traceParts.push(metadataPrefix + ',"spans":' + spansJson + '}') + tracesJson += metadataPrefix + ',"spans":' + spansJson + '}' } else { - traceParts.push('{"spans":' + spansJson + '}') + tracesJson += '{"spans":' + spansJson + '}' } } - const payload = '{"traces":[' + traceParts.join(',') + ']}' + const payload = '{"traces":[' + tracesJson + ']}' this._reset() return Buffer.from(payload, 'utf8') } catch (err) { diff --git a/packages/dd-trace/src/exporters/agent/index.js b/packages/dd-trace/src/exporters/agent/index.js index e951048ab5b..107b4e54de6 100644 --- a/packages/dd-trace/src/exporters/agent/index.js +++ b/packages/dd-trace/src/exporters/agent/index.js @@ -2,12 +2,15 @@ const { URL } = require('url') const log = require('../../log') +const { createServerlessDeliveryTracker } = require('../../serverless') const Writer = require('./writer') class AgentExporter { #timer + #serverlessDeliveryTracker constructor (config, prioritySampler) { + this.#serverlessDeliveryTracker = createServerlessDeliveryTracker() this._config = config const { lookup, protocolVersion, stats = {}, apmTracingEnabled } = config this._url = config.url @@ -23,6 +26,7 @@ class AgentExporter { lookup, protocolVersion, headers, + deliveryTracker: this.#serverlessDeliveryTracker, }) globalThis[Symbol.for('dd-trace')].beforeExitHandlers.add(this.flush.bind(this)) @@ -54,10 +58,26 @@ class AgentExporter { } } - flush (done = () => {}) { + flush (done) { clearTimeout(this.#timer) this.#timer = undefined - this._writer.flush(done) + + if (!this.#serverlessDeliveryTracker) { + try { + return this._writer.flush(done) + } catch (error) { + log.error('Failed to flush traces: %s', error.message) + done?.() + return + } + } + + try { + this._writer.flush() + } catch (error) { + log.error('Failed to flush traces: %s', error.message) + } + this.#serverlessDeliveryTracker.waitForIdle(done) } } diff --git a/packages/dd-trace/src/exporters/agent/writer.js b/packages/dd-trace/src/exporters/agent/writer.js index d81f197395a..c4b1271a1ee 100644 --- a/packages/dd-trace/src/exporters/agent/writer.js +++ b/packages/dd-trace/src/exporters/agent/writer.js @@ -39,18 +39,18 @@ class AgentWriter extends BaseWriter { } /** - * Flushes payloads, including requests already in flight during Test Optimization finalization. - * + * Performs the writer flush without registering a serverless delivery. + * Test Optimization owns its own request lifecycle tracking. * @param {(error?: Error) => void} [done] * @param {{ deadline?: number }} [options] * @returns {void} */ - flush (done, options) { + flushDirect (done, options) { if (this.#requestTracker) { this.#requestTracker.flush(done, options) return } - super.flush(done, options) + super.flushDirect(done, options) } _sendPayload (data, count, done, flushOptions) { diff --git a/packages/dd-trace/src/exporters/common/writer.js b/packages/dd-trace/src/exporters/common/writer.js index 06500f0001d..287ef0a7aec 100644 --- a/packages/dd-trace/src/exporters/common/writer.js +++ b/packages/dd-trace/src/exporters/common/writer.js @@ -10,14 +10,36 @@ const { safeJSONStringify } = require('./util') const firstFlushChannel = channel('dd-trace:exporter:first-flush') class Writer { - constructor ({ url, beforeFirstFlush }) { + #deliveryTracker + + constructor ({ url, beforeFirstFlush, deliveryTracker }) { this._url = url this._beforeFirstFlush = beforeFirstFlush + this.#deliveryTracker = deliveryTracker } #isFirstFlush = true - flush (done = () => {}, options) { + /** + * Flushes queued telemetry, retaining delivery on supported serverless platforms. + * @param {(error?: Error) => void} [done] + * @param {{ deadline?: number }} [options] + * @returns {void} + */ + flush (done, options) { + if (this.#deliveryTracker) { + return this.#deliveryTracker.track(callback => this.flushDirect(callback, options), done) + } + this.flushDirect(done, options) + } + + /** + * Flushes queued telemetry without registering serverless delivery retention. + * @param {(error?: Error) => void} [done] + * @param {{ deadline?: number }} [options] + * @returns {void} + */ + flushDirect (done = () => {}, options) { const count = this._encoder.count() if (!request.writable && options?.deadline === undefined) { diff --git a/packages/dd-trace/src/exporters/span-stats/index.js b/packages/dd-trace/src/exporters/span-stats/index.js index 9fa10de4f8a..b1000f33707 100644 --- a/packages/dd-trace/src/exporters/span-stats/index.js +++ b/packages/dd-trace/src/exporters/span-stats/index.js @@ -1,16 +1,35 @@ 'use strict' +const log = require('../../log') +const { createServerlessDeliveryTracker } = require('../../serverless') const { Writer } = require('./writer') class SpanStatsExporter { + #serverlessDeliveryTracker + constructor (config) { + this.#serverlessDeliveryTracker = createServerlessDeliveryTracker() this._url = config.url - this._writer = new Writer({ url: this._url }) + this._writer = new Writer({ + url: this._url, + deliveryTracker: this.#serverlessDeliveryTracker, + }) + } + + export (payload, done) { + try { + this._writer.append(payload) + this._writer.flush(this.#serverlessDeliveryTracker ? undefined : done) + } catch (error) { + if (!done) throw error + log.error('Failed to flush span stats: %s', error.message) + } + this.#serverlessDeliveryTracker?.waitForIdle(done) } - export (payload) { - this._writer.append(payload) - this._writer.flush() + flush (done) { + this._writer.flush(this.#serverlessDeliveryTracker ? undefined : done) + this.#serverlessDeliveryTracker?.waitForIdle(done) } } diff --git a/packages/dd-trace/src/external-logger/src/index.js b/packages/dd-trace/src/external-logger/src/index.js index cd3532a7bb0..5b82ffbdc7e 100644 --- a/packages/dd-trace/src/external-logger/src/index.js +++ b/packages/dd-trace/src/external-logger/src/index.js @@ -33,11 +33,12 @@ class ExternalLogger { } static tagString (tags) { - const tagArray = [] + let tagString = '' for (const key in tags) { - tagArray.push(key + ':' + tags[key]) + if (tagString) tagString += ',' + tagString += key + ':' + tags[key] } - return tagArray.join(',') + return tagString } // Parses and enqueues a log diff --git a/packages/dd-trace/src/flush.js b/packages/dd-trace/src/flush.js new file mode 100644 index 00000000000..3c0f33ce32d --- /dev/null +++ b/packages/dd-trace/src/flush.js @@ -0,0 +1,102 @@ +'use strict' + +const log = require('./log') +const { supportsServerlessTelemetryRetention } = require('./serverless') + +/** + * @typedef {(done: () => void) => void} TelemetryFlusher + */ + +/** @type {Set} */ +const telemetryFlushers = new Set() +const postTraceTelemetryFlushers = new Set() + +/** + * @typedef {{ + * trace?: TelemetryFlusher, + * spanStats?: TelemetryFlusher + * }} TraceFlushers + */ + +/** + * Registers a configured telemetry pipeline so serverless lifecycle retention + * waits for its final export alongside trace delivery. + * @param {TelemetryFlusher} flusher + * @param {{ afterTrace?: boolean }} [options] + * @returns {() => void} Removes this pipeline when its provider is replaced. + */ +function registerTelemetryFlusher (flusher, options) { + if (!supportsServerlessTelemetryRetention()) return () => {} + + const flushers = options?.afterTrace ? postTraceTelemetryFlushers : telemetryFlushers + flushers.add(flusher) + // Avoid retaining a replaced provider or flushing it alongside the new one. + return () => flushers.delete(flusher) +} + +/** + * Coordinates the configured telemetry flushers for a serverless lifecycle. + * + * Trace-owned flushers are supplied by DatadogTracer so this module does not + * depend on its private implementation details. + * @param {() => void} [done] + * @param {{ timeout?: number }} [options] + * @param {TraceFlushers} [traceFlushers] + */ +function flushServerlessTelemetry (done, options, traceFlushers = {}) { + const { trace: traceFlusher, spanStats: spanStatsFlusher } = traceFlushers + // TODO: Include DSM after DataStreamsProcessor exposes a completion-aware flush API. + let pending = telemetryFlushers.size + postTraceTelemetryFlushers.size + + (typeof traceFlusher === 'function' ? 1 : 0) + + (typeof spanStatsFlusher === 'function' ? 1 : 0) + let completed = false + let timeout + + const finish = () => { + if (completed) return + completed = true + clearTimeout(timeout) + done?.() + } + const complete = () => { + if (--pending === 0) finish() + } + + if (pending === 0) return finish() + if (options?.timeout) { + timeout = setTimeout(() => { + log.warn('Timed out waiting for telemetry flush after %dms', options.timeout) + finish() + }, options.timeout) + } + + const flush = (flusher, afterFlushed) => { + let flushed = false + const onFlushed = error => { + if (flushed) return + flushed = true + if (error) log.error('Error flushing telemetry pipeline:', error) + afterFlushed?.() + complete() + } + try { + flusher(onFlushed) + } catch (error) { + onFlushed(error) + } + } + + if (typeof traceFlusher === 'function') { + flush(traceFlusher, () => { + for (const flusher of postTraceTelemetryFlushers) flush(flusher) + }) + } else { + for (const flusher of postTraceTelemetryFlushers) flush(flusher) + } + if (typeof spanStatsFlusher === 'function') { + flush(spanStatsFlusher) + } + for (const flusher of telemetryFlushers) flush(flusher) +} + +module.exports = { flushServerlessTelemetry, registerTelemetryFlusher } diff --git a/packages/dd-trace/src/llmobs/experiments/client.js b/packages/dd-trace/src/llmobs/experiments/client.js index 50b2f6dc847..1560a7a84a1 100644 --- a/packages/dd-trace/src/llmobs/experiments/client.js +++ b/packages/dd-trace/src/llmobs/experiments/client.js @@ -108,6 +108,10 @@ class ExperimentsClient { return this.#site } + get projectName () { + return this.#projectName + } + // Dashboard URL base for the configured site, e.g. https://app.datadoghq.com get appBase () { return `https://${appHost(this.#site)}` diff --git a/packages/dd-trace/src/llmobs/experiments/dataset.js b/packages/dd-trace/src/llmobs/experiments/dataset.js index a950df1e4c8..dbddb75112f 100644 --- a/packages/dd-trace/src/llmobs/experiments/dataset.js +++ b/packages/dd-trace/src/llmobs/experiments/dataset.js @@ -1,8 +1,17 @@ 'use strict' const { randomUUID } = require('node:crypto') +const snapshotPayload = require('../../../../../vendor/dist/rfdc')({ proto: false, circles: false }) /** @typedef {{add?: string[], remove?: string[], replace?: string[]}} TagOperations */ +/** + * @typedef {object} DatasetRecordNew + * @property {string} [id] + * @property {unknown} inputData + * @property {unknown} [expectedOutput] + * @property {Record} [metadata] + * @property {string[]} [tags] + */ /** * @typedef {object} PendingBatch * @property {object} attributes @@ -114,6 +123,9 @@ function updateFromInsertedRecord (recordId, record, payload) { update.expectedOutput = record.expectedOutput } if (!valuesAreEqual(record.metadata, payload.metadata)) update.metadata = record.metadata + if (!valuesAreEqual(record.tags, payload.tags ?? [])) { + update.tagOperations = { replace: [...record.tags] } + } return update } @@ -173,6 +185,36 @@ class Dataset { return this } + /** + * Add multiple records to a dataset. + * @param {DatasetRecordNew[]} records + * @returns {Dataset} This dataset for chaining. + */ + addRecords (records) { + const newRecords = [] + const recordIds = new Set(this.#recordsById.keys()) + + // Construct and validate the entire batch before mutating the dataset. + for (const record of records) { + if (record.id !== undefined && (typeof record.id !== 'string' || record.id.length === 0)) { + throw new Error('record id must be a non-empty string') + } + const newRecord = new DatasetRecord( + record.inputData, + record.expectedOutput, + record.metadata, + record.id, + record.tags + ) + if (recordIds.has(newRecord.id)) throw new Error(`Duplicate record id '${newRecord.id}'`) + recordIds.add(newRecord.id) + newRecords.push(newRecord) + } + + for (const record of newRecords) this.#addRecord(record) + return this + } + /** * Add tags to a dataset record. * @param {number} index Dataset record index. @@ -336,6 +378,10 @@ class Dataset { return this.#projectId } + projectName () { + return this.#client.projectName + } + version () { return this.#version } @@ -416,7 +462,7 @@ class Dataset { const insertRecords = [] const insertPayloads = new Map() for (const [recordId, record] of this.#newRecordsById) { - const payload = serializedRecord(record) + const payload = snapshotPayload(serializedRecord(record)) insertRecords.push(payload) insertPayloads.set(recordId, payload) } @@ -427,7 +473,7 @@ class Dataset { const tagOperations = this.#pendingTagOperations.get(recordId) if (tagOperations) update.tagOperations = tagOperations else delete update.tagOperations - const payload = serializedRecordUpdate(update) + const payload = snapshotPayload(serializedRecordUpdate(update)) updateRecords.push(payload) updatePayloads.set(recordId, payload) } @@ -522,6 +568,9 @@ class Dataset { updateFromInsertedRecord(recordId, current, payload) const queuedOperations = this.#pendingTagOperations.get(recordId) if (queuedOperations) update.tagOperations = queuedOperations + if (update.tagOperations) { + this.#pendingTagOperations.set(recordId, copyTagOperations(update.tagOperations)) + } this.#updatedRecordsById.set(recordId, update) } diff --git a/packages/dd-trace/src/llmobs/experiments/experiment.js b/packages/dd-trace/src/llmobs/experiments/experiment.js index 97e4b00ca6c..0767945d9ea 100644 --- a/packages/dd-trace/src/llmobs/experiments/experiment.js +++ b/packages/dd-trace/src/llmobs/experiments/experiment.js @@ -8,11 +8,13 @@ const { buildSpanMetadata, buildTags, durationNs, + generateRunId, hasEntries, inferMetricType, - normalizeEvaluators, mergeTags, + normalizeEvaluators, normalizeJsonMetricValue, + normalizePositiveInteger, recordTagsToObject, sleep, stringify, @@ -20,6 +22,8 @@ const { validateEvaluatorName, } = require('./util') +const TASK_ERROR_MESSAGE = 'task error; evaluation skipped' + // One span per experiment row (LLM Obs experiment span wire format). function toSpan (row, metadata, ids, spanName, userTags, recordTags) { const meta = { @@ -46,6 +50,7 @@ function toSpan (row, metadata, ids, spanName, userTags, recordTags) { dataset_record_id: ids.datasetRecordId, dataset_name: ids.datasetName, experiment_name: ids.experimentName, + project_name: ids.projectName, }) return { @@ -64,7 +69,7 @@ function toSpan (row, metadata, ids, spanName, userTags, recordTags) { // One metric per evaluator per row or summary evaluator. function toMetric ( - label, value, errorMessage, spanId, traceId, timestampMs, experimentId, userTags, source = 'custom' + label, value, errorMessage, spanId, traceId, timestampMs, experimentId, userTags, source = 'custom', ids = {} ) { const metric = { metric_source: source, @@ -72,7 +77,11 @@ function toMetric ( span_id: spanId, trace_id: traceId, timestamp_ms: timestampMs, - tags: buildTags(userTags, { experiment_id: experimentId }), + tags: buildTags(userTags, { + experiment_id: experimentId, + run_id: ids.runId, + run_iteration: ids.runIteration, + }), experiment_id: experimentId, } @@ -124,8 +133,43 @@ function errorMessage (error) { return error.message ?? String(error) } -// Builder + run() orchestration: runs rows sequentially, emits one root span -// per dataset row, and posts spans + metrics to the experiments events API. +function createLimiter (concurrency) { + const waiting = [] + let active = 0 + let cancellation + + const limit = async function limit (fn, cancelOnError = false) { + if (cancellation !== undefined) throw cancellation.error + if (active >= concurrency) { + await new Promise((resolve, reject) => waiting.push({ resolve, reject })) + } + if (cancellation !== undefined) throw cancellation.error + active++ + try { + return await fn() + } catch (error) { + if (cancelOnError) limit.cancel(error) + throw error + } finally { + active-- + const next = waiting.shift() + if (next !== undefined) next.resolve() + } + } + + limit.cancel = (error) => { + if (cancellation !== undefined) return + cancellation = { error } + const queued = [...waiting] + waiting.length = 0 + for (const waiter of queued) waiter.reject(error) + } + + return limit +} + +// Builder + run() orchestration: emits one root span per dataset row and +// posts spans + metrics to the experiments events API. class Experiment { #client #llmobs @@ -139,6 +183,8 @@ class Experiment { #config #tags #metadata + #runs + #projectName #projectId #experimentId #runId @@ -163,8 +209,11 @@ class Experiment { this.#config = { ...options.config } const filterTags = this.#dataset.filterTags?.() ?? [] if (filterTags.length > 0) this.#config.filtered_record_tags = filterTags + this.#projectName = options.projectName this.#tags = { ...options.tags } + if (this.#projectName !== undefined) this.#tags.project_name = this.#projectName this.#metadata = { ...options.metadata } + this.#runs = this.#external ? 1 : normalizePositiveInteger(options.runs ?? 1, 'runs') this.#projectId = null this.#experimentId = null this.#runId = null @@ -281,6 +330,7 @@ class Experiment { projectId: this.#projectId, datasetId: this.#dataset.id, datasetRecordId: input.datasetRecordId, + projectName: this.#projectName, runId: input.runId ?? this.#runId, runIteration: input.runIteration ?? this.#runIteration, }, input.name ?? this.#name, mergeTags(this.#tags, input.tags)) @@ -324,6 +374,8 @@ class Experiment { log.warn('LLMObs experiments: skipping external metric %s because it has neither value nor error', metric.label) continue } + const metricTags = mergeTags(this.#tags, metric.tags) + if (this.#projectName !== undefined) metricTags.project_name = this.#projectName payload.push(toMetric( metric.label, metric.value, @@ -332,7 +384,7 @@ class Experiment { span.traceId, timestampMs(metric.timestamp), experimentId, - mergeTags(this.#tags, metric.tags), + metricTags, metric.source ?? 'custom' )) } @@ -359,10 +411,12 @@ class Experiment { maxRetries = 0, retryDelay = (attempt) => 100 * (attempt + 1), throwOnErrors = false, + concurrency = 10, } = options if (maxRetries < 0) throw new Error('maxRetries must be >= 0') if (typeof retryDelay !== 'function') throw new TypeError('retryDelay must be a function') + const concurrencyLimit = normalizePositiveInteger(concurrency, 'concurrency') const projectId = await this.#client.ensureProjectId() @@ -380,13 +434,12 @@ class Experiment { dataset_id: datasetId, description: this.#description, ensure_unique: true, - run_count: 1, + run_count: this.#runs, metadata: { tags: buildTags(this.#tags, {}) }, } const datasetVersion = this.#dataset.version() if (datasetVersion !== null) attributes.dataset_version = datasetVersion - // eslint-disable-next-line no-restricted-syntax -- faster than tracking entries while copying arbitrary config - if (Object.keys(this.#config).length > 0) attributes.config = this.#config + if (hasEntries(this.#config)) attributes.config = this.#config let created try { @@ -396,28 +449,21 @@ class Experiment { } this.#experimentId = created.experimentId const experimentId = this.#experimentId - const runId = id().toString(16).padStart(16, '0') - const runIteration = 0 try { const records = this.#dataset.records() const recordIds = this.#dataset.recordIds() - const rows = [] - const spans = [] - const metrics = [] - const evaluatorResults = {} const usesLLMObsTrace = Boolean(this.#llmobs?.enabled) - let hasRowError = false + const runs = [] + let hasRunError = false - for (let i = 0; i < records.length; i++) { - const record = records[i] - const datasetRecordId = i < recordIds.length ? recordIds[i] : '' - // Rows currently run sequentially by design; jobs/concurrency is a P1 follow-up. + for (let runIndex = 0; runIndex < this.#runs; runIndex++) { + const runId = generateRunId() + const runIteration = runIndex + 1 // eslint-disable-next-line no-await-in-loop - const row = await this.#processRecord({ - index: i, - record, - datasetRecordId, + const result = await this.#runSingle({ + records, + recordIds, projectId, datasetId, experimentId, @@ -426,80 +472,296 @@ class Experiment { maxRetries, retryDelay, throwOnErrors, + concurrency: concurrencyLimit, + usesLLMObsTrace, }) + runs.push(result.run) + // Submit each run before starting the next iteration so results are available incrementally. + // eslint-disable-next-line no-await-in-loop + await this.#postEvents(experimentId, result.spans, result.metrics) + this.#llmobs?.flush?.() + if (result.hasRowError) hasRunError = true + } - const timestampMs = Date.now() - for (const [label, evaluator] of this.#evaluators) { - if (!evaluatorResults[label]) evaluatorResults[label] = [] - if (row.isError) { - const msg = 'task error; evaluation skipped' - row.evaluationErrors[label] = msg - evaluatorResults[label].push(null) - metrics.push(toMetric(label, null, msg, row.spanId, row.traceId, timestampMs, experimentId, this.#tags)) - continue - } - try { - // eslint-disable-next-line no-await-in-loop - const value = await this.#runWithRetries( - () => evaluator(record.input, row.output, record.expectedOutput), - maxRetries, - retryDelay - ) - row.evaluations[label] = value - evaluatorResults[label].push(value) - metrics.push(toMetric(label, value, null, row.spanId, row.traceId, timestampMs, experimentId, this.#tags)) - } catch (err) { - if (throwOnErrors) throw err - const msg = err.message ?? String(err) - row.evaluationErrors[label] = msg - evaluatorResults[label].push(null) - metrics.push(toMetric(label, null, msg, row.spanId, row.traceId, timestampMs, experimentId, this.#tags)) - } - } + // A row error doesn't abort the run, but the experiment didn't succeed cleanly. + await this.#updateStatus( + experimentId, + hasRunError ? 'failed' : 'completed', + hasRunError ? 'one or more rows failed' : null + ) - rows.push(row) - if (row.isError || hasEntries(row.evaluationErrors)) hasRowError = true - if (!usesLLMObsTrace) { - spans.push(toSpan(row, record.metadata, { - experimentId, - projectId, - datasetId, - datasetRecordId, - datasetName: this.#dataset.name(), - experimentName: this.#name, - runId, - runIteration, - }, this.#task.name || this.#name, this.#tags, record.tags)) - } - } + const firstRun = runs[0] + return new ExperimentResult( + experimentId, + firstRun?.rows ?? [], + this.url(), + runs, + firstRun?.summaryEvaluations ?? {} + ) + } catch (err) { + await this.#updateStatus(experimentId, 'failed', err.message ?? String(err)) + throw err + } + } - const summaryEvaluations = await this.#runSummaryEvaluators(rows, records, evaluatorResults, { + async #runSingle ({ + records, + recordIds, + projectId, + datasetId, + experimentId, + runId, + runIteration, + maxRetries, + retryDelay, + throwOnErrors, + concurrency, + usesLLMObsTrace, + }) { + const limit = createLimiter(concurrency) + const results = await this.#mapRecords(records, (index) => { + const record = records[index] + const datasetRecordId = index < recordIds.length ? recordIds[index] : '' + return this.#processRecordWithEvaluators({ + index, + record, + datasetRecordId, + projectId, + datasetId, + experimentId, + runId, + runIteration, maxRetries, retryDelay, throwOnErrors, - experimentId, - metrics, + limit, }) - if (hasEntries(summaryEvaluations)) { - for (const value of Object.values(summaryEvaluations)) { - if (value?.error) hasRowError = true - } + }, limit, concurrency, throwOnErrors) + + const rows = new Array(results.length) + const spans = [] + const metrics = [] + const evaluatorResults = {} + let hasRowError = false + for (const [label] of this.#evaluators) evaluatorResults[label] = [] + + for (let i = 0; i < results.length; i++) { + const result = results[i] + const row = result.row + rows[i] = row + for (const [label] of this.#evaluators) { + const value = Object.hasOwn(result.evaluatorValues, label) ? result.evaluatorValues[label] : null + evaluatorResults[label].push(value) + } + for (const metric of result.metrics) metrics.push(metric) + if (result.hasRowError) hasRowError = true + if (!usesLLMObsTrace) { + spans.push(toSpan(row, records[i].metadata, { + experimentId, + projectId, + datasetId, + datasetRecordId: i < recordIds.length ? recordIds[i] : '', + datasetName: this.#dataset.name(), + experimentName: this.#name, + projectName: this.#projectName, + runId, + runIteration, + }, this.#task.name || this.#name, this.#tags, records[i].tags)) } + } - await this.#postEvents(experimentId, spans, metrics) - this.#llmobs?.flush?.() - // A row error doesn't abort the run, but the experiment didn't succeed cleanly. - await this.#updateStatus( + const summaryEvaluations = await this.#runSummaryEvaluators(rows, records, evaluatorResults, { + maxRetries, + retryDelay, + throwOnErrors, + experimentId, + runId, + runIteration, + metrics, + limit, + }) + if (hasEntries(summaryEvaluations)) { + for (const value of Object.values(summaryEvaluations)) { + if (value?.error !== null && value?.error !== undefined) hasRowError = true + } + } + + return { + run: new ExperimentRun({ runId, runIteration, hasError: hasRowError, rows, summaryEvaluations }), + spans, + metrics, + hasRowError, + } + } + + async #mapRecords (records, processRecord, limit, concurrency, throwOnErrors) { + const results = new Array(records.length) + let nextIndex = 0 + + const worker = async () => { + while (nextIndex < records.length) { + const index = nextIndex++ + // eslint-disable-next-line no-await-in-loop -- each worker processes one record at a time + results[index] = await processRecord(index) + } + } + + const workers = new Array(Math.min(concurrency, records.length)) + for (let i = 0; i < workers.length; i++) workers[i] = worker() + + try { + await Promise.all(workers) + } catch (error) { + if (throwOnErrors) limit.cancel(error) + throw error + } + return results + } + + async #processRecordWithEvaluators ({ + index, + record, + datasetRecordId, + projectId, + datasetId, + experimentId, + runId, + runIteration, + maxRetries, + retryDelay, + throwOnErrors, + limit, + }) { + const row = await limit(() => this.#processRecord({ + index, + record, + datasetRecordId, + projectId, + datasetId, + experimentId, + runId, + runIteration, + maxRetries, + retryDelay, + throwOnErrors, + }), throwOnErrors) + const timestampMs = Date.now() + const metrics = [] + const evaluatorValues = {} + let firstError + + const pending = new Array(this.#evaluators.length) + for (let i = 0; i < this.#evaluators.length; i++) { + const [label, evaluator] = this.#evaluators[i] + pending[i] = this.#runRowEvaluator({ + label, + evaluator, + row, + record, + timestampMs, experimentId, - hasRowError ? 'failed' : 'completed', - hasRowError ? 'one or more rows failed' : null - ) + runId, + runIteration, + maxRetries, + retryDelay, + throwOnErrors, + limit, + }) + } + + const evaluatorResults = await Promise.all(pending) + for (const result of evaluatorResults) { + if (result.metric !== null) metrics.push(result.metric) + evaluatorValues[result.label] = result.value + if (result.error !== undefined && firstError === undefined) firstError = result.error + } + if (firstError !== undefined) throw firstError + + return { + row, + metrics, + evaluatorValues, + hasRowError: row.isError || hasEntries(row.evaluationErrors), + } + } + + async #runRowEvaluator ({ + label, + evaluator, + row, + record, + timestampMs, + experimentId, + runId, + runIteration, + maxRetries, + retryDelay, + throwOnErrors, + limit, + }) { + if (row.isError) { + row.evaluationErrors[label] = TASK_ERROR_MESSAGE + return { + label, + value: null, + metric: toMetric( + label, + null, + TASK_ERROR_MESSAGE, + row.spanId, + row.traceId, + timestampMs, + experimentId, + this.#tags, + 'custom', + { runId, runIteration } + ), + } + } - const run = new ExperimentRun({ runId, runIteration, rows, summaryEvaluations }) - return new ExperimentResult(experimentId, rows, this.url(), [run], summaryEvaluations) + try { + const value = await limit(() => this.#runWithRetries( + () => evaluator(record.input, row.output, record.expectedOutput), + maxRetries, + retryDelay + ), throwOnErrors) + row.evaluations[label] = value + return { + label, + value, + metric: toMetric( + label, + value, + null, + row.spanId, + row.traceId, + timestampMs, + experimentId, + this.#tags, + 'custom', + { runId, runIteration } + ), + } } catch (err) { - await this.#updateStatus(experimentId, 'failed', err.message ?? String(err)) - throw err + if (throwOnErrors) throw err + const msg = err.message ?? String(err) + row.evaluationErrors[label] = msg + return { + label, + value: null, + metric: toMetric( + label, + null, + msg, + row.spanId, + row.traceId, + timestampMs, + experimentId, + this.#tags, + 'custom', + { runId, runIteration } + ), + } } } @@ -534,6 +796,7 @@ class Experiment { dataset_name: this.#dataset.name(), experiment_name: this.#name, } + if (this.#projectName !== undefined) autoTags.project_name = this.#projectName const tags = mergeTags(this.#tags, { ...recordTagsToObject(record.tags), ...autoTags }) const execute = () => this.#runWithRetries( @@ -628,17 +891,65 @@ class Experiment { const metadata = records.map(record => buildSpanMetadata(record.metadata, this.#config)) const summaryEvaluations = {} const timestampMs = Date.now() + const pending = new Array(this.#summaryEvaluators.length) + let firstError + + for (let i = 0; i < this.#summaryEvaluators.length; i++) { + const [label, evaluator] = this.#summaryEvaluators[i] + pending[i] = this.#runSummaryEvaluator({ + label, + evaluator, + inputs, + outputs, + expectedOutputs, + evaluatorResults, + metadata, + timestampMs, + options, + }) + } - for (const [label, evaluator] of this.#summaryEvaluators) { - try { - // eslint-disable-next-line no-await-in-loop - const value = await this.#runWithRetries( - () => evaluator(inputs, outputs, expectedOutputs, evaluatorResults, metadata), - options.maxRetries, - options.retryDelay - ) - summaryEvaluations[label] = { value, error: null } - options.metrics.push(toMetric( + let results + try { + results = await Promise.all(pending) + } catch (err) { + if (options.throwOnErrors) options.limit.cancel(err) + throw err + } + for (const result of results) { + if (result.error !== undefined) { + if (firstError === undefined) firstError = result.error + continue + } + summaryEvaluations[result.label] = result.evaluation + options.metrics.push(result.metric) + } + if (firstError !== undefined) throw firstError + + return summaryEvaluations + } + + async #runSummaryEvaluator ({ + label, + evaluator, + inputs, + outputs, + expectedOutputs, + evaluatorResults, + metadata, + timestampMs, + options, + }) { + try { + const value = await options.limit(() => this.#runWithRetries( + () => evaluator(inputs, outputs, expectedOutputs, evaluatorResults, metadata), + options.maxRetries, + options.retryDelay + ), options.throwOnErrors) + return { + label, + evaluation: { value, error: null }, + metric: toMetric( label, value, null, @@ -647,13 +958,17 @@ class Experiment { timestampMs, options.experimentId, this.#tags, - 'summary' - )) - } catch (err) { - if (options.throwOnErrors) throw err - const msg = err.message ?? String(err) - summaryEvaluations[label] = { value: null, error: msg } - options.metrics.push(toMetric( + 'summary', + { runId: options.runId, runIteration: options.runIteration } + ), + } + } catch (err) { + if (options.throwOnErrors) throw err + const msg = err.message ?? String(err) + return { + label, + evaluation: { value: null, error: msg }, + metric: toMetric( label, null, msg, @@ -662,11 +977,11 @@ class Experiment { timestampMs, options.experimentId, this.#tags, - 'summary' - )) + 'summary', + { runId: options.runId, runIteration: options.runIteration } + ), } } - return summaryEvaluations } async #postEvents (experimentId, spans, metrics) { diff --git a/packages/dd-trace/src/llmobs/experiments/index.js b/packages/dd-trace/src/llmobs/experiments/index.js index 50652e13ffb..b7c2c3763fe 100644 --- a/packages/dd-trace/src/llmobs/experiments/index.js +++ b/packages/dd-trace/src/llmobs/experiments/index.js @@ -2,11 +2,13 @@ const log = require('../../log') const { ExperimentsClient } = require('./client') -const { Dataset, DatasetRecord } = require('./dataset') +const { Dataset } = require('./dataset') const { Experiment, ExternalExperiment } = require('./experiment') const { validateTagsList } = require('./util') const NoopExperiments = require('./noop') +const DEFAULT_PROJECT_NAME = 'default-project' + // Poll `attempt` with exponential backoff until it returns true or the time // budget is spent. Used for eventually-consistent reads (pullDataset). async function retryWithBackoff (attempt, { maxTotalMs = 30_000, baseDelayMs = 250, maxDelayMs = 8000 } = {}) { @@ -33,8 +35,8 @@ class Experiments { constructor (config, llmobs) { this.#config = config - this.#llmobs = llmobs - this.#projectName = config.llmobs?.mlApp || config.service + this.#llmobs = config.llmobs?.mlApp || config.service ? llmobs : undefined + this.#projectName = config.llmobs?.projectName || DEFAULT_PROJECT_NAME this.#client = this.#clientForProject(this.#projectName) } @@ -51,27 +53,26 @@ class Experiments { }) } + /** + * @param {string | undefined} projectName + * @returns {ExperimentsClient} + */ + #clientForOperation (projectName) { + if (projectName !== undefined && projectName !== this.#projectName) { + return this.#clientForProject(projectName) + } + if (this.#client === undefined) this.#client = this.#clientForProject(projectName) + return this.#client + } + // Create a local dataset buffer. Pushed remotely on first experiment run. createDataset (name, descriptionOrOptions = '') { const options = typeof descriptionOrOptions === 'string' ? { description: descriptionOrOptions } : (descriptionOrOptions ?? {}) - const dataset = new Dataset(this.#client, name, options.description ?? '') - const recordIds = new Set() - if ((options.records) != null) { - for (const record of options.records) { - if (record.id !== undefined && (typeof record.id !== 'string' || record.id.length === 0)) { - throw new Error('record id must be a non-empty string') - } - if (record.id !== undefined) { - if (recordIds.has(record.id)) throw new Error(`Duplicate record id '${record.id}'`) - recordIds.add(record.id) - } - dataset.addRecord( - new DatasetRecord(record.inputData, record.expectedOutput, record.metadata, record.id, record.tags) - ) - } - } + const client = this.#clientForOperation(options.projectName) + const dataset = new Dataset(client, name, options.description ?? '') + if ((options.records) != null) dataset.addRecords(options.records) return dataset } @@ -80,9 +81,11 @@ class Experiments { // wait until that many records are readable. Pass `tags` to filter records by // dataset record tags. async pullDataset (name, options = {}) { - const { expectedRecordCount, maxWaitMs = 30_000, tags, version } = options + const { expectedRecordCount, maxWaitMs = 30_000, projectName, tags, version } = options const filterTags = validateTagsList(tags) - const projectId = await this.#client.ensureProjectId() + const client = this.#clientForOperation(projectName) + const resolvedProjectName = projectName ?? this.#projectName + const projectId = await client.ensureProjectId() let pulledDataset = null let records = [] @@ -93,7 +96,7 @@ class Experiments { const succeeded = await retryWithBackoff(async () => { try { if (pulledDataset === null) { - const datasets = await this.#client.listDatasets(projectId, { name }) + const datasets = await client.listDatasets(projectId, { name }) for (const dataset of datasets) { if (dataset.name() === name) { pulledDataset = dataset @@ -109,7 +112,7 @@ class Experiments { // Follow the meta.after / page[cursor] pagination until the last page. for (;;) { // eslint-disable-next-line no-await-in-loop - const page = await this.#client.listDatasetRecords(projectId, pulledDataset.id(), { + const page = await client.listDatasetRecords(projectId, pulledDataset.id(), { cursor, tags: filterTags, version: datasetVersion, @@ -129,13 +132,13 @@ class Experiments { }, { maxTotalMs: maxWaitMs }) if (pulledDataset === null && lastError) { - throw new Error(`Failed to list datasets in project '${this.#projectName}': ${lastError}`) + throw new Error(`Failed to list datasets in project '${resolvedProjectName}': ${lastError}`) } if (pulledDataset === null) { - throw new Error(`Dataset '${name}' not found in project '${this.#projectName}' (after ${maxWaitMs}ms)`) + throw new Error(`Dataset '${name}' not found in project '${resolvedProjectName}' (after ${maxWaitMs}ms)`) } if (!succeeded && lastError) { - throw new Error(`Failed to fetch records for dataset '${name}' in project '${this.#projectName}': ${lastError}`) + throw new Error(`Failed to fetch records for dataset '${name}' in project '${resolvedProjectName}': ${lastError}`) } if (!succeeded && expectedRecordCount != null) { throw new Error( @@ -151,7 +154,7 @@ class Experiments { } return Dataset.fromExisting( - this.#client, + client, name, pulledDataset.description(), pulledDataset.id(), @@ -163,9 +166,26 @@ class Experiments { ) } - // Build an experiment: { name, dataset, task, evaluators, description?, config?, tags? }. + // Build an experiment with a dataset, task, evaluators, and optional project/config/tags. experiment (options) { - return new Experiment(this.#client, options, this.#llmobs) + const datasetProjectName = options?.dataset?.projectName?.() + if (options?.projectName !== undefined && + datasetProjectName !== undefined && + options.projectName !== datasetProjectName) { + throw new Error( + `Experiment project '${options.projectName}' does not match dataset project '${datasetProjectName}'` + ) + } + const projectName = options?.projectName ?? datasetProjectName + const client = this.#clientForOperation(projectName) + const usesDatasetOverride = datasetProjectName !== undefined && datasetProjectName !== this.#projectName + const resolvedProjectName = projectName ?? this.#config.llmobs?.projectName + const experimentOptions = options?.projectName === undefined && + (usesDatasetOverride || this.#config.llmobs?.projectName !== undefined) && + resolvedProjectName !== undefined + ? { ...options, projectName: resolvedProjectName } + : options + return new Experiment(client, experimentOptions, this.#llmobs) } /** @@ -177,10 +197,11 @@ class Experiments { * @returns {Promise} */ startExperiment (options) { - const client = options?.projectName === undefined || options.projectName === this.#projectName - ? this.#client - : this.#clientForProject(options.projectName) - return new Experiment(client, { ...options, external: true }).start() + const client = this.#clientForOperation(options?.projectName) + const experimentOptions = options?.projectName === undefined && this.#config.llmobs?.projectName !== undefined + ? { ...options, projectName: this.#config.llmobs.projectName } + : options + return new Experiment(client, { ...experimentOptions, external: true }).start() .then(experiment => new ExternalExperiment(experiment)) } } @@ -195,15 +216,6 @@ function createExperiments (config, llmobs) { log.warn('LLMObs experiments: missing api and/or app keys, set DD_API_KEY and DD_APP_KEY') return new NoopExperiments('DD_API_KEY and DD_APP_KEY are required for experiments') } - if (!config.llmobs?.mlApp && !config.service) { - const reason = 'no project name configured; set the DD_LLMOBS_ML_APP environment variable (or llmobs.mlApp in ' + - 'tracer.init()) to name the LLM Obs project, or DD_SERVICE (or service in tracer.init()) as a fallback, ' + - 'then retry' - const experiments = new Experiments(config, llmobs) - return new NoopExperiments(reason, { - startExperiment: (options) => experiments.startExperiment(options), - }) - } return new Experiments(config, llmobs) } diff --git a/packages/dd-trace/src/llmobs/experiments/noop.js b/packages/dd-trace/src/llmobs/experiments/noop.js index 3269de59151..3c6093c79c7 100644 --- a/packages/dd-trace/src/llmobs/experiments/noop.js +++ b/packages/dd-trace/src/llmobs/experiments/noop.js @@ -7,6 +7,15 @@ const NOOP_EXPERIMENT_ID = '00000000-0000-0000-0000-000000000000' const NOOP_SPAN_ID = '0000000000000000' const NOOP_TRACE_ID = '00000000000000000000000000000000' +/** + * @typedef {object} DatasetRecordNew + * @property {string} [id] + * @property {unknown} inputData + * @property {unknown} [expectedOutput] + * @property {Record} [metadata] + * @property {string[]} [tags] + */ + class NoopDataset { #name #description @@ -37,6 +46,24 @@ class NoopDataset { return this } + /** + * Add multiple records to a dataset. + * @param {DatasetRecordNew[]} records + * @returns {NoopDataset} This dataset for chaining. + */ + addRecords (records) { + for (const record of records) { + this.#records.push({ + id: record.id ?? null, + input: record.inputData, + expectedOutput: record.expectedOutput ?? null, + metadata: record.metadata ?? {}, + tags: [...(record.tags ?? [])], + }) + } + return this + } + update (index, fields) { const record = this.#records[index] if (record == null) return this @@ -114,6 +141,10 @@ class NoopDataset { return null } + projectName () { + return null + } + version () { return null } @@ -165,7 +196,13 @@ class NoopExperiment { } run () { - return Promise.resolve({ experimentId: null, rows: [], url: null }) + return Promise.resolve({ + experimentId: null, + rows: [], + summaryEvaluations: {}, + runs: [], + url: null, + }) } /** @@ -200,11 +237,9 @@ class NoopExperiment { // throwing, so intentionally disabled experiments remain graceful. class NoopExperiments { #reason - #startExperiment - constructor (reason, options = {}) { + constructor (reason) { this.#reason = reason || 'LLMObs experiments are not available' - this.#startExperiment = options.startExperiment } #warn () { @@ -231,10 +266,6 @@ class NoopExperiments { * @returns {Promise} */ startExperiment (options = {}) { - if (this.#startExperiment !== undefined && options.projectName) { - return this.#startExperiment(options) - } - this.#warn() return Promise.resolve(new ExternalExperiment(new NoopExperiment(options.name, true))) } diff --git a/packages/dd-trace/src/llmobs/experiments/result.js b/packages/dd-trace/src/llmobs/experiments/result.js index 136d682b6e8..c4e21d8c289 100644 --- a/packages/dd-trace/src/llmobs/experiments/result.js +++ b/packages/dd-trace/src/llmobs/experiments/result.js @@ -27,6 +27,7 @@ class ExperimentRun { constructor (fields) { this.runId = fields.runId this.runIteration = fields.runIteration + this.hasError = fields.hasError this.rows = fields.rows this.summaryEvaluations = fields.summaryEvaluations } diff --git a/packages/dd-trace/src/llmobs/experiments/util.js b/packages/dd-trace/src/llmobs/experiments/util.js index bb087678b0d..512faf9fb32 100644 --- a/packages/dd-trace/src/llmobs/experiments/util.js +++ b/packages/dd-trace/src/llmobs/experiments/util.js @@ -1,5 +1,7 @@ 'use strict' +const { randomUUID } = require('node:crypto') + const log = require('../../log') // Matches the backend and dd-trace-py evaluator metric label contract. @@ -42,6 +44,23 @@ function tagOperationsAreEmpty (operations) { ) } +/** + * @param {unknown} value + * @param {string} name + * @returns {number} + */ +function normalizePositiveInteger (value, name) { + if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`) + return value +} + +/** + * @returns {string} + */ +function generateRunId () { + return randomUUID() +} + /** * @param {string} name */ @@ -297,11 +316,13 @@ module.exports = { buildSpanMetadata, buildTags, durationNs, + generateRunId, hasEntries, inferMetricType, mergeTags, normalizeEvaluators, normalizeJsonMetricValue, + normalizePositiveInteger, recordTagsToObject, sleep, stringify, diff --git a/packages/dd-trace/src/llmobs/index.js b/packages/dd-trace/src/llmobs/index.js index 9901d96b08a..47307ba2929 100644 --- a/packages/dd-trace/src/llmobs/index.js +++ b/packages/dd-trace/src/llmobs/index.js @@ -3,7 +3,9 @@ const { channel } = require('dc-polyfill') const { readDatadogTags, writeDatadogTags } = require('../carrier') +const { registerTelemetryFlusher } = require('../flush') const log = require('../log') +const { createServerlessDeliveryTracker } = require('../serverless') const { DD_MAJOR } = require('../../../../version') const startupLogs = require('../startup-log') const { @@ -57,6 +59,8 @@ let spanWriter /** @type {LLMObsEvalMetricsWriter | null} */ let evalWriter +let unregisterTelemetryFlusher + /** @type {import('../config/config-base')} */ let globalTracerConfig @@ -66,23 +70,36 @@ let globalTracerConfig function enable (config) { globalTracerConfig = config + const retiredSpanWriter = spanWriter + const retiredEvalWriter = evalWriter + const isReinitializing = Boolean(retiredSpanWriter || retiredEvalWriter) + unregisterTelemetryFlusher?.() + retireWriters(retiredSpanWriter, retiredEvalWriter) + const startTime = performance.now() // create writers and eval writer append and flush channels // span writer append is handled by the span processor evalWriter = new LLMObsEvalMetricsWriter(config) spanWriter = new LLMObsSpanWriter(config) + const currentEvalWriter = evalWriter + const currentSpanWriter = spanWriter + unregisterTelemetryFlusher = registerTelemetryFlusher(done => { + flushWriters(done, currentSpanWriter, currentEvalWriter) + }) - evalMetricAppendCh.subscribe(handleEvalMetricAppend) - flushCh.subscribe(handleFlush) - registerUserSpanProcessorCh.subscribe(handleRegisterProcessor) + if (!isReinitializing) { + evalMetricAppendCh.subscribe(handleEvalMetricAppend) + flushCh.subscribe(handleFlush) + registerUserSpanProcessorCh.subscribe(handleRegisterProcessor) + } // span processing spanProcessor = new LLMObsSpanProcessor(config) spanProcessor.setWriter(spanWriter) - spanFinishCh.subscribe(handleSpanProcess) + if (!isReinitializing) spanFinishCh.subscribe(handleSpanProcess) // distributed tracing for llmobs - injectCh.subscribe(handleLLMObsInjection) + if (!isReinitializing) injectCh.subscribe(handleLLMObsInjection) setAgentStrategy(config, useAgentless => { if (useAgentless && !(config.DD_API_KEY && config.site)) { @@ -94,8 +111,10 @@ function enable (config) { } } - evalWriter?.setAgentless(useAgentless) - spanWriter?.setAgentless(useAgentless) + // A disable can happen while transport selection is still pending. Keep + // configuring these writers so their queued lifecycle flushes can drain. + currentEvalWriter.setAgentless(useAgentless) + currentSpanWriter.setAgentless(useAgentless) telemetry.recordLLMObsEnabled(startTime, config) log.debug('[LLMObs] Enabled LLM Observability with configuration: %o', config.llmobs) @@ -109,16 +128,39 @@ function disable () { if (injectCh.hasSubscribers) injectCh.unsubscribe(handleLLMObsInjection) if (registerUserSpanProcessorCh.hasSubscribers) registerUserSpanProcessorCh.unsubscribe(handleRegisterProcessor) - spanWriter?.destroy() - evalWriter?.destroy() + const retiredSpanWriter = spanWriter + const retiredEvalWriter = evalWriter spanProcessor?.setWriter(null) + unregisterTelemetryFlusher?.() + unregisterTelemetryFlusher = undefined spanWriter = null evalWriter = null + retireWriters(retiredSpanWriter, retiredEvalWriter) + log.debug('[LLMObs] Disabled LLM Observability') } +/** + * Keeps retired writers reachable until their destroy-triggered deliveries complete. + * @param {LLMObsSpanWriter | null} retiredSpanWriter + * @param {LLMObsEvalMetricsWriter | null} retiredEvalWriter + * @returns {void} + */ +function retireWriters (retiredSpanWriter, retiredEvalWriter) { + const retiredWriters = [retiredSpanWriter, retiredEvalWriter].filter(Boolean) + if (retiredWriters.length === 0) return + let remainingWriters = retiredWriters.length + const unregisterRetiredFlusher = registerTelemetryFlusher(done => { + flushWriters(done, retiredSpanWriter, retiredEvalWriter) + }) + function onWriterDestroyed () { + if (--remainingWriters === 0) unregisterRetiredFlusher?.() + } + for (const writer of retiredWriters) writer.destroy(onWriterDestroyed) +} + // since LLMObs traces can extend between services and be the same trace, // we need to propagate the parent id, mlApp, session id, and sampling rate/decision. function handleLLMObsInjection ({ carrier }) { @@ -192,15 +234,36 @@ function handleLLMObsInjection ({ carrier }) { if (tags !== existing) writeDatadogTags(carrier, tags) } -function handleFlush () { - let err = '' - try { - spanWriter.flush() - evalWriter.flush() - } catch (e) { - err = 'writer_flush_error' - log.warn('Failed to flush LLMObs spans and evaluation metrics:', e.message) +/** + * Flushes the specified LLMObs writers and joins deliveries active at the boundary. + * @param {Function} [done] + * @param {LLMObsSpanWriter | null} [currentSpanWriter] + * @param {LLMObsEvalMetricsWriter | null} [currentEvalWriter] + * @returns {boolean} `true` when a writer throws synchronously. + */ +function flushWriters (done, currentSpanWriter = spanWriter, currentEvalWriter = evalWriter) { + let failed = false + const deliveryTracker = createServerlessDeliveryTracker() + const flush = writer => { + try { + if (deliveryTracker && writer) deliveryTracker.track(complete => writer.flush(complete)) + // Non-serverless flushes retain the existing writer behavior. + else writer?.flush() + } catch (error) { + failed = true + log.warn('Failed to flush LLMObs writer:', error.message) + } } + + flush(currentSpanWriter) + flush(currentEvalWriter) + deliveryTracker?.waitForIdle(done) + if (!deliveryTracker) done?.() + return failed +} + +function handleFlush () { + const err = flushWriters() ? 'writer_flush_error' : '' telemetry.recordUserFlush(err) } diff --git a/packages/dd-trace/src/llmobs/noop.js b/packages/dd-trace/src/llmobs/noop.js index ebe738da823..0c40dd62547 100644 --- a/packages/dd-trace/src/llmobs/noop.js +++ b/packages/dd-trace/src/llmobs/noop.js @@ -1,6 +1,6 @@ 'use strict' -const NoopExperiments = require('./experiments/noop') +let NoopExperiments class NoopLLMObs { constructor (noopTracer) { @@ -12,6 +12,7 @@ class NoopLLMObs { } get experiments () { + NoopExperiments ??= require('./experiments/noop') return new NoopExperiments('LLM Observability is not enabled') } diff --git a/packages/dd-trace/src/llmobs/plugins/anthropic/util.js b/packages/dd-trace/src/llmobs/plugins/anthropic/util.js index a5948f6d257..8fcc93320b2 100644 --- a/packages/dd-trace/src/llmobs/plugins/anthropic/util.js +++ b/packages/dd-trace/src/llmobs/plugins/anthropic/util.js @@ -38,16 +38,20 @@ function formatAnthropicToolResultContent (content) { if (typeof content === 'string') { return content } else if (Array.isArray(content)) { - const formattedContent = [] + let formattedContent = '' for (const toolResultBlock of content) { + let part if (toolResultBlock.text) { - formattedContent.push(toolResultBlock.text) + part = toolResultBlock.text } else if (toolResultBlock.type === 'image') { - formattedContent.push('([IMAGE DETECTED])') + part = '([IMAGE DETECTED])' } + if (part === undefined) continue + if (formattedContent) formattedContent += ',' + formattedContent += part } - return formattedContent.join(',') + return formattedContent } return JSON.stringify(content) } diff --git a/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js b/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js index ca6ccfcefad..7d42fc8ba7f 100644 --- a/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +++ b/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js @@ -18,12 +18,15 @@ function getToolOutputText (raw) { if (raw == null) return if (Array.isArray(raw)) { - const output = [] + let output = '' + let separator = '' for (const block of raw) { const text = getToolOutputText(block) - if (text) output.push(text) + if (!text) continue + output += separator + text + separator = '\n' } - return output.join('\n') || undefined + return output } if (raw.type === 'tool_result') return getToolOutputText(raw.content) diff --git a/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js b/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js index 4af8e5b1778..e2a90f60cbc 100644 --- a/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js +++ b/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js @@ -11,23 +11,23 @@ const { safeJsonParse } = require('../../util') * @returns {{ content: string, audioParts: Array<{ mimeType: string, content: string }> }} */ function extractMessageContent (parts) { - const contentParts = [] + let content = '' const audioParts = [] for (const part of parts) { if (!part) continue const text = extractTextFromContentItem(part) if (text) { - contentParts.push(text) + content += text continue } const extracted = extractContentParts([part]) - if (extracted.content) contentParts.push(extracted.content) + if (extracted.content) content += extracted.content if (extracted.audioParts.length > 0) audioParts.push(...extracted.audioParts) } - return { content: contentParts.join(''), audioParts } + return { content, audioParts } } /** @@ -167,13 +167,11 @@ function extractOutputMessages (result) { if (item.type === 'message') { let content = '' if (Array.isArray(item.content)) { - const textParts = [] for (const contentItem of item.content) { if (contentItem?.type === 'output_text' && contentItem.text) { - textParts.push(contentItem.text) + content += contentItem.text } } - content = textParts.join('') } else if (typeof item.content === 'string') { content = item.content } diff --git a/packages/dd-trace/src/llmobs/plugins/openai/utils.js b/packages/dd-trace/src/llmobs/plugins/openai/utils.js index 17a10a12ccc..c7ab8233084 100644 --- a/packages/dd-trace/src/llmobs/plugins/openai/utils.js +++ b/packages/dd-trace/src/llmobs/plugins/openai/utils.js @@ -134,15 +134,17 @@ function hasMultimodalInputs (variables) { * @returns {{ content: string, audioParts: Array<{ mimeType: string, content: string }> }} */ function extractContentParts (parts) { - const extracted = [] + let content = '' + let hasContent = false const audioParts = [] for (const part of parts) { const partType = part?.type ?? '' + let extracted if (partType === 'text') { - extracted.push(part.text ?? '') + extracted = part.text ?? '' } else if (partType === 'image_url') { - extracted.push(IMAGE_FALLBACK) + extracted = IMAGE_FALLBACK } else if (partType === 'input_audio') { const inputAudio = part.input_audio ?? {} const data = inputAudio.data @@ -151,14 +153,19 @@ function extractContentParts (parts) { // is needed. Only fall back to "[audio]" when there's no audio to capture. audioParts.push(formatAudioPart(data, audioMimeTypeFromFormat(inputAudio.format, AUDIO_MIME_TYPES))) } else { - extracted.push(AUDIO_FALLBACK) + extracted = AUDIO_FALLBACK } } else { - extracted.push(`[${partType}]`) + extracted = `[${partType}]` } + + if (extracted === undefined) continue + if (hasContent) content += '\n' + content += extracted + hasContent = true } - return { content: extracted.join('\n'), audioParts } + return { content, audioParts } } /** diff --git a/packages/dd-trace/src/llmobs/writers/base.js b/packages/dd-trace/src/llmobs/writers/base.js index c35e67ab4c2..91ae3ab77dd 100644 --- a/packages/dd-trace/src/llmobs/writers/base.js +++ b/packages/dd-trace/src/llmobs/writers/base.js @@ -4,6 +4,7 @@ const { URL, format } = require('node:url') const path = require('node:path') const request = require('../../exporters/common/request') const { getEnvironmentVariable } = require('../../config/helper') +const { createServerlessDeliveryTracker } = require('../../serverless') const logger = require('../../log') @@ -33,6 +34,9 @@ class LLMObsBuffer { class BaseLLMObsWriter { #destroyer + /** @type {Function[]} */ + #pendingFlushes = [] + #serverlessDeliveryTracker = createServerlessDeliveryTracker() /** @type {Map} */ #multiTenantBuffers = new Map() @@ -111,35 +115,52 @@ class BaseLLMObsWriter { return true } - flush () { + /** + * Drains buffered events and joins requests active at this flush boundary. + * @param {Function} [done] + */ + flush (done) { if (this._agentless == null) { + if (done) this.#pendingFlushes.push(done) return } - // Flush default buffer - if (this._buffer.events.length > 0) { - const events = this._buffer.events - this._buffer.clear() - - const payload = this._encode(this.makePayload(events)) + const requests = this.#drainBuffers() + if (!this.#serverlessDeliveryTracker) { + // Only invocation-retaining platforms need completion-aware delivery. + for (const request of requests) this.#sendSafely(request) + done?.() + return + } - log.debug('Encoded LLMObs payload: %s', payload) + for (const request of requests) this.#sendSafely(request) + this.#serverlessDeliveryTracker.waitForIdle(done) + } - const options = this._getOptions() + #sendSafely (requestToSend) { + try { + if (this.#serverlessDeliveryTracker) { + this.#serverlessDeliveryTracker.track(done => this.#send(requestToSend, done)) + } else { + this.#send(requestToSend) + } + } catch (error) { + logger.error('Failed to send LLMObs %s events: %s', this._eventType, error.message) + } + } - request(payload, options, (err, resp, code) => { - parseResponseAndLog(err, code, events.length, this.url, this._eventType) - }) + #drainBuffers () { + const requests = [] + if (this._buffer.events.length > 0) { + const events = this._buffer.events + this._buffer.clear() + requests.push({ events, options: this._getOptions(), url: this.url }) } - // Flush multi-tenant buffers for (const [apiKey, buffer] of this.#multiTenantBuffers) { if (buffer.events.length === 0) continue - const events = buffer.events buffer.clear() - - const payload = this._encode(this.makePayload(events)) const site = buffer.routing.site || this._config.site const options = { headers: { @@ -156,16 +177,21 @@ class BaseLLMObsWriter { } const url = this.#buildUrl(options.url.href, options.path) const maskedApiKey = apiKey ? `****${apiKey.slice(-4)}` : '' - log.debug('Encoding and flushing multi-tenant buffer for %s', maskedApiKey) - log.debug('Encoded LLMObs payload: %s', payload) - - request(payload, options, (err, resp, code) => { - parseResponseAndLog(err, code, events.length, url, this._eventType) - }) + requests.push({ events, options, url }) } this.#cleanupEmptyBuffers() + return requests + } + + #send ({ events, options, url }, done) { + const payload = this._encode(this.makePayload(events)) + log.debug('Encoded LLMObs payload: %s', payload) + request(payload, options, (err, resp, code) => { + parseResponseAndLog(err, code, events.length, url, this._eventType) + done?.() + }) } #cleanupEmptyBuffers () { @@ -178,13 +204,19 @@ class BaseLLMObsWriter { makePayload (events) {} - destroy () { + /** + * Stops periodic flushing and drains buffered events. + * @param {Function} [done] Called after queued and active deliveries complete. + */ + destroy (done) { if (this.#destroyer) { logger.debug(`Stopping ${this.constructor.name}`) clearInterval(this._periodic) globalThis[Symbol.for('dd-trace')].beforeExitHandlers.delete(this.#destroyer) - this.flush() + this.flush(done) this.#destroyer = undefined + } else { + done?.() } } @@ -196,6 +228,10 @@ class BaseLLMObsWriter { this._endpoint = endpoint logger.debug(`Configuring ${this.constructor.name} to ${this.url}`) + + const pendingFlushes = this.#pendingFlushes + this.#pendingFlushes = [] + for (const done of pendingFlushes) this.flush(done) } _getUrlAndPath () { diff --git a/packages/dd-trace/src/noop/span.js b/packages/dd-trace/src/noop/span.js index 0aa191c8f34..59fcce6732d 100644 --- a/packages/dd-trace/src/noop/span.js +++ b/packages/dd-trace/src/noop/span.js @@ -26,6 +26,11 @@ class NoopSpan { addLink (link) { return this } addLinks (links) { return this } addSpanPointer (ptrKind, ptrDir, ptrHash) { return this } + /** + * @param {import('../../../../index').Exception} exception + * @param {import('../../../../index').SpanEventAttributes} [attributes] + */ + recordException (exception, attributes) {} log () { return this } logEvent () {} finish (finishTime) {} diff --git a/packages/dd-trace/src/openfeature/writers/exposures.js b/packages/dd-trace/src/openfeature/writers/exposures.js index c41d344d71f..a7f5bdf50b7 100644 --- a/packages/dd-trace/src/openfeature/writers/exposures.js +++ b/packages/dd-trace/src/openfeature/writers/exposures.js @@ -37,6 +37,7 @@ const PENDING_MAX_EVENTS = 1000 * @property {string} flag.key - Flag key * @property {object} variant - Variant information * @property {string} variant.key - Variant key + * @property {number} [serial_id] - Serial id of the split the subject landed in * @property {object} subject - Subject (user/entity) information * @property {string} subject.id - Subject identifier * @property {string} [subject.type] - Subject type @@ -204,7 +205,7 @@ class ExposuresWriter extends BaseFFEWriter { makePayload (events) { const formattedEvents = events.map(event => { /** @type {ExposureEvent} */ - return { + const formatted = { timestamp: event.timestamp || Date.now(), allocation: { key: event.allocation?.key || event['allocation.key'], @@ -221,6 +222,12 @@ class ExposuresWriter extends BaseFFEWriter { attributes: event.subject?.attributes, }, } + + if (typeof event.serial_id === 'number') { + formatted.serial_id = event.serial_id + } + + return formatted }) return { diff --git a/packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js b/packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js index 46e8ba6c16a..ce14685aee6 100644 --- a/packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js +++ b/packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js @@ -1,5 +1,8 @@ 'use strict' +const log = require('../../log') +const { createServerlessDeliveryTracker } = require('../../serverless') + /** * @typedef {import('@opentelemetry/api-logs').LogRecord} LogRecord * @typedef {import('@opentelemetry/core').InstrumentationScope} InstrumentationScope @@ -54,10 +57,48 @@ class BatchLogRecordProcessor { /** * Forces an immediate flush of all pending log records. - * @returns {undefined} Promise that resolves when flush is complete + * @param {Function} [done] Called after all pending log exports complete */ - forceFlush () { - this.#export() + forceFlush (done) { + this.#clearTimer() + + const deliveryTracker = createServerlessDeliveryTracker() + if (!deliveryTracker) { + // Normal processes preserve the existing fire-and-forget batch flush. + this.#export() + done?.() + return + } + + // Flush only records present at this boundary. New records belong to the + // later request that produced them and must not extend this lifecycle flush. + const logRecords = this.#logRecords + this.#logRecords = [] + + // Join exports already active at this boundary before draining this snapshot. + if (typeof this.exporter.flush === 'function') { + deliveryTracker.track(complete => this.exporter.flush(complete)) + } + + deliveryTracker.track(complete => { + const flushNext = () => { + if (logRecords.length === 0) { + complete() + return + } + + // Drain the boundary snapshot one batch at a time. + const batch = logRecords.splice(0, this.#maxExportBatchSize) + try { + this.exporter.export(batch, flushNext) + } catch (error) { + log.error('Error exporting OTLP logs:', error) + complete() + } + } + flushNext() + }) + deliveryTracker.waitForIdle(done) } /** @@ -79,6 +120,7 @@ class BatchLogRecordProcessor { * @private */ #export () { + if (this.#logRecords.length === 0) return const logRecords = this.#logRecords.slice(0, this.#maxExportBatchSize) this.#logRecords = this.#logRecords.slice(this.#maxExportBatchSize) diff --git a/packages/dd-trace/src/opentelemetry/logs/index.js b/packages/dd-trace/src/opentelemetry/logs/index.js index d6a40ad0122..3fe9181c8de 100644 --- a/packages/dd-trace/src/opentelemetry/logs/index.js +++ b/packages/dd-trace/src/opentelemetry/logs/index.js @@ -27,6 +27,7 @@ const os = require('os') * @package */ +const { registerTelemetryFlusher } = require('../../flush') const LoggerProvider = require('./logger_provider') const BatchLogRecordProcessor = require('./batch_log_processor') const OtlpHttpLogExporter = require('./otlp_http_log_exporter') @@ -77,8 +78,10 @@ function initializeOpenTelemetryLogs (config) { // Create logger provider with processor for Datadog Agent export const loggerProvider = new LoggerProvider({ processor }) - // Register the logger provider globally with OpenTelemetry API + // Expose this provider to application calls through the OpenTelemetry Logs API. loggerProvider.register() + // Include final log batches in lifecycle retention with trace delivery. + registerTelemetryFlusher(done => loggerProvider.forceFlush(done)) } module.exports = { diff --git a/packages/dd-trace/src/opentelemetry/logs/logger_provider.js b/packages/dd-trace/src/opentelemetry/logs/logger_provider.js index 820d3da574f..81a2cf340a4 100644 --- a/packages/dd-trace/src/opentelemetry/logs/logger_provider.js +++ b/packages/dd-trace/src/opentelemetry/logs/logger_provider.js @@ -84,12 +84,15 @@ class LoggerProvider { /** * Forces a flush of all pending log records. - * @returns {undefined} Promise that resolves when flush is n ssue cncomplete + * @param {Function} [done] Called after all pending log exports complete */ - forceFlush () { - if (!this.isShutdown) { - return this.processor?.forceFlush() + forceFlush (done) { + if (this.isShutdown || !this.processor) { + done?.() + return } + + this.processor.forceFlush(done) } /** diff --git a/packages/dd-trace/src/opentelemetry/metrics/index.js b/packages/dd-trace/src/opentelemetry/metrics/index.js index 20c6d424f06..ef665a4b3fe 100644 --- a/packages/dd-trace/src/opentelemetry/metrics/index.js +++ b/packages/dd-trace/src/opentelemetry/metrics/index.js @@ -6,12 +6,12 @@ const { metrics } = require('@opentelemetry/api') const { VERSION } = require('../../../../../version') const processTags = require('../../process-tags') +const { registerTelemetryFlusher } = require('../../flush') const MeterProvider = require('./meter_provider') const PeriodicMetricReader = require('./periodic_metric_reader') const OtlpHttpMetricExporter = require('./otlp_http_metric_exporter') const RESERVED_TRACER_TAGS = new Set(['service', 'env', 'version', 'runtime_id', 'runtime-id']) - /** * @typedef {import('../../config')} Config */ @@ -78,6 +78,8 @@ function initializeOpenTelemetryMetrics (config) { const meterProvider = new MeterProvider({ reader }) metrics.setGlobalMeterProvider(meterProvider) + // Include the final metric collection and export in lifecycle retention. + registerTelemetryFlusher(done => meterProvider.forceFlush(done)) } /** diff --git a/packages/dd-trace/src/opentelemetry/metrics/meter_provider.js b/packages/dd-trace/src/opentelemetry/metrics/meter_provider.js index ebc9eeb1910..53cfcb20a57 100644 --- a/packages/dd-trace/src/opentelemetry/metrics/meter_provider.js +++ b/packages/dd-trace/src/opentelemetry/metrics/meter_provider.js @@ -49,6 +49,14 @@ class MeterProvider { } return meter } + + /** + * @param {Function} [done] Called after the metric export completes + */ + forceFlush (done) { + if (this.reader) this.reader.forceFlush(done) + else done?.() + } } module.exports = MeterProvider diff --git a/packages/dd-trace/src/opentelemetry/metrics/otlp_http_metric_exporter.js b/packages/dd-trace/src/opentelemetry/metrics/otlp_http_metric_exporter.js index 8af42b70854..3bd2b305d03 100644 --- a/packages/dd-trace/src/opentelemetry/metrics/otlp_http_metric_exporter.js +++ b/packages/dd-trace/src/opentelemetry/metrics/otlp_http_metric_exporter.js @@ -34,10 +34,11 @@ class OtlpHttpMetricExporter extends OtlpHttpExporterBase { * * @param {Map} metrics - Map of metric data to export * - * @returns {void} + * @param {Function} [done] Called after the HTTP export completes */ - export (metrics) { + export (metrics, done) { if (metrics.size === 0) { + done?.({ code: 0 }) return } @@ -56,6 +57,7 @@ class OtlpHttpMetricExporter extends OtlpHttpExporterBase { if (result.code === 0) { this.recordTelemetry('otel.metrics_export_successes', 1, additionalTags) } + done?.(result) }) } } diff --git a/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_exporter.js b/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_exporter.js index b7809e00ffd..890f1ba1953 100644 --- a/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_exporter.js +++ b/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_exporter.js @@ -22,14 +22,16 @@ class OtlpStatsExporter extends OtlpHttpExporterBase { /** * @param {Array<{timeNs: number, bucket: import('../../span_stats').SpanBuckets}>} drained * @param {number} bucketSizeNs + * @param {Function} [done] Called after the HTTP export completes */ - export (drained, bucketSizeNs) { - if (drained.length === 0) return + export (drained, bucketSizeNs, done) { + if (drained.length === 0) return done?.() const payload = this.#transformer.transform(drained, bucketSizeNs) this.sendPayload(payload, (result) => { if (result.code !== 0) { log.error('Failed to export span stats: %s', result.error?.message) } + done?.() }) } } diff --git a/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js b/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js index a97b5cb6c99..3bdf1408f80 100644 --- a/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js +++ b/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js @@ -197,14 +197,33 @@ class PeriodicMetricReader { /** * Forces an immediate collection and export of all metrics. - * @returns {void} + * @param {Function} [done] Called after the metric export completes */ - forceFlush () { + forceFlush (done) { if (this.#isShutdown) { log.warn('PeriodicMetricReader is shutdown. %d measurement(s) were dropped', this.#droppedCount) + done?.() return } - this.#collectAndExport() + let pending = 2 + const complete = () => { + if (--pending === 0) done?.() + } + + // Snapshot requests already active before starting this flush's export. + try { + if (typeof this.exporter.flush === 'function') this.exporter.flush(complete) + else complete() + } catch (error) { + log.error('Error flushing OTLP metrics:', error) + complete() + } + try { + this.#collectAndExport(complete) + } catch (error) { + log.error('Error exporting OTLP metrics:', error) + complete() + } } /** @@ -250,7 +269,8 @@ class PeriodicMetricReader { * * @param {Function} [callback] - Called after export completes */ - #collectAndExport (callback = () => {}) { + #collectAndExport (callback) { + // Observable instruments must be collected even without synchronous measurements. // Atomically drain measurements for export. New measurements can be recorded // during export without interfering with this batch. const allMeasurements = this.#measurements @@ -292,7 +312,7 @@ class PeriodicMetricReader { } if (allMeasurements.length === 0) { - callback() + callback?.() return } diff --git a/packages/dd-trace/src/opentelemetry/otlp/otlp_http_exporter_base.js b/packages/dd-trace/src/opentelemetry/otlp/otlp_http_exporter_base.js index fe27cb643dd..b5034396c7d 100644 --- a/packages/dd-trace/src/opentelemetry/otlp/otlp_http_exporter_base.js +++ b/packages/dd-trace/src/opentelemetry/otlp/otlp_http_exporter_base.js @@ -5,6 +5,7 @@ const https = require('node:https') const { URL } = require('node:url') const { storage } = require('../../../../datadog-core') const log = require('../../log') +const { createServerlessDeliveryTracker } = require('../../serverless') const telemetryMetrics = require('../../telemetry/metrics') const tracerMetrics = telemetryMetrics.manager.namespace('tracers') @@ -20,6 +21,7 @@ const legacyStorage = storage('legacy') */ class OtlpHttpExporterBase { #transport = https + #serverlessDeliveryTracker /** * Creates a new OtlpHttpExporterBase instance. @@ -32,6 +34,7 @@ class OtlpHttpExporterBase { * @param {string} signalType - Signal type for error messages (e.g., 'logs', 'metrics') */ constructor (url, headers, timeout, protocol, signalType) { + this.#serverlessDeliveryTracker = createServerlessDeliveryTracker() this.protocol = protocol this.signalType = signalType @@ -80,6 +83,13 @@ class OtlpHttpExporterBase { * @protected */ sendPayload (payload, resultCallback) { + if (this.#serverlessDeliveryTracker) { + return this.#serverlessDeliveryTracker.track(done => this.#sendPayload(payload, resultCallback, done)) + } + this.#sendPayload(payload, resultCallback) + } + + #sendPayload (payload, resultCallback, done) { const options = { ...this.options, headers: { @@ -88,39 +98,65 @@ class OtlpHttpExporterBase { }, } - legacyStorage.run({ noop: true }, () => { - const req = this.#transport.request(options, (res) => { - let data = '' + let completed = false + const complete = result => { + if (completed) return + completed = true + resultCallback(result) + done?.() + } - res.on('data', (chunk) => { - data += chunk + try { + legacyStorage.run({ noop: true }, () => { + const req = this.#transport.request(options, (res) => { + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.once('error', (error) => { + complete({ code: 1, error }) + }) + + res.once('end', () => { + // @ts-expect-error - res.statusCode can be undefined + if (res.statusCode >= 200 && res.statusCode < 300) { + complete({ code: 0 }) + } else { + const error = new Error(`HTTP ${res.statusCode}: ${data}`) + complete({ code: 1, error }) + } + }) }) - res.once('end', () => { - // @ts-expect-error - res.statusCode can be undefined - if (res.statusCode >= 200 && res.statusCode < 300) { - resultCallback({ code: 0 }) - } else { - const error = new Error(`HTTP ${res.statusCode}: ${data}`) - resultCallback({ code: 1, error }) - } + req.on('error', (error) => { + log.error('Error sending OTLP %s:', this.signalType, error) + complete({ code: 1, error }) }) - }) - req.on('error', (error) => { - log.error('Error sending OTLP %s:', this.signalType, error) - resultCallback({ code: 1, error }) - }) + req.once('timeout', () => { + req.destroy() + const error = new Error('Request timeout') + complete({ code: 1, error }) + }) - req.once('timeout', () => { - req.destroy() - const error = new Error('Request timeout') - resultCallback({ code: 1, error }) + req.write(payload) + req.end() }) + } catch (error) { + log.error('Error sending OTLP %s:', this.signalType, error) + complete({ code: 1, error }) + } + } - req.write(payload) - req.end() - }) + /** + * Calls back once Vercel-tracked requests active at the flush boundary complete. + * @param {Function} [done] + */ + flush (done) { + if (this.#serverlessDeliveryTracker) return this.#serverlessDeliveryTracker.waitForIdle(done) + done?.() } /** diff --git a/packages/dd-trace/src/opentelemetry/tracer.js b/packages/dd-trace/src/opentelemetry/tracer.js index 5ddfb39a437..3f15c315fbd 100644 --- a/packages/dd-trace/src/opentelemetry/tracer.js +++ b/packages/dd-trace/src/opentelemetry/tracer.js @@ -3,17 +3,41 @@ const api = require('@opentelemetry/api') const { sanitizeAttributes } = require('../../../../vendor/dist/@opentelemetry/core') +const { AUTO_KEEP, AUTO_REJECT } = require('../../../../ext/priority') const tracer = require('../../') const id = require('../id') const log = require('../log') -const TextMapPropagator = require('../opentracing/propagation/text_map') const TraceState = require('../opentracing/propagation/tracestate') const SpanContext = require('./span_context') const Span = require('./span') const Sampler = require('./sampler') const { normalizeLinkContext } = require('./span-helpers') +/** + * @param {number} traceparentSampled + * @param {number | undefined} tracestateSamplingPriority + * @param {string | null} origin + * @returns {import('../priority_sampler').SamplingPriority} + */ +function getSamplingPriority (traceparentSampled, tracestateSamplingPriority, origin) { + const fromRumWithoutPriority = tracestateSamplingPriority === undefined && origin === 'rum' + + let samplingPriority = + /** @type {import('../priority_sampler').SamplingPriority} */ (tracestateSamplingPriority ?? AUTO_KEEP) + if (!fromRumWithoutPriority) { + if (traceparentSampled === 0 && + (!tracestateSamplingPriority || tracestateSamplingPriority >= 0)) { + samplingPriority = AUTO_REJECT + } else if (traceparentSampled === 1 && + (!tracestateSamplingPriority || tracestateSamplingPriority < 0)) { + samplingPriority = AUTO_KEEP + } + } + + return samplingPriority +} + class Tracer { constructor (library, config, tracerProvider) { this._sampler = new Sampler() @@ -76,7 +100,7 @@ class Tracer { Object.assign(meta, otherPropagatedTags) // Guard against an undefined/empty `s:` field that would result in NaN. const tracestateSamplingPriority = samplingPriorityTs ? Math.trunc(samplingPriorityTs) : undefined - samplingPriority = TextMapPropagator._getSamplingPriority(traceFlag, tracestateSamplingPriority, origin) + samplingPriority = getSamplingPriority(traceFlag, tracestateSamplingPriority, origin) } else { log.debug('No dd list member in tracestate from incoming request:', ts) } diff --git a/packages/dd-trace/src/opentracing/propagation/text_map.js b/packages/dd-trace/src/opentracing/propagation/text_map.js index d79749edacd..8aa93f686f8 100644 --- a/packages/dd-trace/src/opentracing/propagation/text_map.js +++ b/packages/dd-trace/src/opentracing/propagation/text_map.js @@ -66,15 +66,6 @@ const tagValueExpr = /^[\x20-\x2B\x2D-\x7E]*$/ // ASCII minus commas // https://github.com/nodejs/node/blob/main/lib/_http_common.js const invalidHeaderValueCharExpr = /[^\t\x20-\x7E\x80-\xFF]/ const traceparentExpr = /^([a-f0-9]{2})-([a-f0-9]{32})-([a-f0-9]{16})-([a-f0-9]{2})(-.*)?$/i -// Dispatch table for `_extractSpanContext`. `'b3'` resolves to the matching -// single/multi extractor per instance — see `#b3MethodName` — so it is not in -// this table. `'baggage'` is consumed by `_extractBaggageItems`, not the loop. -const EXTRACT_STYLE_METHODS = new Map([ - ['datadog', '_extractDatadogContext'], - ['tracecontext', '_extractTraceparentContext'], - ['b3 single header', '_extractB3SingleContext'], - ['b3multi', '_extractB3MultiContext'], -]) // Origin value in tracestate replaces '~', ',' and ';' with '_" const tracestateOriginFilter = /[^\x20-\x2B\x2D-\x3A\x3C-\x7D]/g // Tag keys in tracestate replace ' ', ',' and '=' with '_' @@ -86,35 +77,216 @@ const zeroTraceId = '0000000000000000' const hex16 = /^[0-9A-Fa-f]{16}$/ const percentByte = /%([0-9A-Fa-f]{2})/g +/** + * @typedef {object} B3Context + * @property {string} [flags] + * @property {string} [sampled] + * @property {string} [spanId] + * @property {string} [traceId] + */ + +/** + * @param {string | undefined} traceId + * @param {string | undefined} spanId + * @param {number} radix + * @returns {DatadogSpanContext | undefined} + */ +function extractGenericContext (traceId, spanId, radix) { + if (!traceId || invalidSegment.test(traceId)) return + if (!spanId) return + + return new DatadogSpanContext({ + traceId: id(traceId, radix), + spanId: id(spanId, radix), + isRemote: true, + }) +} + +/** + * @param {string} traceId + * @param {DatadogSpanContext} spanContext + * @returns {void} + */ +function extract128BitTraceId (traceId, spanContext) { + const buffer = spanContext._traceId.toBuffer() + + if (buffer.length !== 16) return + + const tid = traceId.slice(0, 16) + + if (tid === zeroTraceId) return + + spanContext._trace.tags['_dd.p.tid'] = tid +} + +/** + * @param {string | undefined} sampled + * @param {boolean} debug + * @returns {import('../../priority_sampler').SamplingPriority | undefined} + */ +function getB3Priority (sampled, debug) { + if (debug) { + return USER_KEEP + } else if (sampled === '1') { + return AUTO_KEEP + } else if (sampled === '0') { + return AUTO_REJECT + } +} + +/** + * @param {B3Context} b3 + * @returns {DatadogSpanContext | undefined} + */ +function extractB3Context (b3) { + const priority = getB3Priority(b3.sampled, b3.flags === '1') + const spanContext = extractGenericContext(b3.traceId, b3.spanId, 16) + + if (priority !== undefined) { + if (!spanContext) { + return new DatadogSpanContext({ + traceId: id(), + spanId: null, + sampling: { priority }, + isRemote: true, + }) + } + + spanContext._sampling.priority = priority + } + + if (spanContext && b3.traceId) extract128BitTraceId(b3.traceId, spanContext) + + return spanContext +} + +/** + * @param {Record} carrier + * @returns {B3Context | undefined} + */ +function extractB3MultipleHeaders (carrier) { + // Parent ID is intentionally not a standalone signal for B3 extraction. + const traceId = readB3TraceId(carrier) + const sampled = readB3Sampled(carrier) + const flags = readB3Flags(carrier) + + if (traceId === undefined && sampled === undefined && flags === undefined) return + + let empty = true + const b3 = {} + const spanId = readB3SpanId(carrier) + + if (traceId && spanId && b3TraceExpr.test(traceId) && b3SpanExpr.test(spanId)) { + b3.traceId = traceId + b3.spanId = spanId + empty = false + } + + if (sampled) { + b3.sampled = sampled + empty = false + } + + if (flags) { + b3.flags = flags + empty = false + } + + return empty ? undefined : b3 +} + +/** + * @param {string} header + * @returns {B3Context} + */ +function extractB3SingleHeader (header) { + const traceIdEnd = header.indexOf('-') + + if (traceIdEnd === -1) { + if (header === 'd') { + return { + sampled: '1', + flags: '1', + } + } + return { + sampled: header, + } + } + + const spanIdStart = traceIdEnd + 1 + const spanIdEnd = spanIdStart + 16 + const b3 = { + traceId: header.slice(0, traceIdEnd), + spanId: header.slice(spanIdStart, spanIdEnd), + } + + if (header.length > spanIdEnd) { + const sampled = header[spanIdEnd + 1] + b3.sampled = sampled === '0' ? '0' : '1' + + if (sampled === 'd') { + b3.flags = '1' + } + } + + return b3 +} + +/** + * @param {Record} carrier + * @returns {DatadogSpanContext | undefined} + */ +function extractB3MultiContext (carrier) { + const b3 = extractB3MultipleHeaders(carrier) + if (b3 === undefined) return + return extractB3Context(b3) +} + +/** + * @param {Record} carrier + * @returns {DatadogSpanContext | undefined} + */ +function extractB3SingleContext (carrier) { + // Resolve the value before running the regex on the common header-less path. + const header = readB3(carrier) + if (!header || !b3HeaderExpr.test(header)) return + return extractB3Context(extractB3SingleHeader(header)) +} + class TextMapPropagator { - /** @type {Set | undefined} Cached `Set` view of `_config.baggageTagKeys`. */ + /** @type {Set | undefined} Cached `Set` view of `#config.baggageTagKeys`. */ #baggageTagKeysSet /** @type {string[] | undefined} Source array that `#baggageTagKeysSet` was built from. */ #baggageTagKeysSetSource - /** @type {'_extractB3SingleContext' | '_extractB3MultiContext'} */ - #b3MethodName + /** @type {import('../../config')} */ + #config + + /** @type {typeof extractB3SingleContext | typeof extractB3MultiContext} */ + #extractB3Context + /** @param {import('../../config')} config */ constructor (config) { - this._config = config + this.#config = config - // v6: `'b3'` is always single-header. v5: `OTEL_PROPAGATORS` callers + // v6+: `'b3'` is always single-header. v5: `OTEL_PROPAGATORS` callers // expect single, legacy `DD_TRACE_PROPAGATION_STYLE` callers expect multi. - /* istanbul ignore else: v5 fallback, master ships 6.0.0-pre */ + /* istanbul ignore else: v5 fallback */ if (DD_MAJOR >= 6) { - this.#b3MethodName = '_extractB3SingleContext' + this.#extractB3Context = extractB3SingleContext } else { const envName = getConfiguredEnvName('DD_TRACE_PROPAGATION_STYLE') // eslint-disable-next-line eslint-rules/eslint-env-aliases - this.#b3MethodName = envName === 'OTEL_PROPAGATORS' - ? '_extractB3SingleContext' - : '_extractB3MultiContext' + this.#extractB3Context = envName === 'OTEL_PROPAGATORS' + ? extractB3SingleContext + : extractB3MultiContext } } /** - * Returns a `Set` view of `_config.baggageTagKeys` that is rebuilt only + * Returns a `Set` view of `#config.baggageTagKeys` that is rebuilt only * when the source array reference changes. Avoids an `O(n)` `Set` alloc * per baggage extract (which is per-request when baggage propagation is * enabled). @@ -122,7 +294,7 @@ class TextMapPropagator { * @returns {Set} */ #getBaggageTagKeysSet () { - const source = this._config.baggageTagKeys + const source = this.#config.baggageTagKeys if (this.#baggageTagKeysSetSource !== source) { this.#baggageTagKeysSet = new Set(source) this.#baggageTagKeysSetSource = source @@ -137,18 +309,18 @@ class TextMapPropagator { */ inject (spanContext, carrier) { if (carrier === null) return - let injectedCarrier = this._injectBaggageItems(spanContext, carrier) + let injectedCarrier = this.#injectBaggageItems(spanContext, carrier) if (!spanContext) return injectedCarrier - const injectTraceContext = this._config.apmTracingEnabled !== false || + const injectTraceContext = this.#config.apmTracingEnabled !== false || hasTraceSourcePropagationTag(spanContext._trace.tags) if (injectTraceContext) { - injectedCarrier = this._injectDatadog(spanContext, injectedCarrier ?? carrier) ?? injectedCarrier - injectedCarrier = this._injectB3MultipleHeaders(spanContext, injectedCarrier ?? carrier) ?? injectedCarrier - injectedCarrier = this._injectB3SingleHeader(spanContext, injectedCarrier ?? carrier) ?? injectedCarrier + injectedCarrier = this.#injectDatadog(spanContext, injectedCarrier ?? carrier) ?? injectedCarrier + injectedCarrier = this.#injectB3MultipleHeaders(spanContext, injectedCarrier ?? carrier) ?? injectedCarrier + injectedCarrier = this.#injectB3SingleHeader(spanContext, injectedCarrier ?? carrier) ?? injectedCarrier } injectedCarrier = this - ._injectTraceparent(spanContext, injectedCarrier ?? carrier, injectTraceContext) ?? injectedCarrier + .#injectTraceparent(spanContext, injectedCarrier ?? carrier, injectTraceContext) ?? injectedCarrier if (injectedCarrier === undefined) return @@ -161,8 +333,12 @@ class TextMapPropagator { return carrier } + /** + * @param {Record} carrier + * @returns {DatadogSpanContext | null} + */ extract (carrier) { - const spanContext = this._extractSpanContext(carrier) + const spanContext = this.#extractSpanContext(carrier) if (spanContext === undefined) return null if (extractCh.hasSubscribers) { @@ -172,7 +348,7 @@ class TextMapPropagator { // eslint-disable-next-line eslint-rules/eslint-log-printf-style log.debug(() => { const keys = JSON.stringify(pickTextMap(carrier)) - const styles = this._config.tracePropagationStyle.extract.join(', ') + const styles = this.#config.tracePropagationStyle.extract.join(', ') return `Extract from carrier (${styles}): ${keys}.` }) @@ -185,34 +361,22 @@ class TextMapPropagator { * @param {Record} [carrier] * @returns {Record | undefined} */ - _injectDatadog (spanContext, carrier) { - if (!this._hasPropagationStyle('inject', 'datadog')) return + #injectDatadog (spanContext, carrier) { + if (!this.#hasPropagationStyle('inject', 'datadog')) return carrier ??= {} writeDatadogTraceId(carrier, spanContext.toTraceId()) writeDatadogParentId(carrier, spanContext.toSpanId()) - this._injectOrigin(spanContext, carrier) - this._injectSamplingPriority(spanContext, carrier) - this._injectTags(spanContext, carrier) - - return carrier - } - - _injectOrigin (spanContext, carrier) { const origin = spanContext._trace.origin + if (origin) writeDatadogOrigin(carrier, origin) - if (origin) { - writeDatadogOrigin(carrier, origin) - } - } - - _injectSamplingPriority (spanContext, carrier) { const priority = spanContext._sampling.priority + if (Number.isInteger(priority)) writeDatadogSamplingPriority(carrier, priority.toString()) - if (Number.isInteger(priority)) { - writeDatadogSamplingPriority(carrier, priority.toString()) - } + this.#injectTags(spanContext, carrier) + + return carrier } /** @@ -220,9 +384,9 @@ class TextMapPropagator { * @param {Record} [carrier] * @returns {Record | undefined} */ - _injectBaggageItems (spanContext, carrier) { + #injectBaggageItems (spanContext, carrier) { let injectedCarrier - if (this._config.legacyBaggageEnabled) { + if (this.#config.legacyBaggageEnabled) { const baggageItems = spanContext?._baggageItems if (baggageItems) { for (const key of Object.keys(baggageItems)) { @@ -241,7 +405,7 @@ class TextMapPropagator { } } - if (this._hasPropagationStyle('inject', 'baggage')) { + if (this.#hasPropagationStyle('inject', 'baggage')) { let baggage = '' let itemCounter = 0 let byteCounter = 0 @@ -259,13 +423,13 @@ class TextMapPropagator { byteCounter += item.length // Check for item count limit exceeded - if (itemCounter > this._config.baggageMaxItems) { + if (itemCounter > this.#config.baggageMaxItems) { tracerMetrics.count('context_header.truncated', ['truncation_reason:baggage_item_count_exceeded']).inc() break } // Check for byte count limit exceeded - if (byteCounter > this._config.baggageMaxBytes) { + if (byteCounter > this.#config.baggageMaxBytes) { tracerMetrics.count('context_header.truncated', ['truncation_reason:baggage_byte_count_exceeded']).inc() break } @@ -284,30 +448,34 @@ class TextMapPropagator { return injectedCarrier } - _injectTags (spanContext, carrier) { + /** + * @param {DatadogSpanContext} spanContext + * @param {Record} carrier + * @returns {void} + */ + #injectTags (spanContext, carrier) { const trace = spanContext._trace - if (this._config.DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH === 0) { + if (this.#config.DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH === 0) { log.debug('Trace tag propagation is disabled, skipping injection.') return } - const tags = [] + let header = '' for (const key of Object.keys(trace.tags)) { const value = trace.tags[key] if (!value || !key.startsWith('_dd.p.')) continue - if (!this._validateTagKey(key) || !this._validateTagValue(value)) { + if (!tagKeyExpr.test(key) || !tagValueExpr.test(value)) { log.error('Trace tags from span are invalid, skipping injection.') return } - tags.push(`${key}=${value}`) + if (header) header += ',' + header += `${key}=${value}` } - const header = tags.join(',') - - if (header.length > this._config.DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH) { + if (header.length > this.#config.DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH) { log.error('Trace tags from span are too large, skipping injection.') } else if (header) { writeDatadogTags(carrier, header) @@ -319,10 +487,10 @@ class TextMapPropagator { * @param {Record} [carrier] * @returns {Record | undefined} */ - _injectB3MultipleHeaders (spanContext, carrier) { - // v5 also accepts the legacy `'b3'` spelling for multi; v6 routes `'b3'` to single-header. - const hasB3multi = this._hasPropagationStyle('inject', 'b3multi') || - (DD_MAJOR < 6 && this._hasPropagationStyle('inject', 'b3')) + #injectB3MultipleHeaders (spanContext, carrier) { + // v5 also accepts the legacy `'b3'` spelling for multi; v6+ routes `'b3'` to single-header. + const hasB3multi = this.#hasPropagationStyle('inject', 'b3multi') || + (DD_MAJOR < 6 && this.#hasPropagationStyle('inject', 'b3')) if (!hasB3multi) return carrier ??= {} @@ -346,10 +514,10 @@ class TextMapPropagator { * @param {Record} [carrier] * @returns {Record | undefined} */ - _injectB3SingleHeader (spanContext, carrier) { - // v6 keeps `'b3 single header'` as a back-compat alias for callers that bypass parser normalisation. - const hasB3SingleHeader = this._hasPropagationStyle('inject', 'b3 single header') || - (DD_MAJOR >= 6 && this._hasPropagationStyle('inject', 'b3')) + #injectB3SingleHeader (spanContext, carrier) { + // v6+ keeps `'b3 single header'` as a back-compat alias for callers that bypass parser normalisation. + const hasB3SingleHeader = this.#hasPropagationStyle('inject', 'b3 single header') || + (DD_MAJOR >= 6 && this.#hasPropagationStyle('inject', 'b3')) if (!hasB3SingleHeader) return carrier ??= {} @@ -372,8 +540,8 @@ class TextMapPropagator { * @param {boolean} injectTraceContext * @returns {Record | undefined} */ - _injectTraceparent (spanContext, carrier, injectTraceContext) { - if (!this._hasPropagationStyle('inject', 'tracecontext')) return + #injectTraceparent (spanContext, carrier, injectTraceContext) { + if (!this.#hasPropagationStyle('inject', 'tracecontext')) return if (!injectTraceContext) { const tracestate = TraceState.fromString(spanContext._tracestate?.toString()) @@ -437,53 +605,93 @@ class TextMapPropagator { return carrier } - _hasPropagationStyle (mode, name) { - return this._config.tracePropagationStyle[mode].includes(name) - } - - _hasTraceIdConflict (w3cSpanContext, firstSpanContext) { - return w3cSpanContext !== undefined && - firstSpanContext.toTraceId(true) === w3cSpanContext.toTraceId(true) && - firstSpanContext.toSpanId() !== w3cSpanContext.toSpanId() - } - - _hasParentIdInTags (spanContext) { - return tags.DD_PARENT_ID in spanContext._trace.tags - } - - _updateParentIdFromDdHeaders (carrier, firstSpanContext) { - const ddCtx = this._extractDatadogContext(carrier) - if (ddCtx !== undefined) { - firstSpanContext._trace.tags[tags.DD_PARENT_ID] = ddCtx._spanId.toString().padStart(16, '0') - } + /** + * @param {'inject' | 'extract'} mode + * @param {string} name + * @returns {boolean} + */ + #hasPropagationStyle (mode, name) { + return this.#config.tracePropagationStyle[mode].includes(name) } - _resolveTraceContextConflicts (w3cSpanContext, firstSpanContext, carrier) { - if (!this._hasTraceIdConflict(w3cSpanContext, firstSpanContext)) { + /** + * @param {DatadogSpanContext | undefined} w3cSpanContext + * @param {DatadogSpanContext} firstSpanContext + * @param {Record} carrier + * @param {DatadogSpanContext | undefined} datadogContext + * @returns {DatadogSpanContext} + */ + #resolveTraceContextConflicts (w3cSpanContext, firstSpanContext, carrier, datadogContext) { + if (w3cSpanContext === undefined || + firstSpanContext.toTraceId(true) !== w3cSpanContext.toTraceId(true) || + firstSpanContext.toSpanId() === w3cSpanContext.toSpanId()) { return firstSpanContext } - if (this._hasParentIdInTags(w3cSpanContext)) { + if (tags.DD_PARENT_ID in w3cSpanContext._trace.tags) { // tracecontext headers contain a p value, ensure this value is sent to backend firstSpanContext._trace.tags[tags.DD_PARENT_ID] = w3cSpanContext._trace.tags[tags.DD_PARENT_ID] } else { // if p value is not present in tracestate, use the parent id from the datadog headers - this._updateParentIdFromDdHeaders(carrier, firstSpanContext) + datadogContext ||= extractGenericContext(readDatadogTraceId(carrier), readDatadogParentId(carrier), 10) + if (datadogContext) { + firstSpanContext._trace.tags[tags.DD_PARENT_ID] = datadogContext._spanId.toString().padStart(16, '0') + } } // the span_id in tracecontext takes precedence over the first extracted propagation style firstSpanContext._spanId = w3cSpanContext._spanId return firstSpanContext } - _extractSpanContext (carrier) { + /** + * @param {Record} carrier + * @returns {DatadogSpanContext | undefined} + */ + #extractSpanContext (carrier) { let context + let datadogContext let style = '' - for (const extractor of this._config.tracePropagationStyle.extract) { - const method = extractor === 'b3' ? this.#b3MethodName : EXTRACT_STYLE_METHODS.get(extractor) - if (method === undefined) { - if (extractor !== 'baggage') log.warn('Unknown propagation style:', extractor) - continue + let extractBaggage = false + let traceContext + let traceContextExtracted = false + for (const extractor of this.#config.tracePropagationStyle.extract) { + let extractedContext + switch (extractor) { + case 'datadog': + datadogContext = this.#extractDatadogContext(carrier) + extractedContext = datadogContext + if (extractedContext !== undefined && !this.#config.DD_TRACE_PROPAGATION_EXTRACT_FIRST) { + if (!traceContextExtracted) { + traceContext = this.#extractTraceparentContext(carrier) + traceContextExtracted = true + } + this.#addTraceContextState(extractedContext, traceContext) + } + break + case 'tracecontext': + if (!traceContextExtracted) { + traceContext = this.#extractTraceparentContext(carrier) + traceContextExtracted = true + } + extractedContext = traceContext + break + case 'b3': + extractedContext = this.#extractB3Context(carrier) + break + case 'b3 single header': + extractedContext = extractB3SingleContext(carrier) + break + case 'b3multi': + extractedContext = extractB3MultiContext(carrier) + break + case 'baggage': + extractBaggage = true + continue + case 'none': + continue + default: + log.warn('Unknown propagation style:', extractor) + continue } - const extractedContext = this[method](carrier) if (extractedContext === undefined) { continue } @@ -491,14 +699,14 @@ class TextMapPropagator { if (context === undefined) { context = extractedContext style = extractor - if (this._config.DD_TRACE_PROPAGATION_EXTRACT_FIRST) { + if (this.#config.DD_TRACE_PROPAGATION_EXTRACT_FIRST) { break } } else { // If extractor is tracecontext, add tracecontext specific information to the context if (extractor === 'tracecontext') { - context = this._resolveTraceContextConflicts( - this._extractTraceparentContext(carrier), context, carrier) + context = this.#resolveTraceContextConflicts( + extractedContext, context, carrier, datadogContext) } if (extractedContext._traceId && extractedContext._spanId && extractedContext.toTraceId(true) !== context.toTraceId(true)) { @@ -511,10 +719,14 @@ class TextMapPropagator { } } - if (this._config.DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT === 'ignore') { + if (context && (style === 'datadog' || style === 'tracecontext')) { + this.#extractLegacyBaggageItems(carrier, context) + } + + if (this.#config.DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT === 'ignore') { if (context !== undefined) context._links = [] } else { - if (this._config.DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT === 'restart' && context) { + if (this.#config.DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT === 'restart' && context) { context._links = [] context._links.push({ context, @@ -524,78 +736,47 @@ class TextMapPropagator { }, }) } - this._extractBaggageItems(carrier, context) + if (!extractBaggage && this.#config.DD_TRACE_PROPAGATION_EXTRACT_FIRST) { + extractBaggage = this.#hasPropagationStyle('extract', 'baggage') + } + this.#extractBaggageItems(carrier, context, extractBaggage) } - return context || this._extractSqsdContext(carrier) + return context || this.#extractSqsdContext(carrier) } - _extractDatadogContext (carrier) { + /** + * @param {Record} carrier + * @returns {DatadogSpanContext | undefined} + */ + #extractDatadogContext (carrier) { if (!carrier) return - const spanContext = this._extractGenericContext( - readDatadogTraceId(carrier), - readDatadogParentId(carrier), - 10 - ) + const traceId = readDatadogTraceId(carrier) + if (!traceId) return + const spanContext = extractGenericContext(traceId, readDatadogParentId(carrier), 10) if (!spanContext) return spanContext - this._extractOrigin(carrier, spanContext) - this._extractLegacyBaggageItems(carrier, spanContext) - this._extractSamplingPriority(carrier, spanContext) - const traceTags = this._extractTags(carrier) - if (traceTags) spanContext._trace.tags = traceTags - - if (this._config.DD_TRACE_PROPAGATION_EXTRACT_FIRST) return spanContext - - const tc = this._extractTraceparentContext(carrier) - - if (tc && spanContext._traceId.equals(tc._traceId)) { - spanContext._traceparent = tc._traceparent - spanContext._tracestate = tc._tracestate - } - - return spanContext - } - - _extractB3MultiContext (carrier) { - const b3 = this._extractB3MultipleHeaders(carrier) - if (b3 === undefined) return - return this._extractB3Context(b3) - } - - _extractB3SingleContext (carrier) { - // Check the resolved value first so the regex does not run on header-less requests. - const header = readB3(carrier) - if (!header || !b3HeaderExpr.test(header)) return - return this._extractB3Context(this._extractB3SingleHeader(header)) - } - - _extractB3Context (b3) { - const debug = b3.flags === '1' - const priority = this._getPriority(b3.sampled, debug) - const spanContext = this._extractGenericContext(b3.traceId, b3.spanId, 16) - - if (priority !== undefined) { - if (!spanContext) { - // B3 can force a sampling decision without providing IDs - return new DatadogSpanContext({ - traceId: id(), - spanId: null, - sampling: { priority }, - isRemote: true, - }) - } + const origin = readDatadogOrigin(carrier) + if (typeof origin === 'string') spanContext._trace.origin = origin - spanContext._sampling.priority = priority + const header = readDatadogSamplingPriority(carrier) + if (header !== undefined) { + const priority = Number.parseInt(header, 10) + if (Number.isInteger(priority)) spanContext._sampling.priority = priority } - this._extract128BitTraceId(b3.traceId, spanContext) + const traceTags = this.#extractTags(carrier) + if (traceTags) spanContext._trace.tags = traceTags return spanContext } - _extractSqsdContext (carrier) { + /** + * @param {Record} carrier + * @returns {DatadogSpanContext | undefined} + */ + #extractSqsdContext (carrier) { const headerValue = readSqsd(carrier) if (!headerValue) return let parsed @@ -604,10 +785,32 @@ class TextMapPropagator { } catch { return } - return this._extractDatadogContext(parsed) + const spanContext = this.#extractDatadogContext(parsed) + if (!spanContext) return + + this.#extractLegacyBaggageItems(parsed, spanContext) + if (this.#config.DD_TRACE_PROPAGATION_EXTRACT_FIRST) return spanContext + + this.#addTraceContextState(spanContext, this.#extractTraceparentContext(parsed)) + return spanContext } - _extractTraceparentContext (carrier) { + /** + * @param {DatadogSpanContext} datadogContext + * @param {DatadogSpanContext | undefined} traceContext + */ + #addTraceContextState (datadogContext, traceContext) { + if (traceContext && datadogContext._traceId.equals(traceContext._traceId)) { + datadogContext._traceparent = traceContext._traceparent + datadogContext._tracestate = traceContext._tracestate + } + } + + /** + * @param {Record} carrier + * @returns {DatadogSpanContext | undefined} + */ + #extractTraceparentContext (carrier) { const headerValue = readTraceparent(carrier) if (!headerValue) return const matches = headerValue.trim().match(traceparentExpr) @@ -634,7 +837,7 @@ class TextMapPropagator { tracestate, }) - this._extract128BitTraceId(traceId, spanContext) + extract128BitTraceId(traceId, spanContext) tracestate.forVendor('dd', state => { for (const [key, value] of state.entries()) { @@ -683,147 +886,78 @@ class TextMapPropagator { } }) - this._extractLegacyBaggageItems(carrier, spanContext) return spanContext } } /** - * @param {string | undefined} traceId - * @param {string | undefined} spanId - * @param {number} radix - * @returns {DatadogSpanContext | undefined} + * @param {Record} carrier + * @param {DatadogSpanContext} spanContext + * @returns {void} */ - _extractGenericContext (traceId, spanId, radix) { - if (!traceId || invalidSegment.test(traceId)) return - if (!spanId) return - - return new DatadogSpanContext({ - traceId: id(traceId, radix), - spanId: id(spanId, radix), - isRemote: true, - }) - } - - _extractB3MultipleHeaders (carrier) { - // The parent-id field is intentionally absent: this method never consults it, - // so a parent-id-only carrier should bail with the rest. - const traceId = readB3TraceId(carrier) - const sampled = readB3Sampled(carrier) - const flags = readB3Flags(carrier) - - if (traceId === undefined && sampled === undefined && flags === undefined) { - return - } - - let empty = true - const b3 = {} - const spanId = readB3SpanId(carrier) - - if (traceId && spanId && b3TraceExpr.test(traceId) && b3SpanExpr.test(spanId)) { - b3.traceId = traceId - b3.spanId = spanId - empty = false - } - - if (sampled) { - b3.sampled = sampled - empty = false - } - - if (flags) { - b3.flags = flags - empty = false - } - - return empty ? undefined : b3 - } - - /** @param {string} header */ - _extractB3SingleHeader (header) { - const parts = header.split('-') - - if (parts[0] === 'd') { - return { - sampled: '1', - flags: '1', - } - } else if (parts.length === 1) { - return { - sampled: parts[0], - } - } - const b3 = { - traceId: parts[0], - spanId: parts[1], - } - - if (parts[2]) { - b3.sampled = parts[2] === '0' ? '0' : '1' - - if (parts[2] === 'd') { - b3.flags = '1' - } - } - - return b3 - } - - _extractOrigin (carrier, spanContext) { - const origin = readDatadogOrigin(carrier) - - if (typeof origin === 'string') { - spanContext._trace.origin = origin - } - } - - _extractLegacyBaggageItems (carrier, spanContext) { - if (!this._config.legacyBaggageEnabled) return + #extractLegacyBaggageItems (carrier, spanContext) { + if (!this.#config.legacyBaggageEnabled) return readLegacyBaggage(carrier, spanContext._baggageItems) } - _extractBaggageItems (carrier, spanContext) { + /** + * @param {Record | undefined} carrier + * @param {DatadogSpanContext | undefined} spanContext + * @param {boolean} extractBaggage + * @returns {void} + */ + #extractBaggageItems (carrier, spanContext, extractBaggage) { removeAllBaggageItems() - if (!carrier || !this._hasPropagationStyle('extract', 'baggage')) return + if (!carrier || !extractBaggage) return const header = readBaggage(carrier) if (!header) return - const baggages = header.split(',') const baggageTagKeys = this.#getBaggageTagKeysSet() const tagAllKeys = baggageTagKeys.has('*') /** @type {Record | undefined} */ let items let itemCount = 0 let byteCount = 0 + let start = 0 - for (const keyValue of baggages) { - if (itemCount >= this._config.baggageMaxItems) { + while (start <= header.length) { + if (itemCount >= this.#config.baggageMaxItems) { tracerMetrics.count('context_header.truncated', ['truncation_reason:baggage_item_count_exceeded']).inc() break } + + const memberStart = start + const commaIndex = header.indexOf(',', memberStart) + const end = commaIndex === -1 ? header.length : commaIndex + // Charge the comma slot before the empty-entry skip so a `,,,,,foo=bar` can't iterate for free. - byteCount += keyValue.length + 1 - if (byteCount > this._config.baggageMaxBytes) { + byteCount += end - memberStart + 1 + if (byteCount > this.#config.baggageMaxBytes) { tracerMetrics.count('context_header.truncated', ['truncation_reason:baggage_byte_count_exceeded']).inc() break } - if (!keyValue) continue + if (memberStart === end) { + start = end + 1 + continue + } // Per W3C baggage, list-members can contain optional properties after `;`. // Example: key=value;prop=1;prop2 // https://www.w3.org/TR/baggage/#header-content - const semicolonIdx = keyValue.indexOf(';') - const member = (semicolonIdx === -1 ? keyValue : keyValue.slice(0, semicolonIdx)).trim() - if (!member) continue - - const eqIdx = member.indexOf('=') - if (eqIdx === -1) { + let memberEnd = header.indexOf(';', memberStart) + if (memberEnd === -1 || memberEnd > end) memberEnd = end + const equalsIndex = header.indexOf('=', memberStart) + start = end + 1 + + if (equalsIndex === -1 || equalsIndex >= memberEnd) { + const member = header.slice(memberStart, memberEnd).trim() + if (!member) continue tracerMetrics.count('context_header_style.malformed', ['header_style:baggage']).inc() return } - const key = member.slice(0, eqIdx).trim() - let value = member.slice(eqIdx + 1).trim() + const key = header.slice(memberStart, equalsIndex).trim() + let value = header.slice(equalsIndex + 1, memberEnd).trim() if (!baggageTokenExpr.test(key) || !value) { tracerMetrics.count('context_header_style.malformed', ['header_style:baggage']).inc() @@ -856,27 +990,17 @@ class TextMapPropagator { } } - _extractSamplingPriority (carrier, spanContext) { - const header = readDatadogSamplingPriority(carrier) - if (header === undefined) return - const priority = Number.parseInt(header, 10) - - if (Number.isInteger(priority)) { - spanContext._sampling.priority = priority - } - } - /** * @param {Record} carrier * @returns {Record | undefined} */ - _extractTags (carrier) { + #extractTags (carrier) { const header = readDatadogTags(carrier) if (!header) return - if (this._config.DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH === 0) { + if (this.#config.DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH === 0) { log.debug('Trace tag propagation is disabled, skipping extraction.') - } else if (header.length > this._config.DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH) { + } else if (header.length > this.#config.DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH) { log.error('Trace tags from carrier are too large, skipping extraction.') } else { const tags = {} @@ -890,7 +1014,7 @@ class TextMapPropagator { const key = header.slice(start, hasSeparator ? separator : end) const value = hasSeparator ? header.slice(separator + 1, end) : '' - if (!this._validateTagKey(key) || !this._validateTagValue(value)) { + if (!tagKeyExpr.test(key) || !tagValueExpr.test(value)) { log.error('Trace tags from carrier are invalid, skipping extraction.') return } @@ -907,62 +1031,6 @@ class TextMapPropagator { return tags } } - - _extract128BitTraceId (traceId, spanContext) { - if (!spanContext) return - - const buffer = spanContext._traceId.toBuffer() - - if (buffer.length !== 16) return - - const tid = traceId.slice(0, 16) - - if (tid === zeroTraceId) return - - spanContext._trace.tags['_dd.p.tid'] = tid - } - - _validateTagKey (key) { - return tagKeyExpr.test(key) - } - - _validateTagValue (value) { - return tagValueExpr.test(value) - } - - _getPriority (sampled, debug) { - if (debug) { - return USER_KEEP - } else if (sampled === '1') { - return AUTO_KEEP - } else if (sampled === '0') { - return AUTO_REJECT - } - } - - /** - * @param {number} traceparentSampled - * @param {number|undefined} tracestateSamplingPriority - * @param {string|null} origin - * @returns {import('../../priority_sampler').SamplingPriority} - */ - static _getSamplingPriority (traceparentSampled, tracestateSamplingPriority, origin) { - const fromRumWithoutPriority = !tracestateSamplingPriority && origin === 'rum' - - let samplingPriority = - /** @type {import('../../priority_sampler').SamplingPriority} */ (tracestateSamplingPriority ?? AUTO_KEEP) - if (!fromRumWithoutPriority) { - if (traceparentSampled === 0 && - (!tracestateSamplingPriority || tracestateSamplingPriority >= 0)) { - samplingPriority = AUTO_REJECT - } else if (traceparentSampled === 1 && - (!tracestateSamplingPriority || tracestateSamplingPriority < 0)) { - samplingPriority = AUTO_KEEP - } - } - - return samplingPriority - } } module.exports = TextMapPropagator diff --git a/packages/dd-trace/src/opentracing/span.js b/packages/dd-trace/src/opentracing/span.js index 6cfee05656d..202650c607d 100644 --- a/packages/dd-trace/src/opentracing/span.js +++ b/packages/dd-trace/src/opentracing/span.js @@ -26,6 +26,15 @@ const finishedRegistry = createRegistry('finished') let OTEL_ENABLED = false const ALLOWED = new Set(['string', 'number', 'boolean']) +/** + * @typedef {object} RecordedException + * @property {string} message + * @property {string} [name] + * @property {string} [stack] + * @typedef {string | number | boolean} SpanEventAttributeScalar + * @typedef {SpanEventAttributeScalar | string[] | number[] | boolean[]} SpanEventAttributeValue + */ + const integrationCounters = { spans_created: {}, spans_finished: {}, @@ -306,6 +315,25 @@ class DatadogSpan { this._events.push(event) } + /** + * @param {RecordedException} exception + * @param {Record} [attributes] + */ + recordException (exception, attributes) { + const { message, name, stack } = exception + const eventAttributes = { ...attributes } + eventAttributes['exception.message'] ??= message + + if (typeof name === 'string') { + eventAttributes['exception.type'] ??= name + } + if (typeof stack === 'string') { + eventAttributes['exception.stacktrace'] ??= stack + } + + this.addEvent('exception', eventAttributes) + } + finish (finishTime) { if (this._duration !== undefined) { return diff --git a/packages/dd-trace/src/otel-thread-ctx.js b/packages/dd-trace/src/otel-thread-ctx.js index 75047cf4c7d..79925554f6f 100644 --- a/packages/dd-trace/src/otel-thread-ctx.js +++ b/packages/dd-trace/src/otel-thread-ctx.js @@ -105,6 +105,10 @@ const THREAD_ID = String(threadId) // time onEnter activates the span, and cleared on span finish, which // is how a pendingEndpoints waiter recognizes a record it must no // longer append to. +// webTags: the web-server tag bag the record's endpoint comes from, or is +// waiting on; undefined while the span has no web-server ancestry. +// Distinguishes a stale announcement from a live one when a nearer +// web-server span takes over. const CachedSym = Symbol('OtelThreadCtx.cached') let started = false @@ -137,6 +141,7 @@ function getOrBuildContext (span) { span[CachedSym] = cached } cached.context = new ThreadContext(traceId, spanId, attrs) + cached.webTags = webTags if (endpoint === undefined) awaitEndpoint(webTags, cached) return cached.context } @@ -227,21 +232,34 @@ function onEndpointResolved (span) { if (endpoint === undefined) return pendingEndpoints.delete(webTags) for (const cached of waiting) { - if (cached.context !== undefined) appendEndpoint(cached.context, endpoint) + // A record whose span has since been attributed to a nearer web-server span + // enlisted again under that bag, and this one is no longer its endpoint. + if (cached.context !== undefined && cached.webTags === webTags) appendEndpoint(cached.context, endpoint) } } -// A span whose record was built before it looked like a web-server span at all -// has just been recognized as one, so it has no endpoint and never enlisted for -// this request. Its ancestry can't have changed, only its own tags, so this is -// only ever about the announced span's own record. +// webTagsCache has changed which request it attributes this span to: the span +// itself or an ancestor was recognized as a web-server span, giving a record +// built without any web-server ancestry its first endpoint, or a nearer +// web-server span superseded the one this record is showing. Give the record +// the new request's endpoint if it has settled, or wait for its announcement. function onWebTagsResolved (span) { if (!started) return const cached = span[CachedSym] if (cached === undefined || cached.context === undefined) return const webTags = webTagsCache.getCachedWebTags(span) + if (webTags === cached.webTags) return + // Recorded so that an announcement for the bag this record was previously + // waiting on no longer applies to it. + cached.webTags = webTags const endpoint = finalEndpoint(webTags) if (endpoint === undefined) { + // A record that already shows the outer request's endpoint goes on showing + // it until the nearer one settles: the record buffer is append-only, so + // there is no way to take the attribute back, and rebuilding the + // ThreadContext would strand every async-context frame holding this one. + // The work is still nested in the outer request, which makes that the least + // wrong of the values available. awaitEndpoint(webTags, cached) } else { appendEndpoint(cached.context, endpoint) diff --git a/packages/dd-trace/src/plugin_manager.js b/packages/dd-trace/src/plugin_manager.js index 12949ebea53..1b6bd723449 100644 --- a/packages/dd-trace/src/plugin_manager.js +++ b/packages/dd-trace/src/plugin_manager.js @@ -165,6 +165,7 @@ module.exports = class PluginManager { codeOriginForSpans, dbmPropagationMode, dsmEnabled, + DD_TRACE_HTTP_CLIENT_ERROR_STATUSES, DD_TRACE_HTTP_SERVER_ERROR_STATUSES, clientIpEnabled, clientIpHeader, @@ -195,6 +196,7 @@ module.exports = class PluginManager { site, url, headers: headerTags || [], + DD_TRACE_HTTP_CLIENT_ERROR_STATUSES, DD_TRACE_HTTP_SERVER_ERROR_STATUSES, clientIpHeader, DD_TEST_SESSION_NAME, diff --git a/packages/dd-trace/src/plugins/util/status-validator.js b/packages/dd-trace/src/plugins/util/status-validator.js new file mode 100644 index 00000000000..fe63726879b --- /dev/null +++ b/packages/dd-trace/src/plugins/util/status-validator.js @@ -0,0 +1,98 @@ +'use strict' + +const log = require('../../log') + +const statusCodeRangesPattern = /^[1-5]\d{2}(?:-[1-5]\d{2})?(?:,[1-5]\d{2}(?:-[1-5]\d{2})?)*$/ +const whitespacePattern = /\s/g +const MAX_HTTP_STATUS_CODE = 599 + +const SERVER_ERROR_STATUSES = 'DD_TRACE_HTTP_SERVER_ERROR_STATUSES' +const CLIENT_ERROR_STATUSES = 'DD_TRACE_HTTP_CLIENT_ERROR_STATUSES' + +/** @typedef {(code: number) => boolean} StatusValidator */ + +/** @type {StatusValidator} */ +function isNotServerErrorCode (code) { + return code < 500 +} + +/** @type {StatusValidator} */ +function isNotClientErrorCode (code) { + return code < 400 || code >= 500 +} + +/** + * @param {{ validateStatus?: unknown, DD_TRACE_HTTP_SERVER_ERROR_STATUSES?: unknown }} config + * @returns {StatusValidator} + */ +function getServerStatusValidator (config) { + return getStatusValidator(config, SERVER_ERROR_STATUSES, '500-599', isNotServerErrorCode) +} + +/** + * @param {{ validateStatus?: unknown, DD_TRACE_HTTP_CLIENT_ERROR_STATUSES?: unknown }} config + * @returns {StatusValidator} + */ +function getClientStatusValidator (config) { + return getStatusValidator(config, CLIENT_ERROR_STATUSES, '400-499', isNotClientErrorCode) +} + +/** + * @param {Record} config + * @param {string} optionName - Name of the configuration holding the error status ranges. + * @param {string} defaultRanges - Ranges covered by `defaultValidator` + * @param {StatusValidator} defaultValidator + * @returns {StatusValidator} + */ +function getStatusValidator (config, optionName, defaultRanges, defaultValidator) { + if (typeof config.validateStatus === 'function') { + return /** @type {StatusValidator} */ (config.validateStatus) + } else if (Object.hasOwn(config, 'validateStatus')) { + log.error('Expected `validateStatus` to be a function.') + } + + const ranges = config[optionName] + if (ranges === undefined) return defaultValidator + if (typeof ranges !== 'string') { + log.error('Expected `%s` to be a string.', optionName) + return defaultValidator + } + if (ranges === defaultRanges) return defaultValidator + + const normalized = ranges.replaceAll(whitespacePattern, '') + if (normalized === defaultRanges) return defaultValidator + if (!statusCodeRangesPattern.test(normalized)) { + log.error('`%s` must contain comma-separated status codes or ranges from 100 to 599.', optionName) + return defaultValidator + } + + const errorStatusCodes = new Uint8Array(MAX_HTTP_STATUS_CODE + 1) + for (const range of normalized.split(',')) { + const separator = range.indexOf('-') + if (separator === -1) { + errorStatusCodes[Number(range)] = 1 + continue + } + + const first = Number(range.slice(0, separator)) + const second = Number(range.slice(separator + 1)) + const start = Math.min(first, second) + const end = Math.max(first, second) + errorStatusCodes.fill(1, start, end + 1) + } + + /** + * @param {number} code + * @returns {boolean} + */ + function isValidStatusCode (code) { + return errorStatusCodes[code] !== 1 + } + + return isValidStatusCode +} + +module.exports = { + getClientStatusValidator, + getServerStatusValidator, +} diff --git a/packages/dd-trace/src/plugins/util/test.js b/packages/dd-trace/src/plugins/util/test.js index 6778fe2c77a..7d4b0593fe6 100644 --- a/packages/dd-trace/src/plugins/util/test.js +++ b/packages/dd-trace/src/plugins/util/test.js @@ -541,6 +541,7 @@ module.exports = { getRelativeCoverageFiles, getLineCoverageBitmap, applySkippedCoverageToCoverage, + getTestCoverageLinesData, getTestCoverageLinesPercentage, resetCoverage, mergeCoverage, @@ -1359,6 +1360,34 @@ function getLineCoverageBitmap (lineCoverage, onlyCoveredLines = false) { return bitmap } +function getLineCoverageBitmaps (lineCoverage) { + let maxLine = 0 + const entries = Object.entries(lineCoverage) + + for (const [line] of entries) { + const lineNumber = Number(line) + if (Number.isSafeInteger(lineNumber) && lineNumber > maxLine) { + maxLine = lineNumber + } + } + if (maxLine === 0) return {} + + const length = Math.ceil((maxLine + 1) / 8) + const coveredBitmap = Buffer.alloc(length) + const executableBitmap = Buffer.alloc(length) + for (const [line, hits] of entries) { + const lineNumber = Number(line) + if (!Number.isSafeInteger(lineNumber) || lineNumber <= 0) continue + + const byteIndex = lineNumber >> 3 + const bit = 1 << (lineNumber % 8) + executableBitmap[byteIndex] |= bit + if (hits) coveredBitmap[byteIndex] |= bit + } + + return { coveredBitmap, executableBitmap } +} + function mergeCoverageBitmaps (targetBitmap, bitmap) { if (!targetBitmap) { return Buffer.from(bitmap) @@ -1419,16 +1448,6 @@ function getCoverageFileBitmap (bitmap) { } } -function addCoverageFilesToMap (files, targetMap, rootDir) { - for (const file of files) { - const bitmap = getCoverageFileBitmap(file.bitmap) - if (!bitmap) continue - - const filename = rootDir ? getTestSuitePath(file.filename, rootDir) : file.filename - targetMap.set(filename, mergeCoverageBitmaps(targetMap.get(filename), bitmap)) - } -} - function addSkippedCoverageToMap (skippedCoverage, targetMap) { if (!skippedCoverage) return @@ -1439,23 +1458,57 @@ function addSkippedCoverageToMap (skippedCoverage, targetMap) { } } -function getTestCoverageLinesPercentage (coverage, skippedCoverage, rootDir) { - const executableLinesByFile = new Map() - const coveredLinesByFile = new Map() +/** + * Calculates line coverage and optionally returns executable-line coverage files in the same traversal. + * @param {object} coverage + * @param {object} [skippedCoverage] + * @param {string} [rootDir] + * @param {boolean} [includeExecutableFiles] + * @returns {{ percentage: number, executableFiles?: Array<{ filename: string, bitmap: Buffer }> }} + */ +function getTestCoverageLinesData (coverage, skippedCoverage, rootDir, includeExecutableFiles = false) { + const coverageMap = getCoverageMap(coverage) + const skippedCoverageByFilename = getSkippedCoverageByFilename(skippedCoverage) + const coverageByFilename = new Map() + const executableFiles = includeExecutableFiles ? [] : undefined + + for (const filename of coverageMap.files()) { + const fileCoverage = coverageMap.fileCoverageFor(filename) + const { coveredBitmap, executableBitmap } = getLineCoverageBitmaps(fileCoverage.getLineCoverage()) + if (!executableBitmap) continue - addCoverageFilesToMap(getExecutableFilesFromCoverage(coverage), executableLinesByFile, rootDir) - addCoverageFilesToMap(getCoveredFilesFromCoverage(coverage), coveredLinesByFile, rootDir) - addSkippedCoverageToMap(skippedCoverage, coveredLinesByFile) + const relativeFilename = rootDir ? getTestSuitePath(filename, rootDir) : filename + const existingCoverage = coverageByFilename.get(relativeFilename) + if (existingCoverage) { + existingCoverage.coveredBitmap = mergeCoverageBitmaps(existingCoverage.coveredBitmap, coveredBitmap) + existingCoverage.executableBitmap = mergeCoverageBitmaps(existingCoverage.executableBitmap, executableBitmap) + } else { + coverageByFilename.set(relativeFilename, { coveredBitmap, executableBitmap }) + } + } let totalExecutableLines = 0 let totalCoveredLines = 0 - - for (const [filename, executableLines] of executableLinesByFile) { - totalExecutableLines += countBitmapBits(executableLines) - totalCoveredLines += countCoveredExecutableBits(coveredLinesByFile.get(filename), executableLines) + for (const [filename, { coveredBitmap, executableBitmap }] of coverageByFilename) { + const skippedBitmap = skippedCoverageByFilename.get(filename) + const combinedCoveredBitmap = skippedBitmap + ? mergeCoverageBitmaps(coveredBitmap, skippedBitmap) + : coveredBitmap + totalExecutableLines += countBitmapBits(executableBitmap) + totalCoveredLines += countCoveredExecutableBits(combinedCoveredBitmap, executableBitmap) + if (executableFiles) { + executableFiles.push({ filename, bitmap: executableBitmap }) + } } - return totalExecutableLines === 0 ? 0 : Math.floor((totalCoveredLines / totalExecutableLines) * 10_000) / 100 + const percentage = totalExecutableLines === 0 + ? 0 + : Math.floor((totalCoveredLines / totalExecutableLines) * 10_000) / 100 + return { percentage, executableFiles } +} + +function getTestCoverageLinesPercentage (coverage, skippedCoverage, rootDir) { + return getTestCoverageLinesData(coverage, skippedCoverage, rootDir).percentage } function isLineCoveredByBitmap (bitmap, line) { diff --git a/packages/dd-trace/src/plugins/util/web.js b/packages/dd-trace/src/plugins/util/web.js index 12c666681ae..6af8aea1ec0 100644 --- a/packages/dd-trace/src/plugins/util/web.js +++ b/packages/dd-trace/src/plugins/util/web.js @@ -13,6 +13,7 @@ const { storage } = require('../../../../datadog-core') const legacyStorage = storage('legacy') const urlFilter = require('./urlfilter') const { createInferredProxySpan, finishInferredProxySpan } = require('./inferred_proxy') +const { getServerStatusValidator } = require('./status-validator') const { extractURL, obfuscateQs, getQsObfuscator, calculateHttpEndpoint } = require('./url') const { NETWORK_PEER_ADDRESS } = require('./http-otel-semantics') @@ -35,11 +36,6 @@ const MANUAL_DROP = tags.MANUAL_DROP const contexts = new WeakMap() const requests = new WeakMap() -const statusCodeRangesPattern = /^[1-5]\d{2}(?:-[1-5]\d{2})?(?:,[1-5]\d{2}(?:-[1-5]\d{2})?)*$/ -const whitespacePattern = /\s/g -const MAX_HTTP_STATUS_CODE = 599 - -/** @typedef {(code: number) => boolean} StatusValidator */ // TODO: change this to no longer rely on creating a dummy plugin to be able to access startSpan function createWebPlugin (tracer, config = {}) { @@ -65,7 +61,7 @@ const web = { // Ensure the configuration has the correct structure and defaults. normalizeConfig (config) { const headers = getHeadersToRecord(config) - const validateStatus = getStatusValidator(config) + const validateStatus = getServerStatusValidator(config) const hooks = getHooks(config) const filter = urlFilter.getFilter(config) const middleware = getMiddlewareSetting(config) @@ -563,65 +559,6 @@ function getHeadersToRecord (config) { return [] } -function isNot500ErrorCode (code) { - return code < 500 -} - -/** - * @param {{ validateStatus?: unknown, DD_TRACE_HTTP_SERVER_ERROR_STATUSES?: unknown }} config - * @returns {StatusValidator} - */ -function getStatusValidator (config) { - if (typeof config.validateStatus === 'function') { - return /** @type {StatusValidator} */ (config.validateStatus) - } else if (config.hasOwnProperty('validateStatus')) { - log.error('Expected `validateStatus` to be a function.') - } - - const { DD_TRACE_HTTP_SERVER_ERROR_STATUSES } = config - if (DD_TRACE_HTTP_SERVER_ERROR_STATUSES === undefined) return isNot500ErrorCode - if (typeof DD_TRACE_HTTP_SERVER_ERROR_STATUSES !== 'string') { - log.error('Expected `DD_TRACE_HTTP_SERVER_ERROR_STATUSES` to be a string.') - return isNot500ErrorCode - } - if (DD_TRACE_HTTP_SERVER_ERROR_STATUSES === '500-599') return isNot500ErrorCode - - const normalized = DD_TRACE_HTTP_SERVER_ERROR_STATUSES.replaceAll(whitespacePattern, '') - if (normalized === '500-599') return isNot500ErrorCode - if (!statusCodeRangesPattern.test(normalized)) { - log.error( - '`DD_TRACE_HTTP_SERVER_ERROR_STATUSES` must contain comma-separated status codes or ranges from 100 to 599.' - ) - return isNot500ErrorCode - } - - const errorStatusCodes = new Uint8Array(MAX_HTTP_STATUS_CODE + 1) - const ranges = normalized.split(',') - for (const range of ranges) { - const separator = range.indexOf('-') - if (separator === -1) { - errorStatusCodes[Number(range)] = 1 - continue - } - - const first = Number(range.slice(0, separator)) - const second = Number(range.slice(separator + 1)) - const start = Math.min(first, second) - const end = Math.max(first, second) - errorStatusCodes.fill(1, start, end + 1) - } - - /** - * @param {number} code - * @returns {boolean} - */ - function isValidStatusCode (code) { - return errorStatusCodes[code] !== 1 - } - - return isValidStatusCode -} - const noop = () => {} function getHooks (config) { diff --git a/packages/dd-trace/src/profiling/oom.js b/packages/dd-trace/src/profiling/oom.js index 8cef12daae0..d898ec3b0c0 100644 --- a/packages/dd-trace/src/profiling/oom.js +++ b/packages/dd-trace/src/profiling/oom.js @@ -56,16 +56,17 @@ function strategiesToCallbackMode (strategies, callbackMode) { function buildExportCommand (exporters, tags) { const tagString = [...Object.entries(tags), ['snapshot', snapshotKinds.ON_OUT_OF_MEMORY]].map(([key, value]) => `${key}:${value}`).join(',') - const urls = [] + let urls = '' for (const exporter of exporters) { const url = exporter.getExportUrl() if (url !== undefined) { - urls.push(url.toString()) + if (urls) urls += ',' + urls += url.toString() } } return [process.execPath, path.join(__dirname, 'exporter_cli.js'), - urls.join(','), tagString, 'space'] + urls, tagString, 'space'] } module.exports = { oomExportStrategies, ensureOOMExportStrategies, strategiesToCallbackMode, buildExportCommand } diff --git a/packages/dd-trace/src/profiling/profiler.js b/packages/dd-trace/src/profiling/profiler.js index e30d002e460..4b278e6aeb4 100644 --- a/packages/dd-trace/src/profiling/profiler.js +++ b/packages/dd-trace/src/profiling/profiler.js @@ -95,6 +95,10 @@ class Profiler extends EventEmitter { for (const key of keys) { this.#customLabelKeys.add(key) } + this.#applyCustomLabelKeys() + } + + #applyCustomLabelKeys () { if (this.#profilers) { for (const profiler of this.#profilers) { profiler.setCustomLabelKeys?.(this.#customLabelKeys) @@ -180,6 +184,9 @@ class Profiler extends EventEmitter { this.#profilers = profilers this.#uploadCompression = uploadCompression this.#systemInfoReport = systemInfoReport + if (this.#customLabelKeys.size > 0) { + this.#applyCustomLabelKeys() + } this._setInterval() // Log errors if the source map finder fails, but don't prevent the rest diff --git a/packages/dd-trace/src/profiling/profilers/events.js b/packages/dd-trace/src/profiling/profilers/events.js index f256e9c8a58..baa89813b90 100644 --- a/packages/dd-trace/src/profiling/profilers/events.js +++ b/packages/dd-trace/src/profiling/profilers/events.js @@ -124,13 +124,13 @@ class GCDecorator { } let reasonLabel = this.reasonLabels[flags] if (!reasonLabel) { - const reasons = [] + let reasonStr = '' for (const [key, value] of Object.entries(this.flagObj)) { if (value & flags) { - reasons.push(key) + if (reasonStr) reasonStr += ',' + reasonStr += key } } - const reasonStr = reasons.join(',') reasonLabel = labelFromStr(this.stringTable, this.reasonLabelKey, reasonStr) this.reasonLabels[flags] = reasonLabel } diff --git a/packages/dd-trace/src/profiling/profilers/wall.js b/packages/dd-trace/src/profiling/profilers/wall.js index 2228f853977..594d5b71096 100644 --- a/packages/dd-trace/src/profiling/profilers/wall.js +++ b/packages/dd-trace/src/profiling/profilers/wall.js @@ -138,7 +138,7 @@ class NativeWallProfiler { this.#pprof.time.start({ collectCpuTime: this.#cpuProfilingEnabled, - columnNumbers: 'pack', + columnNumbers: 'emit', durationMillis: this.#flushIntervalMillis, intervalMicros: this.#samplingIntervalMicros, lineNumbers: false, @@ -168,8 +168,8 @@ class NativeWallProfiler { spanFinishCh.subscribe(this.#boundSpanFinished) if (this.#endpointCollectionEnabled) { // Web-tags cache publishes once per span at the moment its - // walk-result transitions from undefined to a real value — - // exactly when we need to refresh the ProfilingContext snapshot. + // walk-result changes — exactly when we need to refresh the + // ProfilingContext snapshot. webTagsCache.resolvedCh.subscribe(this.#boundSpanTagsUpdated) // Turn on the cache's own tagsUpdate subscription — it's // ref-counted, so this composes with any other active consumer. @@ -229,13 +229,12 @@ class NativeWallProfiler { } else if (current !== sampleContext) { this.#pprof.time.setContext(sampleContext) } - // Every setContext() call in ACF mode allocates a fresh contextHolder - // (a node::ObjectWrap with its own v8::Global) in the native - // profiler. Skip the call if the CPED already holds this sampleContext, - // which is the common case when the same span is repeatedly activated: - // #getProfilingContext caches profilingContext on span[ProfilingContext], - // so identity comparison short-circuits. } else if (current !== sampleContext) { + // Every setContext() call in ACF mode allocates a fresh contextHolder + // (a node::ObjectWrap with its own v8::Global) in the native + // profiler. We're only incurring the cost when current !== sampleContext + // and skip it when current === sampleContext, which is the common case + // when the same span is repeatedly activated. this.#pprof.time.setContext(sampleContext) } } else { @@ -266,8 +265,8 @@ class NativeWallProfiler { } // webTags is snapshotted into the sample context at getProfilingContext - // time; if the answer turns out to be undefined and the span later gets - // web-server tags, #spanTagsUpdated refreshes this field via the shared + // time; if the answer changes later — the span or an ancestor becomes a + // web-server span — #spanTagsUpdated refreshes this field via the shared // cache (see web-tags-cache.js). const webTags = this.#endpointCollectionEnabled ? webTagsCache.getCachedWebTags(span) : undefined @@ -291,9 +290,10 @@ class NativeWallProfiler { } // Invoked (via webTagsCache.resolvedCh) once per span at the moment the - // shared cache promotes a previously-undefined webTags answer into a - // real value. Refresh the ProfilingContext snapshot so future samples - // pick it up. + // shared cache changes its webTags answer: an empty one promoted into a real + // value, or an outer request's tag bag replaced by a nearer one. Refresh the + // ProfilingContext snapshot in place so samples already holding it — including + // those of a span that stays active across the promotion — pick it up. #spanTagsUpdated (span) { if (!this.#started) return const profilingContext = span[ProfilingContext] diff --git a/packages/dd-trace/src/proxy.js b/packages/dd-trace/src/proxy.js index 3330061d831..05fea890ed4 100644 --- a/packages/dd-trace/src/proxy.js +++ b/packages/dd-trace/src/proxy.js @@ -7,12 +7,12 @@ const { getEnvironmentVariable } = require('./config/helper') const runtimeMetrics = require('./runtime_metrics') const log = require('./log') const { setStartupLogPluginManager, startupLog } = require('./startup-log') -const DynamicInstrumentation = require('./debugger') const telemetry = require('./telemetry') const nomenclature = require('./service-naming') const PluginManager = require('./plugin_manager') const NoopDogStatsDClient = require('./noop/dogstatsd') -const { IS_SERVERLESS } = require('./serverless') +const { IS_SERVERLESS, initializeServerlessTelemetry, supportsServerlessTelemetryRetention } = require('./serverless') +const { flushServerlessTelemetry, registerTelemetryFlusher } = require('./flush') const processTags = require('./process-tags') const { isTrue } = require('./util') const { @@ -41,6 +41,13 @@ const OPENFEATURE_STATE_NOOP = 0 const OPENFEATURE_STATE_LAZY = 1 const OPENFEATURE_STATE_ACTIVE = 2 +let dynamicInstrumentation + +function getDynamicInstrumentation () { + dynamicInstrumentation ??= require('./debugger') + return dynamicInstrumentation +} + class LazyModule { constructor (provider) { this.provider = provider @@ -100,6 +107,15 @@ class Tracer extends NoopProxy { this._pluginManager = new PluginManager(this) this.dogstatsd = new NoopDogStatsDClient() this._tracingInitialized = false + // Logs and metrics can need retention even when tracing is disabled. + if (supportsServerlessTelemetryRetention()) { + this._serverlessTelemetry = { + flushAll: (done, options) => { + if (typeof this._tracer?.flushAll === 'function') this._tracer.flushAll(done, options) + else flushServerlessTelemetry(done, options) + }, + } + } this._flare = new LazyModule(() => require('./flare')) this.setBaggageItem = setBaggageItem this.getBaggageItem = getBaggageItem @@ -205,7 +221,7 @@ class Tracer extends NoopProxy { } if (config.dynamicInstrumentation.enabled) { - DynamicInstrumentation.start(config, rc) + getDynamicInstrumentation().start(config, rc) } const openfeatureRemoteConfig = require('./openfeature/remote_config') @@ -255,13 +271,20 @@ class Tracer extends NoopProxy { if (config.runtimeMetrics.enabled) { runtimeMetrics.start(config) + // Agent trace response metrics are recorded asynchronously, so drain + // runtime metrics after the trace export has completed. + registerTelemetryFlusher(done => runtimeMetrics.flush(done), { afterTrace: true }) } this.#updateTracing(config) - this._modules.rewriter.enable(config) + if (config.iast.enabled) { + this._modules.rewriter.enable(config) + } - if (config.DD_TRACE_ENABLED && config.testOptimization.DD_CIVISIBILITY_MANUAL_API_ENABLED) { + if (config.isCiVisibility && + config.DD_TRACE_ENABLED && + config.testOptimization.DD_CIVISIBILITY_MANUAL_API_ENABLED) { const TestApiManualPlugin = require('./ci-visibility/test-api-manual/test-api-manual-plugin') this._testApiManualPlugin = new TestApiManualPlugin(this) // `shouldGetEnvironmentData` is passed as false so that we only lazily calculate it @@ -269,7 +292,7 @@ class Tracer extends NoopProxy { // are lazily configured when the library is imported. this._testApiManualPlugin.configure({ ...config, enabled: true }, false) } - if (config.DD_AGENTLESS_LOG_SUBMISSION_ENABLED) { + if (config.isCiVisibility && config.DD_AGENTLESS_LOG_SUBMISSION_ENABLED) { if (config.DD_API_KEY) { const LogSubmissionPlugin = require('./ci-visibility/log-submission/log-submission-plugin') const automaticLogPlugin = new LogSubmissionPlugin(this) @@ -282,7 +305,7 @@ class Tracer extends NoopProxy { } } - if (config.testOptimization.DD_TEST_FAILED_TEST_REPLAY_ENABLED) { + if (config.isCiVisibility && config.testOptimization.DD_TEST_FAILED_TEST_REPLAY_ENABLED) { const getDynamicInstrumentationClient = require('./ci-visibility/dynamic-instrumentation') // We instantiate the client but do not start the Worker here. The worker is started lazily getDynamicInstrumentationClient(config) @@ -389,10 +412,12 @@ class Tracer extends NoopProxy { if (this._tracingInitialized) { this._tracer.configure(config) this._pluginManager.configure(config) - DynamicInstrumentation.configure(config) + dynamicInstrumentation?.configure(config) setStartupLogPluginManager(this._pluginManager) startupLog() } + + initializeServerlessTelemetry(this._serverlessTelemetry) } /** @@ -404,6 +429,9 @@ class Tracer extends NoopProxy { */ #updateDebugger (config, rc) { const shouldBeEnabled = config.dynamicInstrumentation.enabled + if (!shouldBeEnabled && dynamicInstrumentation === undefined) return + + const DynamicInstrumentation = getDynamicInstrumentation() const isCurrentlyStarted = DynamicInstrumentation.isStarted() if (shouldBeEnabled) { diff --git a/packages/dd-trace/src/runtime_metrics/index.js b/packages/dd-trace/src/runtime_metrics/index.js index f9451beb359..600132de8e7 100644 --- a/packages/dd-trace/src/runtime_metrics/index.js +++ b/packages/dd-trace/src/runtime_metrics/index.js @@ -13,6 +13,7 @@ const noop = runtimeMetrics = { gauge () {}, increment () {}, decrement () {}, + flush (done) { done?.() }, } module.exports = { @@ -42,6 +43,10 @@ module.exports = { runtimeMetrics = noop Object.setPrototypeOf(module.exports, noop) }, + + flush (done) { + runtimeMetrics.flush(done) + }, } Object.setPrototypeOf(module.exports, noop) diff --git a/packages/dd-trace/src/runtime_metrics/otlp_runtime_metrics.js b/packages/dd-trace/src/runtime_metrics/otlp_runtime_metrics.js index fbb6e1d7675..3c9ec42c45b 100644 --- a/packages/dd-trace/src/runtime_metrics/otlp_runtime_metrics.js +++ b/packages/dd-trace/src/runtime_metrics/otlp_runtime_metrics.js @@ -245,6 +245,11 @@ module.exports = { decrement (name, tag) { this.count(name, -1, tag) }, + + flush (done) { + if (client) return client.flush(done) + done?.() + }, } /** diff --git a/packages/dd-trace/src/runtime_metrics/runtime_metrics.js b/packages/dd-trace/src/runtime_metrics/runtime_metrics.js index 74e522b047d..1f7d0a431c0 100644 --- a/packages/dd-trace/src/runtime_metrics/runtime_metrics.js +++ b/packages/dd-trace/src/runtime_metrics/runtime_metrics.js @@ -27,6 +27,7 @@ let client = null let lastTime = 0 let lastCpuUsage = null let eventLoopDelayObserver = null +let capture = null // !!!!!!!!!!! // IMPORTANT @@ -76,11 +77,10 @@ module.exports = { lastTime = performance.now() if (nativeMetrics) { - interval = setInterval(() => { + capture = () => { captureNativeMetrics(trackEventLoop, trackGc) captureCommonMetrics(trackEventLoop) - client.flush() - }, flushIntervalMs) + } } else { lastCpuUsage = process.cpuUsage() @@ -92,17 +92,21 @@ module.exports = { eventLoopDelayObserver.enable() } - interval = setInterval(() => { + capture = () => { captureCpuUsage() captureCommonMetrics(trackEventLoop) captureHeapSpace() if (trackEventLoop) { captureEventLoopDelay() } - client.flush() - }, flushIntervalMs) + } } + interval = setInterval(() => { + capture() + client.flush() + }, flushIntervalMs) + interval.unref?.() }, @@ -114,6 +118,7 @@ module.exports = { interval = null client = null + capture = null lastCpuUsage = null gcObserver?.disconnect() @@ -158,6 +163,12 @@ module.exports = { decrement (name, tag) { this.count(name, -1, tag) }, + + flush (done) { + if (!client) return done?.() + capture?.() + client.flush(done) + }, } function captureCpuUsage () { diff --git a/packages/dd-trace/src/serverless.js b/packages/dd-trace/src/serverless.js index 23e424b4baa..1bf9ff7eecf 100644 --- a/packages/dd-trace/src/serverless.js +++ b/packages/dd-trace/src/serverless.js @@ -45,44 +45,61 @@ function isInServerlessEnvironment () { /** * Gets tags describing the serverless platform where the tracer is running. * + * @param {{ isVercel: boolean }} [platform] Detected serverless platform. * @returns {string[]|undefined} */ -function getServerlessPlatformTags () { - if (getEnvironmentVariable('VERCEL') === '1') { - return getVercelPlatformTags() +function getServerlessPlatformTags (platform = getServerlessPlatform()) { + if (platform.isVercel) { + return require('./serverless/vercel').getVercelPlatformTags() } } /** - * @returns {string[]|undefined} + * Detects the serverless platform once while configuration is built. + * @returns {{ isVercel: boolean }} */ -function getVercelPlatformTags () { - let tags - const projectId = getEnvironmentVariable('VERCEL_PROJECT_ID') - if (projectId) { - tags = ['vercel.project_id', projectId] - } +function getServerlessPlatform () { + return { isVercel: getEnvironmentVariable('VERCEL') === '1' } +} - const environment = getEnvironmentVariable('VERCEL_ENV') - if (environment) { - tags ??= [] - tags.push('vercel.environment', environment) - } +/** + * Whether the current platform can retain an invocation for telemetry delivery. + * + * Add future serverless platforms here as they gain an equivalent retention hook. + * @returns {boolean} + */ +function supportsServerlessTelemetryRetention () { + return getServerlessPlatform().isVercel +} - const region = getEnvironmentVariable('VERCEL_REGION') - if (region) { - tags ??= [] - tags.push('vercel.region', region) +/** + * Creates delivery tracking for platforms with an invocation retention hook. + */ +function createServerlessDeliveryTracker () { + if (supportsServerlessTelemetryRetention()) { + return new (require('./serverless/telemetry-delivery-tracker'))() } +} - return tags +/** + * Registers the lifecycle adapter selected by the detected serverless platform. + * @param {{ flushAll?: (done: () => void) => void }} tracer + */ +function initializeServerlessTelemetry (tracer) { + if (supportsServerlessTelemetryRetention()) { + return require('./serverless/vercel').registerVercelTelemetryRetention(tracer) + } } module.exports = { getServerlessPlatformTags, + getServerlessPlatform, + supportsServerlessTelemetryRetention, + createServerlessDeliveryTracker, getIsGCPFunction, getIsAzureFunction, enableGCPPubSubPushSubscription, getIsFlexConsumptionAzureFunction, + initializeServerlessTelemetry, IS_SERVERLESS: isInServerlessEnvironment(), } diff --git a/packages/dd-trace/src/serverless/telemetry-delivery-tracker.js b/packages/dd-trace/src/serverless/telemetry-delivery-tracker.js new file mode 100644 index 00000000000..0e5ef3b6b96 --- /dev/null +++ b/packages/dd-trace/src/serverless/telemetry-delivery-tracker.js @@ -0,0 +1,55 @@ +'use strict' + +/** + * Tracks transport deliveries that must outlive a serverless request. + * + * The tracker is created only for platforms with an invocation-retention + * boundary. Exporters keep their normal callback path when it is absent. + */ +class TelemetryDeliveryTracker { + #deliveries = new Set() + + /** + * Tracks one asynchronous transport delivery until its callback runs. + * @param {(done: () => void) => void} deliver + * @param {(() => void)|undefined} done + */ + track (deliver, done) { + const delivery = { callbacks: done ? [done] : [] } + this.#deliveries.add(delivery) + + let completed = false + const complete = () => { + if (completed) return + completed = true + this.#deliveries.delete(delivery) + for (const callback of delivery.callbacks) callback() + } + + try { + deliver(complete) + } catch (error) { + complete() + throw error + } + } + + /** + * Calls back after every delivery active at this boundary has completed. + * @param {(() => void)|undefined} done + */ + waitForIdle (done) { + if (!done) return + + const deliveries = [...this.#deliveries] + if (deliveries.length === 0) return done() + + let pending = deliveries.length + const complete = () => { + if (--pending === 0) done() + } + for (const delivery of deliveries) delivery.callbacks.push(complete) + } +} + +module.exports = TelemetryDeliveryTracker diff --git a/packages/dd-trace/src/serverless/vercel.js b/packages/dd-trace/src/serverless/vercel.js new file mode 100644 index 00000000000..04bcc84cf90 --- /dev/null +++ b/packages/dd-trace/src/serverless/vercel.js @@ -0,0 +1,122 @@ +'use strict' + +const { channel } = require('dc-polyfill') + +const { getEnvironmentVariable } = require('../config/helper') +const log = require('../log') + +const httpRequestFinishChannel = channel('apm:http:server:request:finish') +const http2RequestStartChannel = channel('apm:http2:server:request:start') +const http2ResponseEmitChannel = channel('apm:http2:server:response:emit') +const VERCEL_REQUEST_CONTEXT = Symbol.for('@vercel/request-context') +const VERCEL_FLUSH_TIMEOUT = 2000 +const vercelRetentionHandlers = new WeakMap() + +/** + * @typedef {{ flushAll?: (done: () => void, options?: { timeout?: number }) => void }} TelemetryFlusher + */ + +/** + * @param {TelemetryFlusher} tracer + * @param {() => void} done + * @returns {void} + */ +function flushVercelTelemetry (tracer, done) { + setImmediate(() => { + try { + tracer.flushAll(done, { timeout: VERCEL_FLUSH_TIMEOUT }) + } catch (error) { + log.warn('Unable to flush Vercel telemetry:', error) + done() + } + }) +} + +function registerVercelRequestFlush (tracer) { + const requestContext = getVercelRequestContext() + if (!requestContext) return + + const { waitUntil } = requestContext + if (typeof waitUntil !== 'function') return + + // Retain the invocation synchronously, then flush after the response completes. + let done + const pending = new Promise(resolve => { done = resolve }) + try { + waitUntil(pending) + flushVercelTelemetry(tracer, done) + } catch (error) { + log.warn('Unable to retain Vercel telemetry:', error) + done() + } +} + +function getVercelRequestContext () { + return globalThis[VERCEL_REQUEST_CONTEXT]?.get?.() +} + +// HTTP/2 binds response lifecycle events while handling its request-start channel. +function activateHttp2Lifecycle () {} + +/** + * Retains a Vercel Node Function until configured telemetry exporters complete. + * + * @param {TelemetryFlusher} tracer + * @returns {(() => void)|undefined} + */ +function registerVercelTelemetryRetention (tracer) { + const existing = vercelRetentionHandlers.get(tracer) + if (existing) return existing + + if (typeof tracer?.flushAll !== 'function') return + // The HTTP finish channel activates its response wrapper directly. HTTP/2 needs + // a passive request-start subscriber before it can bind response emit events. + const flushRequest = () => registerVercelRequestFlush(tracer) + const flushHttp2Response = ({ eventName }) => { + if (eventName === 'close') flushRequest() + } + httpRequestFinishChannel.subscribe(flushRequest) + http2RequestStartChannel.subscribe(activateHttp2Lifecycle) + http2ResponseEmitChannel.subscribe(flushHttp2Response) + + const unregister = () => { + httpRequestFinishChannel.unsubscribe(flushRequest) + http2RequestStartChannel.unsubscribe(activateHttp2Lifecycle) + http2ResponseEmitChannel.unsubscribe(flushHttp2Response) + vercelRetentionHandlers.delete(tracer) + } + vercelRetentionHandlers.set(tracer, unregister) + return unregister +} + +/** + * Gets Vercel deployment tags to attach to spans. + * + * @returns {string[]|undefined} + */ +function getVercelPlatformTags () { + let tags + const projectId = getEnvironmentVariable('VERCEL_PROJECT_ID') + if (projectId) { + tags = ['vercel.project_id', projectId] + } + + const environment = getEnvironmentVariable('VERCEL_ENV') + if (environment) { + tags ??= [] + tags.push('vercel.environment', environment) + } + + const region = getEnvironmentVariable('VERCEL_REGION') + if (region) { + tags ??= [] + tags.push('vercel.region', region) + } + + return tags +} + +module.exports = { + getVercelPlatformTags, + registerVercelTelemetryRetention, +} diff --git a/packages/dd-trace/src/span_stats.js b/packages/dd-trace/src/span_stats.js index e5f66588eb6..fe8f064e8cc 100644 --- a/packages/dd-trace/src/span_stats.js +++ b/packages/dd-trace/src/span_stats.js @@ -15,6 +15,7 @@ const { } = require('../../../ext/tags') const { ORIGIN_KEY, TOP_LEVEL_KEY, SVC_SRC_KEY, GRPC_STATUS_NAMES } = require('./constants') const id = require('./id') +const log = require('./log') const GRPC_STATUS_CODE_MAP = Object.fromEntries(GRPC_STATUS_NAMES.map((name, i) => [name, String(i)])) const ZERO_ID = id('0') @@ -235,6 +236,18 @@ class SpanStatsProcessor { } onInterval () { + this.#flush() + } + + /** + * Drains pending span statistics and waits for their export. + * @param {Function} [done] + */ + forceFlush (done) { + this.#flush(done) + } + + #flush (done) { const drained = this.#drainBuckets() if (this.enabled && !this.otlpExporter) { @@ -248,10 +261,34 @@ class SpanStatsProcessor { RuntimeID: this.tags['runtime-id'], Sequence: ++this.sequence, ProcessTags: processTags.serialized, - }) + }, done) } else if (this.otlpExporter && drained.length > 0) { - this.otlpExporter.export(drained, this.bucketSizeNs) - } + if (typeof this.otlpExporter.flush === 'function' && done) { + // Snapshot requests already in flight before starting this boundary + // export, so a later invocation cannot extend this lifecycle barrier. + let pending = 2 + const complete = () => { + if (--pending === 0) done() + } + try { + this.otlpExporter.flush(complete) + } catch (error) { + log.error('Failed to flush OTLP span stats:', error) + complete() + } + try { + this.otlpExporter.export(drained, this.bucketSizeNs, complete) + } catch (error) { + log.error('Failed to export OTLP span stats:', error) + complete() + } + } else { + this.otlpExporter.export(drained, this.bucketSizeNs, done) + } + } else if (this.otlpExporter) { + if (typeof this.otlpExporter.flush === 'function') this.otlpExporter.flush(done) + else done?.() + } else done?.() } onSpanFinished (span) { diff --git a/packages/dd-trace/src/tracer.js b/packages/dd-trace/src/tracer.js index 28e7df78d5f..883ca8218c5 100644 --- a/packages/dd-trace/src/tracer.js +++ b/packages/dd-trace/src/tracer.js @@ -13,6 +13,7 @@ const { isError } = require('./util') const { setStartupLogConfig } = require('./startup-log') const { DataStreamsCheckpointer, DataStreamsManager, DataStreamsProcessor } = require('./datastreams') const { IS_SERVERLESS } = require('./serverless') +const { flushServerlessTelemetry } = require('./flush') const log = require('./log') // Always-on writer (console.warn), not the channel-gated `log`: these surface regardless of // DD_TRACE_DEBUG. @@ -150,6 +151,27 @@ class DatadogTracer extends Tracer { this._dataStreamsProcessor.setUrl(url) } + /** + * Flushes every configured telemetry pipeline for a serverless lifecycle. + * @param {Function} [done] Called after every configured export completes + * @param {{ timeout?: number }} [options] Bounds this flush operation. + */ + flushAll (done, options) { + const traceExporter = this._exporter + const spanStats = this._processor?._stats + const traceFlusher = typeof traceExporter?.flush === 'function' + ? callback => traceExporter.flush(callback) + : undefined + const spanStatsFlusher = typeof spanStats?.forceFlush === 'function' + ? callback => spanStats.forceFlush(callback) + : undefined + + flushServerlessTelemetry(done, options, { + trace: traceFlusher, + spanStats: spanStatsFlusher, + }) + } + scope () { return this._scope } diff --git a/packages/dd-trace/src/web-tags-cache.js b/packages/dd-trace/src/web-tags-cache.js index 7e528228706..7319e34effd 100644 --- a/packages/dd-trace/src/web-tags-cache.js +++ b/packages/dd-trace/src/web-tags-cache.js @@ -3,8 +3,9 @@ // Per-span cache of "which tag bag from the started-spans chain identifies // this span (or its nearest web-server ancestor) as a web-server span?" // Populated lazily on first `getCachedWebTags(span)`, refreshed -// automatically when a `dd-trace:span:tags:update` event promotes a -// previously-empty answer for that span into a real value. +// automatically when a `dd-trace:span:tags:update` event turns a span into a +// web-server span after the fact — for that span and for every descendant +// whose answer the promotion changes. // // Used by the wall profiler (endpoint-collection label on samples) and by // the OTEP-4947 thread-context writer (endpoint attribute in the record); @@ -13,11 +14,12 @@ // // Consumers that want to react to late web-server-span discovery // subscribe to `resolvedCh` — a diagnostics channel we publish on once -// per span at the moment its cached webTags transitions from undefined -// to a real value. Doing it via a channel (rather than exposing a -// stateful "did the transition happen?" query) means each consumer sees -// every transition exactly once, regardless of subscription order or -// how many other consumers are attached. +// per span at the moment its cached webTags changes, i.e. when a promotion +// gives it an answer it didn't have or replaces the one it had with a +// nearer one. Doing it via a channel (rather than exposing a stateful "did +// the transition happen?" query) means each consumer sees every transition +// exactly once, regardless of subscription order or how many other +// consumers are attached. // // `endpointResolvedCh` is the same idea one field over: published once per // web-server span at the moment its endpoint name settles (see finalEndpoint in @@ -63,17 +65,21 @@ function getCache (span) { // Returns the web-server tag bag for this span or its nearest web-server // ancestor in the started-spans chain, or undefined if none is a // web-server span. Lazy: walks the parent chain on the first call, caches -// the result on the span. +// the result on the span. Answers only ever change through onTagsUpdate, which +// rewrites the affected entries in place, so a resolved answer is never +// revisited here. function getCachedWebTags (span) { const cached = getCache(span) if (cached.resolved) return cached.webTags const spanContext = span.context() const tags = spanContext.getTags() + const parentId = spanContext._parentId let webTags if (isWebServerSpan(tags)) { webTags = tags - } else { - const parentId = spanContext._parentId + // A span with no parent has nothing to inherit from, and looking for one + // anyway means scanning the entire started-spans list to conclude that. + } else if (parentId != null) { const startedSpans = getStartedSpans(spanContext) for (let i = startedSpans.length; --i >= 0;) { const ispan = startedSpans[i] @@ -97,11 +103,17 @@ function getCachedWebTags (span) { function onTagsUpdate (span) { const cached = span[CachedSym] if (cached === undefined || !cached.resolved) return - const tags = span.context().getTags() - if (cached.webTags === undefined) { - if (!isWebServerSpan(tags)) return + const spanContext = span.context() + const tags = spanContext.getTags() + // Anything but this span's own bag is an answer that predates it being a + // web-server span: either empty, or an outer web-server ancestor's bag that + // this span now supersedes for itself and for its descendants. A span whose + // cached answer already is its own bag is the overwhelmingly common case here + // (every further tag update on a request span), and stops at one comparison. + if (cached.webTags !== tags && isWebServerSpan(tags)) { cached.webTags = tags resolvedCh.publish(span) + resolveDescendants(span, spanContext, tags) } // Endpoint finality is a property of the web-server span itself: for a // descendant, cached.webTags is an ancestor's bag rather than these tags, and @@ -114,6 +126,48 @@ function onTagsUpdate (span) { endpointResolvedCh.publish(span) } +// A span has just become a web-server span; answers cached for its descendants +// before that moment found a farther ancestor or nothing at all, and are now +// wrong. Rewrite them here rather than letting each descendant discover it on +// its next lookup: a descendant that is already active keeps handing samplers +// its stale answer for as long as it runs without re-entering storage, which is +// precisely the uninterrupted synchronous stretch profiling cares about. +// +// One forward pass over the trace's started-spans list, which is in creation +// order, so a span's parent always precedes it: `answers` holds the new answer +// for each span in the promoted span's subtree, keyed by span id, and a span +// inherits its parent's unless it is a web-server span itself, in which case its +// own bag shadows the promotion for its own subtree. Spans outside the subtree +// are never in `answers`, so they cost one map lookup and nothing else. +// +// That same creation order means nothing before the promoted span can be a +// descendant of it, so the pass starts just past it. Locating it from the end +// costs one comparison in the common case, where a request span is promoted as +// it is created and is still the newest entry — leaving nothing to visit and +// nothing to allocate. A span the list no longer holds (finished, and dropped by +// a partial flush) ends that scan at -1, which visits the whole list. +function resolveDescendants (span, spanContext, webTags) { + const startedSpans = getStartedSpans(spanContext) + let index = startedSpans.length - 1 + while (index >= 0 && startedSpans[index] !== span) index-- + index++ + if (index === startedSpans.length) return + const answers = new Map([[spanContext._spanId, webTags]]) + for (; index < startedSpans.length; index++) { + const descendant = startedSpans[index] + const context = descendant.context() + const inherited = answers.get(context._parentId) + if (inherited === undefined) continue + const tags = context.getTags() + const answer = isWebServerSpan(tags) ? tags : inherited + answers.set(context._spanId, answer) + const cached = descendant[CachedSym] + if (cached === undefined || !cached.resolved || cached.webTags === answer) continue + cached.webTags = answer + resolvedCh.publish(descendant) + } +} + let activeCount = 0 function activate () { diff --git a/packages/dd-trace/test/aiguard/evaluation.spec.js b/packages/dd-trace/test/aiguard/evaluation.spec.js index f8e928bb2f8..e117dd31558 100644 --- a/packages/dd-trace/test/aiguard/evaluation.spec.js +++ b/packages/dd-trace/test/aiguard/evaluation.spec.js @@ -13,6 +13,7 @@ const { describe('AI Guard evaluation response', () => { it('parses the backend response into the internal evaluation contract', () => { const sdsFindings = [{ category: 'ssn' }] + const redactionReplacements = [{ path: 'messages[0].content', replacement: '' }] const tagProbabilities = { jailbreak: 0.8 } const response = { data: { @@ -23,6 +24,7 @@ describe('AI Guard evaluation response', () => { sds_findings: sdsFindings, tag_probs: tagProbabilities, is_blocking_enabled: true, + redaction_replacements: redactionReplacements, }, }, } @@ -35,6 +37,7 @@ describe('AI Guard evaluation response', () => { tagProbabilities, hasTagProbabilities: true, blockingEnabled: true, + redactionReplacements, }) }) @@ -47,6 +50,7 @@ describe('AI Guard evaluation response', () => { tagProbabilities: {}, hasTagProbabilities: false, blockingEnabled: false, + redactionReplacements: undefined, }) }) @@ -63,22 +67,26 @@ describe('AI Guard evaluation response', () => { }) } - it('creates a blocked outcome', () => { + it('returns the redacted private snapshot in a blocked outcome', () => { + const privateSnapshot = [{ role: 'user', content: 'My SSN is 123-45-6789' }] const evaluation = parseEvaluationResponse({ data: { attributes: { action: 'DENY', reason: 'Sensitive data detected.', tags: ['prompt-injection'], - sds_findings: [{ category: 'email_address' }], + sds_findings: [{ category: 'email_address', matched_text: 'ops@acme.io' }], tag_probs: { 'prompt-injection': 0.9 }, is_blocking_enabled: true, + redaction_replacements: [ + { path: 'messages[0].content', replacement: 'My SSN is ' }, + ], }, }, }) assert.ok(evaluation) - const outcome = createEvaluationOutcome(evaluation, true) + const outcome = createEvaluationOutcome(privateSnapshot, evaluation, { block: true, redactionEnabled: true }) assert.deepStrictEqual(outcome, { result: { @@ -86,13 +94,110 @@ describe('AI Guard evaluation response', () => { reason: 'Sensitive data detected.', tags: ['prompt-injection'], tagProbabilities: { 'prompt-injection': 0.9 }, - sds: [{ category: 'email_address' }], + sds: [{ category: 'email_address', matched_text: 'ops@acme.io' }], + messages: [{ role: 'user', content: 'My SSN is ' }], + redactionReplacements: [{ path: 'messages[0].content', replacement: 'My SSN is ' }], }, shouldBlock: true, hasTagProbabilities: true, + redaction: { + enabled: true, + applied: true, + failures: 0, + }, + }) + assert.strictEqual(outcome.result.messages, privateSnapshot) + assert.strictEqual(privateSnapshot[0].content, 'My SSN is ') + }) + + it('keeps original messages but reports the backend replacements when redaction is disabled', () => { + const messages = [{ role: 'user', content: 'My SSN is 123-45-6789' }] + const evaluation = parseEvaluationResponse({ + data: { + attributes: { + action: 'ALLOW', + redaction_replacements: [ + { path: 'messages[0].content', replacement: 'My SSN is ' }, + ], + }, + }, + }) + + assert.ok(evaluation) + const outcome = createEvaluationOutcome(messages, evaluation, { block: true, redactionEnabled: false }) + + assert.strictEqual(outcome.result.messages, messages) + assert.deepStrictEqual(outcome.result.redactionReplacements, [ + { path: 'messages[0].content', replacement: 'My SSN is ' }, + ]) + assert.deepStrictEqual(outcome.redaction, { + enabled: false, + applied: false, + failures: 0, + }) + }) + + for (const redactionEnabled of [true, false]) { + it(`reports no replacements when the backend sends none and redaction is ${ + redactionEnabled ? 'enabled' : 'disabled'}`, () => { + const messages = [{ role: 'user', content: 'Hello' }] + const evaluation = parseEvaluationResponse({ data: { attributes: { action: 'ALLOW' } } }) + + assert.ok(evaluation) + const outcome = createEvaluationOutcome(messages, evaluation, { block: true, redactionEnabled }) + + assert.deepStrictEqual(outcome.result.redactionReplacements, []) + }) + } + + it('reports only well-formed replacements while still counting malformed ones as failures', () => { + const messages = [{ role: 'user', content: 'My SSN is 123-45-6789' }] + const evaluation = parseEvaluationResponse({ + data: { + attributes: { + action: 'ALLOW', + redaction_replacements: [ + { path: 'messages[0].content', replacement: 'My SSN is ' }, + { path: 'messages[8].content', replacement: 'unresolvable but well-formed' }, + 'not an object', + { path: 'messages[0].content', replacement: 42 }, + { path: '', replacement: 'empty path' }, + ], + }, + }, }) + + assert.ok(evaluation) + const outcome = createEvaluationOutcome(messages, evaluation, { block: true, redactionEnabled: true }) + + assert.deepStrictEqual(outcome.result.redactionReplacements, [ + { path: 'messages[0].content', replacement: 'My SSN is ' }, + { path: 'messages[8].content', replacement: 'unresolvable but well-formed' }, + ]) + assert.strictEqual(outcome.redaction.failures, 4) }) + for (const replacements of ['not an array', false]) { + it(`reports no replacements when the backend sends non-array ${JSON.stringify(replacements)}`, () => { + const privateSnapshot = [{ role: 'user', content: 'My SSN is 123-45-6789' }] + const evaluation = parseEvaluationResponse({ + data: { attributes: { action: 'ALLOW', redaction_replacements: replacements } }, + }) + + assert.ok(evaluation) + const outcome = createEvaluationOutcome(privateSnapshot, evaluation, { block: true, redactionEnabled: true }) + + assert.deepStrictEqual(outcome.result.redactionReplacements, []) + assert.strictEqual(outcome.result.messages, privateSnapshot) + assert.deepStrictEqual(privateSnapshot, [{ role: 'user', content: 'My SSN is 123-45-6789' }]) + assert.deepStrictEqual(outcome.redaction, { + enabled: true, + applied: false, + failures: 1, + }) + }) + } + for (const testCase of [ { block: false, action: 'DENY', blockingEnabled: true, expected: false }, { block: true, action: 'DENY', blockingEnabled: false, expected: false }, diff --git a/packages/dd-trace/test/aiguard/index.spec.js b/packages/dd-trace/test/aiguard/index.spec.js index 2f9af1f51c7..6047e8a1a83 100644 --- a/packages/dd-trace/test/aiguard/index.spec.js +++ b/packages/dd-trace/test/aiguard/index.spec.js @@ -2,7 +2,7 @@ const assert = require('node:assert/strict') const { rejects } = require('node:assert/strict') -const { inspect } = require('node:util') +const { inspect, isDeepStrictEqual } = require('node:util') const msgpack = require('@msgpack/msgpack') const { afterEach, beforeEach, describe, it } = require('mocha') @@ -29,6 +29,7 @@ const { ERROR_TYPE_CLIENT, ERROR_TYPE_STATUS, ERROR_TYPE_RESPONSE, + ERROR_TYPE_REDACTION, } = require('../../src/aiguard/tags') describe('AIGuard SDK', () => { @@ -45,13 +46,14 @@ describe('AIGuard SDK', () => { endpoint: 'https://aiguard.com', maxMessagesLength: 16, maxContentSize: 512 * 1024, + redactionEnabled: true, timeout: 10_000, }, }, } let tracer let aiguard - let count, inc + let count const toolCall = [ { role: 'system', content: 'You are a beautiful AI assistant' }, @@ -89,10 +91,7 @@ describe('AIGuard SDK', () => { originalFetch = global.fetch global.fetch = sinon.stub() - inc = sinon.spy() - count = sinon.stub(aiguardMetrics, 'count').returns({ - inc, - }) + count = sinon.stub(aiguardMetrics, 'count').callsFake(() => ({ inc: sinon.spy() })) aiguardMetrics.metrics.clear() aiguard = new AIGuard(tracer, config) @@ -116,6 +115,18 @@ describe('AIGuard SDK', () => { } } + const mockDeferredFetch = () => { + let resolveFetch + global.fetch.callsFake(() => new Promise(resolve => { + resolveFetch = resolve + })) + + return options => resolveFetch({ + status: options.status ?? 200, + json: sinon.stub().resolves(options.body), + }) + } + const assertFetch = (messages, url) => { const postData = JSON.stringify( { data: { attributes: { messages, meta: { service: config.service, env: config.env } } } } @@ -152,8 +163,19 @@ describe('AIGuard SDK', () => { const sdkTags = { source: SOURCE_SDK, integration: INTEGRATION_NONE } - const assertTelemetry = (metric, tags) => { - sinon.assert.calledWith(count, metric, tags) + const assertTelemetry = (metric, tags, incAmount = 1) => { + if (metric === 'requests' && tags.error === false && !Object.hasOwn(tags, 'redacted')) { + tags = { ...tags, redacted: false } + } + const metricCalls = count.getCalls().filter(call => + call.args[0] === metric && isDeepStrictEqual(call.args[1], tags) + ) + assert.strictEqual( + metricCalls.length, + 1, + `Expected one telemetry count(${inspect(metric)}, ${inspect(tags)}), got ${metricCalls.length}` + ) + sinon.assert.calledOnceWithExactly(metricCalls[0].returnValue.inc, incAmount) } const testSuite = [ @@ -194,6 +216,8 @@ describe('AIGuard SDK', () => { assert.strictEqual(evaluation.tagProbabilities, attributes.tag_probs) } assert.deepStrictEqual(evaluation.sds, []) + assert.notStrictEqual(evaluation.messages, messages) + assert.deepStrictEqual(evaluation.messages, messages) } assertTelemetry('requests', { action, error: false, block: shouldBlock, ...sdkTags }) @@ -236,6 +260,8 @@ describe('AIGuard SDK', () => { } else { const evaluation = await aiguard.evaluate(prompt, opts) assert.strictEqual(evaluation.action, 'DENY') + assert.notStrictEqual(evaluation.messages, prompt) + assert.deepStrictEqual(evaluation.messages, prompt) } assertTelemetry('requests', { error: false, action: 'DENY', block: shouldBlock, ...sdkTags }) @@ -277,6 +303,8 @@ describe('AIGuard SDK', () => { const result = await aiguard.evaluate(messages) assert.deepStrictEqual(result.sds, sdsFindings) + assert.notStrictEqual(result.messages, messages) + assert.deepStrictEqual(result.messages, messages) await assertAIGuardSpan( { 'ai_guard.target': 'prompt', 'ai_guard.action': 'ALLOW' }, { messages, sds: sdsFindings } @@ -296,12 +324,357 @@ describe('AIGuard SDK', () => { const result = await aiguard.evaluate(messages) assert.deepStrictEqual(result.sds, []) + assert.notStrictEqual(result.messages, messages) + assert.deepStrictEqual(result.messages, messages) + assert.deepStrictEqual(result.redactionReplacements, []) await assertAIGuardSpan( { 'ai_guard.target': 'prompt', 'ai_guard.action': 'ALLOW' }, { messages } ) }) + it('returns redacted messages and reports them in meta-struct without mutating the input', async () => { + const messages = [{ role: 'user', content: 'My SSN is 123-45-6789' }] + const redactionReplacements = [ + { path: 'messages[0].content', replacement: 'My SSN is ' }, + ] + mockFetch({ + body: { + data: { + attributes: { + action: 'ALLOW', + reason: 'Sensitive data redacted.', + redaction_replacements: redactionReplacements, + is_blocking_enabled: true, + }, + }, + }, + }) + + const result = await aiguard.evaluate(messages) + + assert.notStrictEqual(result.messages, messages) + assert.deepStrictEqual(result.messages, [{ role: 'user', content: 'My SSN is ' }]) + assert.deepStrictEqual(result.redactionReplacements, redactionReplacements) + assert.strictEqual(messages[0].content, 'My SSN is 123-45-6789') + assertFetch(messages) + assertTelemetry('requests', { + action: 'ALLOW', + error: false, + block: false, + redacted: true, + ...sdkTags, + }) + await assertAIGuardSpan( + { 'ai_guard.target': 'prompt', 'ai_guard.action': 'ALLOW', 'ai_guard.redacted': 'true' }, + { messages: [{ role: 'user', content: 'My SSN is ' }] } + ) + }) + + it('uses one message snapshot when the caller mutates messages while evaluation is pending', async () => { + const messages = [{ role: 'user', content: 'My SSN is 123-45-6789' }] + const originalMessages = [{ role: 'user', content: 'My SSN is 123-45-6789' }] + const callerMutation = { role: 'system', content: 'Caller mutation' } + const resolveFetch = mockDeferredFetch() + + const evaluation = aiguard.evaluate(messages) + messages.unshift(callerMutation) + resolveFetch({ + body: { + data: { + attributes: { + action: 'ALLOW', + redaction_replacements: [ + { path: 'messages[0].content', replacement: 'My SSN is ' }, + ], + }, + }, + }, + }) + + const result = await evaluation + + assertFetch(originalMessages) + assert.deepStrictEqual(result.messages, [{ role: 'user', content: 'My SSN is ' }]) + assert.deepStrictEqual(messages, [callerMutation, ...originalMessages]) + await assertAIGuardSpan( + { 'ai_guard.redacted': 'true' }, + { messages: [{ role: 'user', content: 'My SSN is ' }] } + ) + }) + + it('returns complete redacted messages while truncating only the meta-struct copy', async () => { + const maxContentSize = 12 + const atLimit = 'A'.repeat(maxContentSize) + const limited = new AIGuard(tracer, { + ...config, + experimental: { + ...config.experimental, + aiguard: { ...config.experimental.aiguard, maxContentSize }, + }, + }) + const messages = [{ + role: 'user', + content: [ + { type: 'input_text', text: 'My SSN is 123-45-6789' }, + { type: 'input_text', text: atLimit }, + { type: 'input_image', image_url: { url: 'https://example.com/image.png' } }, + ], + }] + const replacement = 'My SSN is ' + mockFetch({ + body: { + data: { + attributes: { + action: 'ALLOW', + redaction_replacements: [{ path: 'messages[0].content[0].text', replacement }], + }, + }, + }, + }) + + const result = await limited.evaluate(messages) + + assert.deepStrictEqual(result.messages, [{ + role: 'user', + content: [ + { type: 'input_text', text: replacement }, + { type: 'input_text', text: atLimit }, + { type: 'input_image', image_url: { url: 'https://example.com/image.png' } }, + ], + }]) + assertTelemetry('truncated', { type: 'content', ...sdkTags }) + assert.strictEqual(count.getCalls().filter(call => call.args[0] === 'truncated').length, 1) + await assertAIGuardSpan( + { 'ai_guard.redacted': 'true' }, + { + messages: [{ + role: 'user', + content: [ + { type: 'input_text', text: replacement.slice(0, maxContentSize) }, + { type: 'input_text', text: '' }, + { type: 'input_image', image_url: { url: 'https://example.com/image.png' } }, + ], + }], + } + ) + }) + + it('skips nullish structured content parts when reporting a successful evaluation', async () => { + const messages = [{ + role: 'user', + content: [ + null, + undefined, + { type: 'input_text', text: 'Describe this image' }, + { type: 'input_image', image_url: { url: 'https://example.com/image.png' } }, + ], + }] + mockFetch({ + body: { data: { attributes: { action: 'ALLOW', reason: 'OK', is_blocking_enabled: false } } }, + }) + + const result = await aiguard.evaluate(messages) + + assert.deepStrictEqual(result.messages, messages) + assertFetch(messages) + await assertAIGuardSpan( + { 'ai_guard.target': 'prompt', 'ai_guard.action': 'ALLOW' }, + { + messages: [{ + role: 'user', + content: [ + { type: 'input_text', text: 'Describe this image' }, + { type: 'input_image', image_url: { url: 'https://example.com/image.png' } }, + ], + }], + } + ) + }) + + it('redacts blocked payloads in meta-struct without adding messages to the abort error', async () => { + const messages = [{ role: 'user', content: 'My SSN is 123-45-6789' }] + mockFetch({ + body: { + data: { + attributes: { + action: 'DENY', + reason: 'Sensitive data blocked.', + redaction_replacements: [ + { path: 'messages[0].content', replacement: 'My SSN is ' }, + ], + is_blocking_enabled: true, + }, + }, + }, + }) + + await rejects( + () => aiguard.evaluate(messages), + err => err.name === 'AIGuardAbortError' && !Object.hasOwn(err, 'messages') + ) + + await assertAIGuardSpan( + { + 'ai_guard.action': 'DENY', + 'ai_guard.blocked': 'true', + 'ai_guard.redacted': 'true', + 'error.type': 'AIGuardAbortError', + }, + { messages: [{ role: 'user', content: 'My SSN is ' }] } + ) + }) + + it('reports malformed replacements and applies valid siblings', async () => { + const messages = [ + { role: 'system', content: 'ops@acme.io' }, + { role: 'user', content: '123-45-6789' }, + ] + mockFetch({ + body: { + data: { + attributes: { + action: 'ALLOW', + redaction_replacements: [ + { path: 'messages[0].content', replacement: '' }, + { path: 'messages[8].content', replacement: 'missing' }, + { path: 'messages.invalid.content', replacement: 'malformed' }, + ], + }, + }, + }, + }) + + const result = await aiguard.evaluate(messages) + + assert.deepStrictEqual(result.messages, [ + { role: 'system', content: '' }, + { role: 'user', content: '123-45-6789' }, + ]) + assertTelemetry('error', { type: ERROR_TYPE_REDACTION, ...sdkTags }, 2) + assertTelemetry('requests', { + action: 'ALLOW', + error: false, + block: false, + redacted: true, + ...sdkTags, + }) + await assertAIGuardSpan( + { 'ai_guard.redacted': 'true' }, + { + messages: [ + { role: 'system', content: '' }, + { role: 'user', content: '123-45-6789' }, + ], + } + ) + }) + + it('reports originals when every replacement fails', async () => { + const messages = [{ role: 'user', content: '123-45-6789' }] + mockFetch({ + body: { + data: { + attributes: { + action: 'ALLOW', + tags: ['pii'], + sds_findings: [{ category: 'pii', matched_text: '123-45-6789' }], + redaction_replacements: [{ path: 'messages[8].content', replacement: '' }], + }, + }, + }, + }) + + const result = await aiguard.evaluate(messages) + + assert.deepStrictEqual(result.messages, messages) + assert.deepStrictEqual(result.sds, [{ category: 'pii', matched_text: '123-45-6789' }]) + assertTelemetry('error', { type: ERROR_TYPE_REDACTION, ...sdkTags }) + assertTelemetry('requests', { + action: 'ALLOW', + error: false, + block: false, + redacted: false, + ...sdkTags, + }) + await assertAIGuardSpan( + { 'ai_guard.redacted': 'false' }, + { + messages, + attack_categories: ['pii'], + sds: [{ category: 'pii', matched_text: '123-45-6789' }], + } + ) + }) + + it('reports a falsy non-array replacement collection as a redaction error', async () => { + const messages = [{ role: 'user', content: '123-45-6789' }] + mockFetch({ + body: { + data: { + attributes: { + action: 'ALLOW', + redaction_replacements: false, + }, + }, + }, + }) + + const result = await aiguard.evaluate(messages) + + assert.deepStrictEqual(result.messages, messages) + assert.deepStrictEqual(result.redactionReplacements, []) + assertTelemetry('error', { type: ERROR_TYPE_REDACTION, ...sdkTags }) + assertTelemetry('requests', { + action: 'ALLOW', + error: false, + block: false, + redacted: false, + ...sdkTags, + }) + await assertAIGuardSpan( + { 'ai_guard.redacted': 'false' }, + { messages } + ) + }) + + it('keeps originals and omits redaction tags when the kill-switch is off', async () => { + const disabled = new AIGuard(tracer, { + ...config, + experimental: { + ...config.experimental, + aiguard: { ...config.experimental.aiguard, redactionEnabled: false }, + }, + }) + const messages = [{ role: 'user', content: 'My SSN is 123-45-6789' }] + const redactionReplacements = [ + { path: 'messages[0].content', replacement: 'My SSN is ' }, + ] + mockFetch({ + body: { + data: { + attributes: { + action: 'ALLOW', + redaction_replacements: redactionReplacements, + }, + }, + }, + }) + + const result = await disabled.evaluate(messages) + + assert.strictEqual(result.messages, messages) + assert.strictEqual(result.messages[0], messages[0]) + assert.deepStrictEqual(result.messages, messages) + assert.deepStrictEqual(result.redactionReplacements, redactionReplacements) + const requestMetricCall = count.getCalls().find(call => call.args[0] === 'requests') + assert.ok(!Object.hasOwn(requestMetricCall.args[1], 'redacted')) + await agent.assertFirstTraceSpan(span => { + assert.ok(!Object.hasOwn(span.meta, 'ai_guard.redacted')) + assert.deepStrictEqual(msgpack.decode(span.meta_struct.ai_guard), { messages }) + }, { rejectFirst: true }) + }) + it('test evaluate with sds_findings in abort error', async () => { const sdsFindings = [ { @@ -331,6 +704,14 @@ describe('AIGuard SDK', () => { () => aiguard.evaluate(messages, { block: true }), err => err.name === 'AIGuardAbortError' && JSON.stringify(err.sds) === JSON.stringify(sdsFindings) ) + await assertAIGuardSpan( + { 'ai_guard.blocked': 'true', 'error.type': 'AIGuardAbortError' }, + { + messages, + attack_categories: ['pii'], + sds: sdsFindings, + } + ) }) it('test evaluate with API error', async () => { @@ -352,7 +733,30 @@ describe('AIGuard SDK', () => { await assertAIGuardSpan({ 'ai_guard.target': 'tool', 'error.type': 'AIGuardClientError', - }) + }, { messages: toolCall }) + }) + + it('reports the message snapshot when a failed request settles after caller mutation', async () => { + const messages = [{ role: 'user', content: 'Original message' }] + const originalMessages = [{ role: 'user', content: 'Original message' }] + const callerMutation = { role: 'system', content: 'Caller mutation' } + const resolveFetch = mockDeferredFetch() + + const evaluation = aiguard.evaluate(messages) + messages.unshift(callerMutation) + resolveFetch({ status: 503, body: { errors: [{ title: 'Unavailable' }] } }) + + await rejects( + () => evaluation, + err => err.name === 'AIGuardClientError' && err.message === 'AI Guard service call failed, status 503' + ) + + assertFetch(originalMessages) + assert.deepStrictEqual(messages, [callerMutation, ...originalMessages]) + await assertAIGuardSpan( + { 'ai_guard.target': 'prompt', 'error.type': 'AIGuardClientError' }, + { messages: originalMessages } + ) }) it('test evaluate with API exception', async () => { @@ -372,7 +776,25 @@ describe('AIGuard SDK', () => { await assertAIGuardSpan({ 'ai_guard.target': 'tool', 'error.type': 'AIGuardClientError', - }) + }, { messages: toolCall }) + }) + + it('does not mask a client error when structured content contains nullish parts', async () => { + const messages = [{ role: 'user', content: [null, { type: 'input_text', text: 'Are you sure?' }] }] + mockFetch({ error: new Error('Boom!!!') }) + + await rejects( + () => aiguard.evaluate(messages), + err => err.name === 'AIGuardClientError' && err.message === 'Unexpected error calling AI Guard service: Boom!!!' + ) + + assertTelemetry('requests', { error: true, ...sdkTags }) + assertTelemetry('error', { type: ERROR_TYPE_CLIENT, ...sdkTags }) + assertFetch(messages) + await assertAIGuardSpan( + { 'ai_guard.target': 'prompt', 'error.type': 'AIGuardClientError' }, + { messages: [{ role: 'user', content: [{ type: 'input_text', text: 'Are you sure?' }] }] } + ) }) it('test evaluate with invalid JSON', async () => { @@ -389,7 +811,7 @@ describe('AIGuard SDK', () => { await assertAIGuardSpan({ 'ai_guard.target': 'tool', 'error.type': 'AIGuardClientError', - }) + }, { messages: toolCall }) }) it('test evaluate with with missing action or response', async () => { @@ -406,14 +828,16 @@ describe('AIGuard SDK', () => { await assertAIGuardSpan({ 'ai_guard.target': 'tool', 'error.type': 'AIGuardClientError', - }) + }, { messages: toolCall }) }) it('test noop implementation', async () => { const noop = new NoopAIGuard() const result = await noop.evaluate(prompt) - result.action === 'ALLOW' - result.reason === 'AI Guard is not enabled' + assert.strictEqual(result.action, 'ALLOW') + assert.strictEqual(result.reason, 'AI Guard is not enabled') + assert.strictEqual(result.messages, prompt) + assert.deepStrictEqual(result.redactionReplacements, []) }) it('test message length truncation', async () => { diff --git a/packages/dd-trace/test/aiguard/integrations/vercel-ai.spec.js b/packages/dd-trace/test/aiguard/integrations/vercel-ai.spec.js index 9054dcfbc1b..8d19f21fb49 100644 --- a/packages/dd-trace/test/aiguard/integrations/vercel-ai.spec.js +++ b/packages/dd-trace/test/aiguard/integrations/vercel-ai.spec.js @@ -74,8 +74,10 @@ describe('AIGuard Vercel AI integration', () => { it('evaluates accumulated doStream output chunks', async () => { const chunks = [ - { type: 'text-delta', textDelta: 'Hello' }, - { type: 'text-delta', textDelta: ' world' }, + { type: 'text-delta', delta: 'Hello' }, + { type: 'text-delta', delta: ' world' }, + { type: 'text-delta', textDelta: '!' }, + { type: 'text-delta' }, ] const ctx = publish(doStreamAfterChannel, { prompt, chunks }) @@ -84,7 +86,7 @@ describe('AIGuard Vercel AI integration', () => { sinon.assert.calledOnceWithExactly(evaluate, [ { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hello world' }, + { role: 'assistant', content: 'Hello world!' }, ], { block: true, source: SOURCE_AUTO, diff --git a/packages/dd-trace/test/aiguard/messages/anthropic.spec.js b/packages/dd-trace/test/aiguard/messages/anthropic.spec.js index f0feafce1d3..ea329049e36 100644 --- a/packages/dd-trace/test/aiguard/messages/anthropic.spec.js +++ b/packages/dd-trace/test/aiguard/messages/anthropic.spec.js @@ -444,6 +444,33 @@ describe('aiguard/messages/anthropic', () => { }]) }) + it('normalizes every readable server-tool result field', () => { + const message = { + role: 'assistant', + content: [{ + type: 'code_execution_tool_result', + tool_use_id: 'srv_fields', + content: { + stderr: 'warning', + content: 'details', + }, + }, { + type: 'mcp_tool_result', + tool_use_id: 'srv_url', + content: [{ url: 'https://example.com/result' }], + }], + } + assert.deepStrictEqual(convertAnthropicMessage(message), [{ + role: 'tool', + tool_call_id: 'srv_fields', + content: 'warning\ndetails', + }, { + role: 'tool', + tool_call_id: 'srv_url', + content: 'https://example.com/result', + }]) + }) + it('extracts viewed file text from a text_editor_code_execution_tool_result', () => { const message = { role: 'assistant', diff --git a/packages/dd-trace/test/aiguard/redaction.spec.js b/packages/dd-trace/test/aiguard/redaction.spec.js new file mode 100644 index 00000000000..ad938c9c4b5 --- /dev/null +++ b/packages/dd-trace/test/aiguard/redaction.spec.js @@ -0,0 +1,313 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it } = require('mocha') + +const { redactMessages } = require('../../src/aiguard/redaction') + +describe('AI Guard redaction', () => { + for (const path of [ + 'messages[-1].content', + 'messages[0].con-tent', + 'messages[0].content ', + 'messages[0].content.', + ]) { + it(`skips malformed path ${path}`, () => { + const messages = [{ role: 'user', content: 'secret' }] + const result = redactMessages(messages, [{ path, replacement: '' }]) + + assert.strictEqual(result.messages, messages) + assert.strictEqual(result.redacted, false) + assert.strictEqual(result.failures, 1) + }) + } + + it('rejects a long malformed segment without excessive backtracking', () => { + const path = `messages[0].${'a'.repeat(100_000)}[${'9'.repeat(100_000)}x]` + const messages = [{ role: 'user', content: 'secret' }] + const result = redactMessages(messages, [{ path, replacement: '' }]) + + assert.strictEqual(result.messages, messages) + assert.strictEqual(result.redacted, false) + assert.strictEqual(result.failures, 1) + }) + + it('redacts message content, content-part text, and tool arguments in place', () => { + const messages = [ + { role: 'user', content: [{ type: 'input_text', text: 'card 4111111111111111' }] }, + { + role: 'assistant', + tool_calls: [{ + id: 'call_1', + function: { name: 'pay', arguments: '{"ssn":"123-45-6789"}' }, + }], + }, + { role: 'tool', tool_call_id: 'call_1', content: 'paid from 000123456789' }, + ] + const replacements = [ + { path: 'messages[0].content[0].text', replacement: 'card ' }, + { path: 'messages[1].tool_calls[0].function.arguments', replacement: '{"ssn":""}' }, + { path: 'messages[2].content', replacement: 'paid from ' }, + ] + + const { messages: redacted } = redactMessages(messages, replacements) + + assert.strictEqual(redacted, messages) + assert.deepStrictEqual(messages, [ + { role: 'user', content: [{ type: 'input_text', text: 'card ' }] }, + { + role: 'assistant', + tool_calls: [{ + id: 'call_1', + function: { name: 'pay', arguments: '{"ssn":""}' }, + }], + }, + { role: 'tool', tool_call_id: 'call_1', content: 'paid from ' }, + ]) + }) + + it('accepts an empty string as the remove placeholder', () => { + const messages = [{ role: 'user', content: '123-45-6789' }] + const { messages: redacted } = redactMessages(messages, [ + { path: 'messages[0].content', replacement: '' }, + ]) + + assert.strictEqual(redacted[0].content, '') + }) + + it('accepts zero-padded indexes', () => { + const messages = [{ role: 'user', content: 'secret' }] + const result = redactMessages(messages, [{ path: 'messages[00].content', replacement: '' }]) + + assert.deepStrictEqual(result.messages, [{ role: 'user', content: '' }]) + assert.strictEqual(result.redacted, true) + assert.strictEqual(result.failures, 0) + }) + + it('applies zero-padded aliases as independent raw paths', () => { + const messages = [{ role: 'user', content: 'secret' }] + const result = redactMessages(messages, [ + { path: 'messages[0].content', replacement: '' }, + { path: 'messages[00].content', replacement: '' }, + ]) + + assert.strictEqual(result.messages, messages) + assert.strictEqual(result.messages[0].content, '') + assert.strictEqual(messages[0].content, '') + assert.strictEqual(result.redacted, true) + assert.strictEqual(result.failures, 0) + }) + + it('applies identical duplicate entries once', () => { + const messages = [{ role: 'user', content: '123-45-6789' }] + const replacement = { path: 'messages[0].content', replacement: '' } + const result = redactMessages(messages, [replacement, replacement]) + + assert.strictEqual(result.redacted, true) + assert.strictEqual(result.failures, 0) + assert.strictEqual(result.messages[0].content, '') + }) + + it('keeps a conflicting path skipped after later duplicate values', () => { + const messages = [{ role: 'user', content: '123-45-6789' }] + const result = redactMessages(messages, [ + { path: 'messages[0].content', replacement: '' }, + { path: 'messages[0].content', replacement: '' }, + { path: 'messages[0].content', replacement: '' }, + ]) + + assert.strictEqual(result.messages, messages) + assert.strictEqual(result.redacted, false) + assert.strictEqual(result.failures, 1) + }) + + it('applies valid siblings while counting malformed and unresolvable entries', () => { + const messages = [ + { role: 'system', content: 'ops@acme.io' }, + { role: 'user', content: '123-45-6789' }, + ] + const result = redactMessages(messages, [ + { path: 'messages[0].content', replacement: '' }, + { path: 'messages[9].content', replacement: 'missing' }, + { path: 'messages[1].content' }, + ]) + + assert.deepStrictEqual(result.messages, [ + { role: 'system', content: '' }, + { role: 'user', content: '123-45-6789' }, + ]) + assert.strictEqual(result.redacted, true) + assert.strictEqual(result.failures, 2) + }) + + it('fails safe for invalid replacement collections and entries', () => { + const messages = [{ role: 'user', content: 'secret' }] + + assert.deepStrictEqual(redactMessages(messages, { path: 'messages[0].content' }), { + messages, + redacted: false, + failures: 1, + }) + assert.deepStrictEqual(redactMessages(messages, [undefined]), { + messages, + redacted: false, + failures: 1, + }) + assert.deepStrictEqual(redactMessages(messages, [{ path: 42, replacement: '' }]), { + messages, + redacted: false, + failures: 1, + }) + assert.deepStrictEqual(redactMessages(messages, []), { + messages, + redacted: false, + failures: 0, + }) + }) + + for (const replacements of [false, 0, '']) { + it(`treats the falsy non-array replacement collection ${JSON.stringify(replacements)} as malformed`, () => { + const messages = [{ role: 'user', content: 'secret' }] + + const result = redactMessages(messages, replacements) + + assert.strictEqual(result.messages, messages) + assert.strictEqual(messages[0].content, 'secret') + assert.strictEqual(result.redacted, false) + assert.strictEqual(result.failures, 1) + }) + } + + for (const replacements of [null, undefined]) { + it(`treats ${replacements === null ? 'null' : 'undefined'} replacements as absent`, () => { + const messages = [{ role: 'user', content: 'secret' }] + + const result = redactMessages(messages, replacements) + + assert.strictEqual(result.messages, messages) + assert.strictEqual(messages[0].content, 'secret') + assert.strictEqual(result.redacted, false) + assert.strictEqual(result.failures, 0) + }) + } + + it('returns the original messages for an empty replacement array', () => { + const messages = [{ role: 'user', content: 'secret' }] + + const result = redactMessages(messages, []) + + assert.strictEqual(result.messages, messages) + assert.strictEqual(result.redacted, false) + assert.strictEqual(result.failures, 0) + }) + + it('fails safe when reading a replacement target throws', () => { + const message = { role: 'user' } + Object.defineProperty(message, 'content', { + enumerable: true, + get () { + throw new Error('unreadable') + }, + }) + const messages = [message] + + assert.deepStrictEqual(redactMessages(messages, [ + { path: 'messages[0].content', replacement: '' }, + ]), { messages, redacted: false, failures: 1 }) + }) + + it('does not partially redact when a later target property read throws', () => { + const unreadableMessage = { role: 'user' } + Object.defineProperty(unreadableMessage, 'content', { + enumerable: true, + get () { + throw new Error('unreadable') + }, + }) + const messages = [ + { role: 'user', content: 'first secret' }, + unreadableMessage, + ] + + const result = redactMessages(messages, [ + { path: 'messages[0].content', replacement: '' }, + { path: 'messages[1].content', replacement: '' }, + ]) + + assert.strictEqual(result.messages, messages) + assert.strictEqual(messages[0].content, 'first secret') + assert.strictEqual(result.redacted, false) + assert.strictEqual(result.failures, 1) + }) + + it('fails safe when replacement preprocessing throws', () => { + const messages = [{ role: 'user', content: 'secret' }] + const replacements = new Proxy([{}], { + get (target, property, receiver) { + if (property === Symbol.iterator) throw new Error('unreadable') + return Reflect.get(target, property, receiver) + }, + }) + + const result = redactMessages(messages, replacements) + + assert.strictEqual(result.messages, messages) + assert.strictEqual(result.redacted, false) + assert.strictEqual(result.failures, 1) + }) + + for (const path of [ + 'content', + 'messages.content', + 'messages[0].content', + 'messages[0].content[0]', + 'messages[0].role', + 'messages[0].content[0]', + 'messages[0].content[1].image_url.url', + 'messages[0].content[1].image_url.url.extra', + 'messages[1].tool_calls[0].function.name', + 'messages[1].tool_calls[0].id', + ]) { + it(`skips non-string, structural, or unsupported target ${path}`, () => { + const messages = [ + { + role: 'user', + content: [ + { type: 'input_text', text: 'hello' }, + { type: 'input_image', image_url: { url: 'https://example.com/image.png' } }, + ], + }, + { + role: 'assistant', + tool_calls: [{ id: 'call_1', function: { name: 'search', arguments: '{}' } }], + }, + ] + const result = redactMessages(messages, [{ path, replacement: '' }]) + + assert.strictEqual(result.messages, messages) + assert.strictEqual(result.redacted, false) + assert.strictEqual(result.failures, 1) + }) + } + + for (const { path, message } of [ + { path: 'messages[0].metadata.text', message: { role: 'user', metadata: { text: 'ops@acme.io' } } }, + { + path: 'messages[0].tool_calls[0].arguments', + message: { role: 'assistant', tool_calls: [{ id: 'call_1', arguments: '{"ssn":"123-45-6789"}' }] }, + }, + { path: 'messages[0].content.text', message: { role: 'user', content: { text: 'ops@acme.io' } } }, + { path: 'messages[0].function.arguments', message: { role: 'user', function: { arguments: '{}' } } }, + ]) { + it(`skips string target reachable only outside the canonical productions ${path}`, () => { + const messages = [message] + + const result = redactMessages(messages, [{ path, replacement: '' }]) + + assert.strictEqual(result.messages, messages) + assert.strictEqual(result.redacted, false) + assert.strictEqual(result.failures, 1) + }) + } +}) 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 5d18bae990e..3f195ef7687 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 @@ -1,7 +1,7 @@ 'use strict' const assert = require('node:assert/strict') - +const { once } = require('node:events') const path = require('node:path') const { inspect } = require('node:util') @@ -15,11 +15,8 @@ withVersions('express', 'express', expressVersion => { describe('Attacker fingerprinting', () => { let port, server - before(() => { - return agent.load(['express', 'http'], { client: false }) - }) - - before((done) => { + before(async () => { + await agent.load(['express', 'http'], { client: false }) const express = require(`../../../../versions/express@${expressVersion}`).get() const bodyParser = require('../../../../versions/body-parser').get() @@ -30,10 +27,9 @@ withVersions('express', 'express', expressVersion => { res.end('DONE') }) - server = app.listen(port, () => { - port = (/** @type {import('net').AddressInfo} */ (server.address())).port - done() - }) + server = app.listen(port) + await once(server, 'listening') + port = (/** @type {import('net').AddressInfo} */ (server.address())).port }) after(() => { diff --git a/packages/dd-trace/test/appsec/attacker-fingerprinting.fastify.plugin.spec.js b/packages/dd-trace/test/appsec/attacker-fingerprinting.fastify.plugin.spec.js index 19e7df09689..2032192b340 100644 --- a/packages/dd-trace/test/appsec/attacker-fingerprinting.fastify.plugin.spec.js +++ b/packages/dd-trace/test/appsec/attacker-fingerprinting.fastify.plugin.spec.js @@ -15,11 +15,8 @@ withVersions('fastify', 'fastify', fastifyVersion => { describe('Attacker fingerprinting', () => { let app, server, axios - before(() => { - return agent.load(['fastify', 'http'], { client: false }) - }) - - before((done) => { + before(async () => { + await agent.load(['fastify', 'http'], { client: false }) const fastify = require(`../../../../versions/fastify@${fastifyVersion}`).get() app = fastify() @@ -28,12 +25,10 @@ withVersions('fastify', 'fastify', fastifyVersion => { reply.send('DONE') }) - app.listen({ host: '127.0.0.1', port: 0 }, () => { - const port = (/** @type {import('net').AddressInfo} */ (server.address())).port - axios = Axios.create({ baseURL: `http://127.0.0.1:${port}` }) - done() - }) + await app.listen({ host: '127.0.0.1', port: 0 }) server = app.server + const port = (/** @type {import('net').AddressInfo} */ (server.address())).port + axios = Axios.create({ baseURL: `http://127.0.0.1:${port}` }) }) after(async () => { 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 316d0cd7723..a3a93b05cd1 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 @@ -174,8 +174,9 @@ describe('nosql injection detection in mongodb - whole feature', () => { }, 'NOSQL_MONGODB_INJECTION') }) - if (semver.satisfies(specificMongooseVersion, '<7')) { - describe('using callbacks', () => { + { + const callbackSuite = semver.satisfies(specificMongooseVersion, '<7') ? describe : describe.skip + callbackSuite('using callbacks', () => { testThatRequestHasNoVulnerability(async (req, res) => { try { Test.find({ diff --git a/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.express.plugin.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.express.plugin.spec.js index 58db207b6b2..66215cd72ae 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.express.plugin.spec.js @@ -27,6 +27,7 @@ describe('Path traversal analyzer', () => { withVersions('express', 'express', version => { if (semver.intersects(version, '<=4.10.5') && NODE_MAJOR >= 24) { + // Express 4.10.5 and older cannot start on Node.js 24. // eslint-disable-next-line mocha/no-pending-tests describe.skip(`refusing to run tests as express@${version} is incompatible with Node.js ${NODE_MAJOR}`) return diff --git a/packages/dd-trace/test/appsec/iast/vulnerability-formatter/utils.spec.js b/packages/dd-trace/test/appsec/iast/vulnerability-formatter/utils.spec.js index 0f125eaa0af..ed976e62678 100644 --- a/packages/dd-trace/test/appsec/iast/vulnerability-formatter/utils.spec.js +++ b/packages/dd-trace/test/appsec/iast/vulnerability-formatter/utils.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const { describe, it } = require('mocha') +const proxyquire = require('proxyquire').noPreserveCache() const { stringifyWithRanges } = require('../../../../src/appsec/iast/vulnerabilities-formatter/utils') @@ -178,6 +179,23 @@ describe('test vulnerabiilty-formatter utils', () => { }) describe('loadSensitiveRanges = true', () => { + it('preserves values that collide with internal range markers', () => { + const { stringifyWithRanges: stringifyWithDeterministicMarker } = proxyquire( + '../../../../src/appsec/iast/vulnerabilities-formatter/utils', + { crypto: { randomBytes: () => Buffer.alloc(20) } } + ) + const marker = `DD_${'00'.repeat(20)}` + const src = [ + `${marker}SENSITIVENOTSTRING_invalid`, + `${marker}SENSITIVE_invalid`, + `${marker}invalid`, + ] + + const { value } = stringifyWithDeterministicMarker(src, {}, true) + + assert.strictEqual(value, JSON.stringify(src, null, 2)) + }) + it('Undefined ranges', () => { const src = { key: 'value', diff --git a/packages/dd-trace/test/appsec/index.next.plugin.spec.js b/packages/dd-trace/test/appsec/index.next.plugin.spec.js index 81f4e91d9ae..0a037ac5ea9 100644 --- a/packages/dd-trace/test/appsec/index.next.plugin.spec.js +++ b/packages/dd-trace/test/appsec/index.next.plugin.spec.js @@ -91,8 +91,10 @@ describe('test suite', () => { }) }) - if (appName === 'app-dir') { - it('in request body with .text() function', function (done) { + { + const requestTextTest = appName === 'app-dir' ? it : it.skip + + requestTextTest('in request body with .text() function', function (done) { this.timeout(5000) const findBodyThreat = getFindBodyThreatMethod(done) diff --git a/packages/dd-trace/test/appsec/rule_manager.spec.js b/packages/dd-trace/test/appsec/rule_manager.spec.js index 1220a2e735d..70797152143 100644 --- a/packages/dd-trace/test/appsec/rule_manager.spec.js +++ b/packages/dd-trace/test/appsec/rule_manager.spec.js @@ -495,6 +495,81 @@ describe('AppSec Rule Manager', () => { sinon.assert.calledOnceWithExactly(setDefaultBlockingActionParameters, []) }) + + it('should clear blocking actions when a modify drops the actions array', () => { + waf.updateConfig.returns({}) + + const asmWithActions = { + actions: [ + { + id: 'block', + parameters: { + location: '/redirected', + status_code: 302, + }, + }, + ], + } + const toApply = [ + { + product: 'ASM', + id: '1', + file: asmWithActions, + }, + ] + + RuleManager.updateWafFromRC(createTransaction({ toUnapply: [], toApply, toModify: [] })) + + sinon.assert.calledOnceWithExactly(setDefaultBlockingActionParameters, asmWithActions.actions) + sinon.resetHistory() + + // Same config id, new content that no longer carries an `actions` array + const toModify = [ + { + product: 'ASM', + id: '1', + file: { + exclusions: [{ ekey: 'eValue' }], + }, + }, + ] + + RuleManager.updateWafFromRC(createTransaction({ toUnapply: [], toApply: [], toModify })) + + sinon.assert.calledOnceWithExactly(setDefaultBlockingActionParameters, []) + }) + + it('should not touch blocking actions when an actionless config is modified', () => { + waf.updateConfig.returns({}) + + const toApply = [ + { + product: 'ASM', + id: '1', + file: { + exclusions: [{ ekey: 'eValue' }], + }, + }, + ] + + RuleManager.updateWafFromRC(createTransaction({ toUnapply: [], toApply, toModify: [] })) + + sinon.assert.notCalled(setDefaultBlockingActionParameters) + + const toModify = [ + { + product: 'ASM', + id: '1', + file: { + exclusions: [{ ekey: 'newValue' }], + }, + }, + ] + + RuleManager.updateWafFromRC(createTransaction({ toUnapply: [], toApply: [], toModify })) + + sinon.assert.notCalled(setDefaultBlockingActionParameters) + }) }) }) }) diff --git a/packages/dd-trace/test/ci-visibility/ci-plugin.spec.js b/packages/dd-trace/test/ci-visibility/ci-plugin.spec.js index 6f94bfc4329..2f54fc320f4 100644 --- a/packages/dd-trace/test/ci-visibility/ci-plugin.spec.js +++ b/packages/dd-trace/test/ci-visibility/ci-plugin.spec.js @@ -585,7 +585,10 @@ describe('CiPlugin', () => { } it('excludes symlinked coverage report files', function () { - if (process.platform === 'win32') this.skip() + if (process.platform === 'win32') { + // Windows does not expose the symbolic-link behavior exercised here. + this.skip() + } const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-js-coverage-reports-')) const rootDir = path.join(fixtureDir, 'root') diff --git a/packages/dd-trace/test/ci-visibility/generated-files.spec.js b/packages/dd-trace/test/ci-visibility/generated-files.spec.js index a13387255d0..b82766f84e6 100644 --- a/packages/dd-trace/test/ci-visibility/generated-files.spec.js +++ b/packages/dd-trace/test/ci-visibility/generated-files.spec.js @@ -80,7 +80,10 @@ describe('test optimization validation generated files', () => { }) it('refuses generated file paths that escape through a symbolic-link directory', function () { - if (process.platform === 'win32') this.skip() + if (process.platform === 'win32') { + // Windows does not expose the symbolic-link behavior exercised here. + this.skip() + } const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-generated-files-')) const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-generated-files-outside-')) @@ -101,7 +104,10 @@ describe('test optimization validation generated files', () => { }) it('does not delete outside files after the project root is replaced by a symbolic link', function () { - if (process.platform === 'win32') this.skip() + if (process.platform === 'win32') { + // Windows does not expose the symbolic-link replacement behavior exercised here. + this.skip() + } const base = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-generated-files-root-swap-')) const root = path.join(base, 'project') @@ -128,7 +134,10 @@ describe('test optimization validation generated files', () => { }) it('does not delete redirected files after a generated directory is replaced by a symbolic link', function () { - if (process.platform === 'win32') this.skip() + if (process.platform === 'win32') { + // Windows does not expose the symbolic-link replacement behavior exercised here. + this.skip() + } const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-generated-files-directory-swap-')) const generatedDirectory = path.join(root, 'generated') diff --git a/packages/dd-trace/test/ci-visibility/log-submission-plugin.spec.js b/packages/dd-trace/test/ci-visibility/log-submission-plugin.spec.js new file mode 100644 index 00000000000..08be3dffee1 --- /dev/null +++ b/packages/dd-trace/test/ci-visibility/log-submission-plugin.spec.js @@ -0,0 +1,422 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { channel } = require('dc-polyfill') +const proxyquire = require('proxyquire') +const sinon = require('sinon') + +require('../setup/core') + +const { publishWithCompletion } = require('../../../datadog-instrumentations/src/helpers/channel') +const { FINAL_FLUSH_TIMEOUT } = require('../../src/ci-visibility/final-flush') + +const logSubmissionCh = channel('ci:log-submission:log') +const logSubmissionFlushCh = channel('ci:log-submission:flush') +const winstonAddTransportCh = channel('ci:log-submission:winston:add-transport') +const winstonConfigureCh = channel('ci:log-submission:winston:configure') +const request = sinon.stub() +const log = { + error: sinon.stub(), +} +const pluginConfig = { + enabled: true, + DD_AGENTLESS_LOG_SUBMISSION_URL: 'http://127.0.0.1:8126', + DD_API_KEY: 'secret', + service: 'my service', + site: 'datadoghq.com', +} +const LogSubmissionPlugin = proxyquire('../../src/ci-visibility/log-submission/log-submission-plugin', { + '../../exporters/common/request': request, + '../../log': log, +}) + +/** + * @param {string | Record} message + * @param {string} [source] + * @returns {void} + */ +function publishLog (message, source = 'bunyan') { + logSubmissionCh.publish({ source, message }) +} + +describe('LogSubmissionPlugin', () => { + let beforeExitHandler + let clock + let plugin + + beforeEach(() => { + clock = sinon.useFakeTimers() + request.reset() + log.error.reset() + + const beforeExitHandlers = globalThis[Symbol.for('dd-trace')].beforeExitHandlers + const previousBeforeExitHandlers = new Set(beforeExitHandlers) + plugin = new LogSubmissionPlugin({}, {}) + plugin.configure(pluginConfig) + beforeExitHandler = [...beforeExitHandlers].find(handler => !previousBeforeExitHandlers.has(handler)) + }) + + afterEach(() => { + plugin.configure(false) + clock.restore() + }) + + it('batches Bunyan logs and submits them after one second', () => { + publishLog('{"msg":"hello"}\n') + + sinon.assert.notCalled(request) + clock.tick(999) + sinon.assert.notCalled(request) + clock.tick(1) + + sinon.assert.calledOnce(request) + const [data, options] = request.firstCall.args + assert.deepStrictEqual(JSON.parse(data), [{ msg: 'hello' }]) + assert.strictEqual(options.method, 'POST') + assert.strictEqual(options.path, '/api/v2/logs?ddsource=bunyan&service=my+service') + assert.strictEqual(options.url.href, 'http://127.0.0.1:8126/') + assert.deepStrictEqual(options.headers, { + 'DD-API-KEY': 'secret', + 'Content-Type': 'application/json', + }) + }) + + it('batches Winston-formatted logs through the shared sender', () => { + const format = {} + const createJsonFormat = sinon.stub().returns(format) + class StreamTransport { + constructor (options) { + this.options = options + } + } + const logger = { add: sinon.stub() } + + winstonAddTransportCh.publish(logger) + sinon.assert.notCalled(logger.add) + + winstonConfigureCh.publish({ createJsonFormat, StreamTransport }) + + sinon.assert.calledOnce(logger.add) + const transport = logger.add.firstCall.args[0] + assert.ok(transport instanceof StreamTransport) + assert.strictEqual(transport.options.format, format) + + transport.options.stream.write('{"level":"info","message":"hello"}') + clock.tick(1000) + + sinon.assert.calledOnce(request) + const [data, options] = request.firstCall.args + assert.deepStrictEqual(JSON.parse(data), [{ level: 'info', message: 'hello' }]) + assert.strictEqual(options.path, '/api/v2/logs?ddsource=winston&service=my+service') + }) + + it('flushes pending Bunyan logs before exit', () => { + publishLog('{"msg":"hello"}') + sinon.assert.notCalled(request) + + beforeExitHandler() + + sinon.assert.calledOnce(request) + }) + + it('completes a log submission flush immediately when no logs are pending', () => { + const onDone = sinon.spy() + + publishWithCompletion(logSubmissionFlushCh, {}, onDone) + + sinon.assert.calledOnce(onDone) + sinon.assert.notCalled(request) + }) + + it('flushes the batch and waits for the intake request to finish', () => { + let onRequestDone + request.callsFake((data, options, callback) => { + onRequestDone = callback + }) + publishLog('{"msg":"hello"}') + const onDone = sinon.spy() + + publishWithCompletion(logSubmissionFlushCh, {}, onDone) + + sinon.assert.calledOnce(request) + sinon.assert.notCalled(onDone) + onRequestDone() + sinon.assert.calledOnce(onDone) + }) + + it('does not wait for a request started after the flush snapshot', () => { + const requestCallbacks = [] + request.callsFake((data, options, callback) => requestCallbacks.push(callback)) + publishLog('{"msg":"first"}') + const onDone = sinon.spy() + publishWithCompletion(logSubmissionFlushCh, {}, onDone) + + publishLog('{"msg":"second"}') + clock.tick(1000) + assert.strictEqual(requestCallbacks.length, 2) + requestCallbacks[0]() + + sinon.assert.calledOnce(onDone) + requestCallbacks[1]() + }) + + it('releases a flush when the intake request fails', () => { + let onRequestDone + request.callsFake((data, options, callback) => { + onRequestDone = callback + }) + publishLog('{"msg":"hello"}') + const onDone = sinon.spy() + const error = new Error('boom') + publishWithCompletion(logSubmissionFlushCh, {}, onDone) + + onRequestDone(error) + + sinon.assert.calledOnce(onDone) + sinon.assert.calledWith(log.error, 'Error submitting %s logs', 'bunyan', error) + }) + + it('aborts pending requests and releases the final flush at the deadline', () => { + request.callsFake(() => {}) + publishLog('{"msg":"hello"}') + const onDone = sinon.spy() + + publishWithCompletion(logSubmissionFlushCh, {}, onDone) + + const { signal } = request.firstCall.args[1] + clock.tick(FINAL_FLUSH_TIMEOUT - 1) + sinon.assert.notCalled(onDone) + assert.strictEqual(signal.aborted, false) + clock.tick(1) + sinon.assert.calledOnce(onDone) + assert.strictEqual(signal.aborted, true) + assert.strictEqual(signal.reason.code, 'ERR_DD_TEST_OPTIMIZATION_FLUSH_TIMEOUT') + }) + + it('waits for every intake request in the flush snapshot', () => { + const requestCallbacks = [] + request.callsFake((data, options, callback) => requestCallbacks.push(callback)) + publishLog('{"msg":"bunyan"}') + publishLog('{"msg":"pino"}', 'pino') + const onDone = sinon.spy() + + publishWithCompletion(logSubmissionFlushCh, {}, onDone) + + assert.strictEqual(requestCallbacks.length, 2) + requestCallbacks[0]() + sinon.assert.notCalled(onDone) + requestCallbacks[1]() + sinon.assert.calledOnce(onDone) + }) + + it('releases a flush when request throws synchronously', () => { + const error = new Error('boom') + request.throws(error) + publishLog('{"msg":"hello"}') + const onDone = sinon.spy() + + publishWithCompletion(logSubmissionFlushCh, {}, onDone) + + sinon.assert.calledOnce(onDone) + sinon.assert.calledWith(log.error, 'Error submitting %s logs', 'bunyan', error) + }) + + it('uses the default logs intake when no override is configured', () => { + plugin.configure({ + ...pluginConfig, + DD_AGENTLESS_LOG_SUBMISSION_URL: undefined, + }) + publishLog('{"msg":"hello"}') + clock.tick(1000) + + assert.strictEqual(request.firstCall.args[1].url.href, 'https://http-intake.logs.datadoghq.com/') + }) + + it('accepts uppercase letters in the default logs intake site', () => { + plugin.configure({ + ...pluginConfig, + DD_AGENTLESS_LOG_SUBMISSION_URL: undefined, + site: 'DATADOGHQ.COM', + }) + publishLog('{"msg":"hello"}') + clock.tick(1000) + + assert.strictEqual(request.firstCall.args[1].url.href, 'https://http-intake.logs.datadoghq.com/') + }) + + it('does not submit to a URL constructed from an invalid site', () => { + plugin.configure({ + ...pluginConfig, + DD_AGENTLESS_LOG_SUBMISSION_URL: undefined, + site: 'datadoghq.com@other.example', + }) + publishLog('{"msg":"hello"}') + clock.tick(1000) + + sinon.assert.notCalled(request) + sinon.assert.calledWith( + log.error, + 'Could not parse automatic log submission site: %s', + 'datadoghq.com@other.example' + ) + }) + + it('does not submit when the configured URL uses an unsupported protocol', () => { + plugin.configure({ + ...pluginConfig, + DD_AGENTLESS_LOG_SUBMISSION_URL: 'file:///tmp/logs', + }) + publishLog('{"msg":"hello"}') + clock.tick(1000) + + sinon.assert.notCalled(request) + sinon.assert.calledWith(log.error, 'Unsupported automatic log submission URL protocol: %s', 'file:') + }) + + it('does not submit when the configured URL is invalid', () => { + plugin.configure({ + ...pluginConfig, + DD_AGENTLESS_LOG_SUBMISSION_URL: 'not a URL', + }) + publishLog('{"msg":"hello"}') + clock.tick(1000) + + sinon.assert.notCalled(request) + sinon.assert.calledWith(log.error, 'Could not parse DD_AGENTLESS_LOG_SUBMISSION_URL') + }) + + it('flushes at 1000 logs and leaves the next log for the next batch', () => { + for (let index = 0; index < 999; index++) { + publishLog('{"msg":"hello"}') + } + sinon.assert.notCalled(request) + + publishLog('{"msg":"hello"}') + sinon.assert.calledOnce(request) + assert.strictEqual(JSON.parse(request.firstCall.args[0]).length, 1000) + + publishLog('{"msg":"next"}') + sinon.assert.calledOnce(request) + clock.tick(1000) + sinon.assert.calledTwice(request) + assert.deepStrictEqual(JSON.parse(request.secondCall.args[0]), [{ msg: 'next' }]) + }) + + it('does not mix different log sources in the same batch', () => { + publishLog('{"msg":"bunyan"}') + publishLog('{"msg":"pino"}', 'pino') + + sinon.assert.calledOnce(request) + assert.deepStrictEqual(JSON.parse(request.firstCall.args[0]), [{ msg: 'bunyan' }]) + assert.strictEqual(request.firstCall.args[1].path, '/api/v2/logs?ddsource=bunyan&service=my+service') + + clock.tick(1000) + sinon.assert.calledTwice(request) + assert.deepStrictEqual(JSON.parse(request.secondCall.args[0]), [{ msg: 'pino' }]) + assert.strictEqual(request.secondCall.args[1].path, '/api/v2/logs?ddsource=pino&service=my+service') + }) + + it('does not retain a log after a source-change pre-flush fails synchronously', () => { + request.throws(new Error('boom')) + publishLog('{"msg":"bunyan"}') + + publishLog('{"msg":"pino"}', 'pino') + + sinon.assert.calledOnce(request) + plugin.configure(pluginConfig) + beforeExitHandler() + sinon.assert.calledOnce(request) + }) + + it('accepts the byte limit and rejects the first byte over it', () => { + const maximumBatchBytes = 5 * 1024 * 1024 + const acceptedMessage = `"${'a'.repeat(maximumBatchBytes - 4)}"` + publishLog(acceptedMessage) + + sinon.assert.calledOnce(request) + assert.strictEqual(request.firstCall.args[0].length, maximumBatchBytes) + + const rejectedMessage = `"${'a'.repeat(maximumBatchBytes - 3)}"` + publishLog(rejectedMessage) + + sinon.assert.calledOnce(request) + sinon.assert.calledWith( + log.error, + 'Could not submit %s log because it exceeds the %d byte payload limit', + 'bunyan', + maximumBatchBytes + ) + }) + + it('flushes the current batch before adding a log that would exceed the byte limit', () => { + const maximumBatchBytes = 5 * 1024 * 1024 + const firstMessage = `"${'a'.repeat(maximumBatchBytes - 5)}"` + publishLog(firstMessage) + sinon.assert.notCalled(request) + + publishLog('0') + sinon.assert.calledOnce(request) + assert.strictEqual(request.firstCall.args[0].length, maximumBatchBytes - 1) + + clock.tick(1000) + sinon.assert.calledTwice(request) + assert.deepStrictEqual(JSON.parse(request.secondCall.args[0]), [0]) + }) + + it('does not retain a log after a byte-limit pre-flush fails synchronously', () => { + const maximumBatchBytes = 5 * 1024 * 1024 + const firstMessage = `"${'a'.repeat(maximumBatchBytes - 5)}"` + request.throws(new Error('boom')) + publishLog(firstMessage) + + publishLog('0') + + sinon.assert.calledOnce(request) + plugin.configure(pluginConfig) + beforeExitHandler() + sinon.assert.calledOnce(request) + }) + + it('does not throw when a raw Bunyan record cannot be serialized', () => { + const message = {} + message.self = message + + publishLog(message) + + sinon.assert.notCalled(request) + sinon.assert.calledWith( + log.error, + 'Could not serialize %s log for automatic submission', + 'bunyan', + sinon.match.instanceOf(TypeError) + ) + assert.strictEqual(plugin._enabled, true) + }) + + it('does not throw or remain pending when the request fails', () => { + const failure = new Error('boom') + request.callsFake((data, options, callback) => callback(failure)) + + publishLog('{"msg":"hello"}') + clock.tick(1000) + + sinon.assert.calledWith(log.error, 'Error submitting %s logs', 'bunyan', failure) + assert.strictEqual(plugin._enabled, true) + }) + + it('disables submission before reporting synchronous request failures', () => { + const failure = new Error('boom') + request.throws(failure) + log.error.callsFake(() => publishLog('{"msg":"sender failure"}')) + + publishLog('{"msg":"hello"}') + clock.tick(1000) + + sinon.assert.calledOnce(request) + sinon.assert.calledWith(log.error, 'Error submitting %s logs', 'bunyan', failure) + clock.tick(1000) + sinon.assert.calledOnce(request) + assert.strictEqual(plugin._enabled, true) + }) +}) diff --git a/packages/dd-trace/test/ci-visibility/manifest-scaffold.spec.js b/packages/dd-trace/test/ci-visibility/manifest-scaffold.spec.js index 8b09f8a8e27..689c4fd3a3b 100644 --- a/packages/dd-trace/test/ci-visibility/manifest-scaffold.spec.js +++ b/packages/dd-trace/test/ci-visibility/manifest-scaffold.spec.js @@ -1958,7 +1958,7 @@ describe('test optimization validation manifest scaffold', () => { } }) - it('retains Cypress configuration for generated checks', () => { + it('retains Cypress configuration and source for generated checks', () => { const fixture = createRepositoryFixture({ framework: 'cypress', script: 'cypress run --spec cypress/e2e/example.cy.js --browser chrome --config-file cypress.custom.js --e2e', @@ -1971,6 +1971,8 @@ describe('test optimization validation manifest scaffold', () => { }).frameworks[0] const scenario = framework.generatedTestStrategy.scenarios[0] const command = getGeneratedCommand(framework, scenario) + const basicFile = framework.generatedTestStrategy.files[0] + const retryFile = framework.generatedTestStrategy.files[1] assert.deepStrictEqual(framework.validation.runnerArgs, [ '--browser', @@ -1989,6 +1991,16 @@ describe('test optimization validation manifest scaffold', () => { 'cypress.custom.js', '--e2e', ]) + assert.strictEqual( + basicFile.contentLines.join('\n'), + 'describe(\'dd-test-optimization-validation\', () => {\n it("basic-pass", () => {\n' + + ' expect(true).to.equal(true)\n })\n})' + ) + assert.strictEqual( + retryFile.contentLines.join('\n'), + 'let attempt = 0\n\ndescribe(\'dd-test-optimization-validation\', () => {\n' + + ' it("atr-fail-once", () => {\n expect(attempt++).to.equal(1)\n })\n})' + ) } finally { removeFixture(fixture.root) } diff --git a/packages/dd-trace/test/ci-visibility/offline-validation.spec.js b/packages/dd-trace/test/ci-visibility/offline-validation.spec.js index f685143a5f4..1cc55e7cf70 100644 --- a/packages/dd-trace/test/ci-visibility/offline-validation.spec.js +++ b/packages/dd-trace/test/ci-visibility/offline-validation.spec.js @@ -248,7 +248,10 @@ describe('test optimization offline validation artifacts', () => { }) it('rejects symbolic-link and hard-linked payload files', function () { - if (process.platform === 'win32') this.skip() + if (process.platform === 'win32') { + // Windows does not expose the link semantics exercised here. + this.skip() + } const { outputRoot, testsDirectory, processId } = createPayloadRoot(repositoryRoot) const source = path.join(repositoryRoot, 'source.json') fs.writeFileSync(source, JSON.stringify(createTestCyclePayload())) @@ -323,7 +326,10 @@ describe('test optimization offline validation artifacts', () => { }) it('rejects malformed and hard-linked completion records', function () { - if (process.platform === 'win32') this.skip() + if (process.platform === 'win32') { + // Windows does not expose the hard-link semantics exercised here. + this.skip() + } const { outputRoot, processId } = createPayloadRoot(repositoryRoot) const completionPath = path.join(outputRoot, 'completions', `completion-${processId}.json`) diff --git a/packages/dd-trace/test/ci-visibility/static-diagnosis.spec.js b/packages/dd-trace/test/ci-visibility/static-diagnosis.spec.js index e51c2091882..8495e09b4f8 100644 --- a/packages/dd-trace/test/ci-visibility/static-diagnosis.spec.js +++ b/packages/dd-trace/test/ci-visibility/static-diagnosis.spec.js @@ -102,7 +102,10 @@ describe('test optimization validation static diagnosis', () => { }) it('ignores a root package.json symbolic link that escapes the repository', function () { - if (process.platform === 'win32') this.skip() + if (process.platform === 'win32') { + // Windows does not expose the symbolic-link behavior exercised here. + this.skip() + } const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-static-diagnosis-')) const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-static-diagnosis-outside-')) @@ -139,7 +142,10 @@ describe('test optimization validation static diagnosis', () => { }) it('does not execute git from a repository-controlled PATH directory', function () { - if (process.platform === 'win32') this.skip() + if (process.platform === 'win32') { + // Windows does not use the PATH lookup behavior exercised here. + this.skip() + } const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-static-diagnosis-')) const bin = path.join(root, 'node_modules', '.bin') diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index 22df5332176..9c1084427e5 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -896,6 +896,25 @@ describe('Config', () => { }) }) + describe('HTTP client error statuses', () => { + it('should default to 400-499', () => { + const config = getConfig() + + assert.strictEqual(config.DD_TRACE_HTTP_CLIENT_ERROR_STATUSES, '400-499') + }) + + it('should initialize from DD_TRACE_HTTP_CLIENT_ERROR_STATUSES', () => { + process.env.DD_TRACE_HTTP_CLIENT_ERROR_STATUSES = '500-599' + + const config = getConfig() + + assert.strictEqual(config.DD_TRACE_HTTP_CLIENT_ERROR_STATUSES, '500-599') + assertConfigUpdateContains(updateConfig.firstCall.args[0], [ + { name: 'DD_TRACE_HTTP_CLIENT_ERROR_STATUSES', value: '500-599', origin: 'env_var' }, + ]) + }) + }) + it('should initialize with the correct defaults', () => { const config = getConfig() @@ -964,6 +983,7 @@ describe('Config', () => { enabled: false, endpoint: undefined, maxMessagesLength: 16, + redactionEnabled: true, timeout: 10_000, maxContentSize: 512 * 1024, }, @@ -1098,6 +1118,7 @@ describe('Config', () => { { name: 'DD_AI_GUARD_ENDPOINT', value: null, origin: 'default' }, { name: 'DD_AI_GUARD_MAX_CONTENT_SIZE', value: 512 * 1024, origin: 'default' }, { name: 'DD_AI_GUARD_MAX_MESSAGES_LENGTH', value: 16, origin: 'default' }, + { name: 'DD_AI_GUARD_REDACTION_ENABLED', value: true, origin: 'default' }, { name: 'DD_AI_GUARD_TIMEOUT', value: 10_000, origin: 'default' }, { name: 'DD_TRACE_EXPERIMENTAL_GET_RUM_DATA_ENABLED', value: false, origin: 'default' }, { name: 'DD_TRACE_EXPERIMENTAL_EXPORTER', value: '', origin: 'default' }, @@ -1254,6 +1275,7 @@ describe('Config', () => { process.env.DD_AI_GUARD_ENDPOINT = 'https://dd.datad0g.com/api/unstable/ai-guard' process.env.DD_AI_GUARD_MAX_CONTENT_SIZE = String(1024 * 1024) process.env.DD_AI_GUARD_MAX_MESSAGES_LENGTH = '32' + process.env.DD_AI_GUARD_REDACTION_ENABLED = 'false' process.env.DD_AI_GUARD_TIMEOUT = '2000' process.env.DD_API_SECURITY_ENABLED = 'true' process.env.DD_API_SECURITY_SAMPLE_DELAY = '25' @@ -1445,6 +1467,7 @@ describe('Config', () => { endpoint: 'https://dd.datad0g.com/api/unstable/ai-guard', maxContentSize: 1024 * 1024, maxMessagesLength: 32, + redactionEnabled: false, timeout: 2000, }, enableGetRumData: true, @@ -1581,6 +1604,7 @@ describe('Config', () => { { name: 'DD_AI_GUARD_ENDPOINT', value: null, origin: 'default' }, { name: 'DD_AI_GUARD_MAX_CONTENT_SIZE', value: 512 * 1024, origin: 'default' }, { name: 'DD_AI_GUARD_MAX_MESSAGES_LENGTH', value: 16, origin: 'default' }, + { name: 'DD_AI_GUARD_REDACTION_ENABLED', value: true, origin: 'default' }, { name: 'DD_AI_GUARD_TIMEOUT', value: 10_000, origin: 'default' }, { name: 'DD_AI_GUARD_ENABLED', value: true, origin: 'env_var' }, { name: 'DD_AI_GUARD_BLOCK', value: true, origin: 'env_var' }, @@ -1588,6 +1612,7 @@ describe('Config', () => { { name: 'DD_AI_GUARD_TIMEOUT', value: 2000, origin: 'env_var' }, { name: 'DD_AI_GUARD_MAX_CONTENT_SIZE', value: 1024 * 1024, origin: 'env_var' }, { name: 'DD_AI_GUARD_MAX_MESSAGES_LENGTH', value: 32, origin: 'env_var' }, + { name: 'DD_AI_GUARD_REDACTION_ENABLED', value: false, origin: 'env_var' }, { name: 'DD_TRACE_EXPERIMENTAL_GET_RUM_DATA_ENABLED', value: true, origin: 'env_var' }, { name: 'DD_TRACE_EXPERIMENTAL_EXPORTER', value: 'log', origin: 'env_var' }, { name: 'DD_AGENT_HOST', value: 'agent', origin: 'env_var' }, @@ -1949,6 +1974,7 @@ describe('Config', () => { endpoint: 'https://dd.datad0g.com/api/unstable/ai-guard', maxContentSize: 1024 * 1024, maxMessagesLength: 32, + redactionEnabled: true, timeout: 2000, }, exporter: 'log', @@ -2055,6 +2081,7 @@ describe('Config', () => { endpoint: 'https://dd.datad0g.com/api/unstable/ai-guard', maxContentSize: 1024 * 1024, maxMessagesLength: 32, + redactionEnabled: true, timeout: 2000, }, enableGetRumData: true, @@ -2200,6 +2227,7 @@ describe('Config', () => { { name: 'DD_AI_GUARD_ENDPOINT', value: 'https://dd.datad0g.com/api/unstable/ai-guard', origin: 'code' }, { name: 'DD_AI_GUARD_MAX_CONTENT_SIZE', value: 1024 * 1024, origin: 'code' }, { name: 'DD_AI_GUARD_MAX_MESSAGES_LENGTH', value: 32, origin: 'code' }, + { name: 'DD_AI_GUARD_REDACTION_ENABLED', value: true, origin: 'code' }, { name: 'DD_AI_GUARD_TIMEOUT', value: 2_000, origin: 'code' }, { name: 'DD_TRACE_EXPERIMENTAL_GET_RUM_DATA_ENABLED', value: true, origin: 'code' }, { name: 'DD_TRACE_EXPERIMENTAL_EXPORTER', value: 'log', origin: 'code' }, @@ -2423,6 +2451,7 @@ describe('Config', () => { process.env.DD_AI_GUARD_ENDPOINT = 'https://dd.datadog.com/api/unstable/ai-guard' process.env.DD_AI_GUARD_MAX_CONTENT_SIZE = String(512 * 1024) process.env.DD_AI_GUARD_MAX_MESSAGES_LENGTH = '16' + process.env.DD_AI_GUARD_REDACTION_ENABLED = 'false' process.env.DD_AI_GUARD_TIMEOUT = '1000' process.env.DD_API_KEY = '123' process.env.DD_API_SECURITY_ENABLED = 'false' @@ -2559,6 +2588,7 @@ describe('Config', () => { endpoint: 'https://dd.datad0g.com/api/unstable/ai-guard', maxContentSize: 1024 * 1024, maxMessagesLength: 32, + redactionEnabled: true, timeout: 2000, }, b3: false, @@ -2673,6 +2703,7 @@ describe('Config', () => { endpoint: 'https://dd.datad0g.com/api/unstable/ai-guard', maxContentSize: 1024 * 1024, maxMessagesLength: 32, + redactionEnabled: true, timeout: 2000, }, enableGetRumData: false, @@ -3893,6 +3924,35 @@ describe('Config', () => { }]) }) + it('should configure the experiments project name from options and enable llmobs', () => { + const config = getConfig({ llmobs: { projectName: 'experiments-project' } }) + assert.strictEqual(config.llmobs.projectName, 'experiments-project') + assert.strictEqual(config.llmobs.DD_LLMOBS_ENABLED, true) + }) + + context('DD_LLMOBS_PROJECT_NAME', () => { + let savedEnv + + beforeEach(() => { + savedEnv = process.env + process.env.DD_LLMOBS_PROJECT_NAME = 'env-project' + }) + + afterEach(() => { + process.env = savedEnv + }) + + it('should configure the experiments project name from the environment', () => { + const config = getConfig() + assert.strictEqual(config.llmobs.projectName, 'env-project') + assert.strictEqual(config.llmobs.DD_LLMOBS_ENABLED, true) + + assertConfigUpdateContains(updateConfig.getCall(0).args[0], [{ + name: 'DD_LLMOBS_PROJECT_NAME', value: 'env-project', origin: 'env_var', + }]) + }) + }) + it('should have DD_LLMOBS_ENABLED take priority over options', () => { process.env.DD_LLMOBS_ENABLED = 'false' const config = getConfig({ llmobs: { agentlessEnabled: true } }) diff --git a/packages/dd-trace/test/dogstatsd.spec.js b/packages/dd-trace/test/dogstatsd.spec.js index 2544531201b..260c87182a8 100644 --- a/packages/dd-trace/test/dogstatsd.spec.js +++ b/packages/dd-trace/test/dogstatsd.spec.js @@ -12,6 +12,7 @@ const proxyquire = require('proxyquire') const datadogCore = require('../../datadog-core') require('./setup/core') +const TelemetryDeliveryTracker = require('../src/serverless/telemetry-delivery-tracker') describe('dogstatsd', () => { let client @@ -32,6 +33,8 @@ describe('dogstatsd', () => { let assertData let docker let log + let registerTelemetryFlusher + let createServerlessDeliveryTracker beforeEach((done) => { udp6 = { @@ -74,10 +77,14 @@ describe('dogstatsd', () => { docker = {} log = { debug: sinon.stub(), error: sinon.stub() } + registerTelemetryFlusher = sinon.stub() + createServerlessDeliveryTracker = sinon.stub() const dogstatsd = proxyquire.noPreserveCache().noCallThru()('../src/dogstatsd', { dgram, '../../datadog-core': datadogCore, + './flush': { registerTelemetryFlusher }, + './serverless': { createServerlessDeliveryTracker }, './exporters/common/docker': docker, './log': log, }) @@ -237,6 +244,40 @@ describe('dogstatsd', () => { sinon.assert.notCalled(log.debug) }) + it('calls the flush callback after UDP accepts the metrics', (done) => { + udp4.send = sinon.stub().callsFake((...args) => args.at(-1)()) + client = createDogStatsDClient() + + client.gauge('test.avg', 1) + client.flush(() => { + sinon.assert.calledOnce(udp4.send) + done() + }) + }) + + it('joins an already in-flight UDP flush', (done) => { + let completeFirstFlush + udp4.send = sinon.stub().callsFake((...args) => { + completeFirstFlush = args.at(-1) + }) + createServerlessDeliveryTracker.returns(new TelemetryDeliveryTracker()) + client = createDogStatsDClient() + client.gauge('test.avg', 1) + client.flush() + + client.flush(() => { + try { + sinon.assert.calledOnce(udp4.send) + done() + } catch (error) { + done(error) + } + }) + + assert.strictEqual(completeFirstFlush instanceof Function, true) + completeFirstFlush() + }) + it('logs the metric count and the UDP transport on a non-empty flush', () => { client = createDogStatsDClient() @@ -388,6 +429,22 @@ describe('dogstatsd', () => { client.flush() }) + it('calls the flush callback after the HTTP proxy responds', (done) => { + client = createDogStatsDClient({ + metricsProxyUrl: `http://localhost:${httpPort}`, + }) + + client.gauge('test.avg', 1) + client.flush(() => { + try { + assert.strictEqual(Buffer.concat(httpData).toString(), 'test.avg:1|g\n') + done() + } catch (error) { + done(error) + } + }) + }) + it('should support HTTP via URL object', (done) => { assertData = () => { try { @@ -444,13 +501,23 @@ describe('dogstatsd', () => { } }) - statusCode = null + const request = sinon.stub().callsFake((buffer, options, callback) => { + callback(new Error('connection refused')) + }) + const { DogStatsDClient: FailingDogStatsDClient } = proxyquire.noPreserveCache().noCallThru()('../src/dogstatsd', { + dgram, + '../../datadog-core': datadogCore, + './exporters/common/docker': docker, + './exporters/common/request': request, + './log': log, + }) - // host exists but port does not, ECONNREFUSED - client = createDogStatsDClient({ - metricsProxyUrl: 'http://localhost:32700', + client = new FailingDogStatsDClient({ host: 'localhost', + lookup: dns.lookup, + metricsProxyUrl: 'http://localhost:8126', port: 8125, + tags: [], }) client.increment('test.foo', 10) @@ -459,6 +526,18 @@ describe('dogstatsd', () => { }) describe('CustomMetrics', () => { + it('registers its aggregated metrics flush with the telemetry lifecycle', () => { + udp4.send = sinon.stub().callsFake((_buffer, _offset, _length, _port, _host, done) => done()) + client = createCustomMetrics() + client.gauge('test.avg', 10) + const done = sinon.spy() + + registerTelemetryFlusher.firstCall.args[0](done) + + sinon.assert.calledOnce(done) + sinon.assert.calledOnce(udp4.send) + }) + it('.gauge()', () => { client = createCustomMetrics() diff --git a/packages/dd-trace/test/encode/agentless-json.spec.js b/packages/dd-trace/test/encode/agentless-json.spec.js index 908bcfb7ba3..656fe2dd8b4 100644 --- a/packages/dd-trace/test/encode/agentless-json.spec.js +++ b/packages/dd-trace/test/encode/agentless-json.spec.js @@ -323,6 +323,19 @@ describe('AgentlessJSONEncoder', () => { }) }) + it('should encode multiple traces without metadata', () => { + const encoderWithoutMetadata = new AgentlessJSONEncoder(writer, {}) + encoderWithoutMetadata.encode(data) + encoderWithoutMetadata.encode([childSpan]) + + const decoded = JSON.parse(encoderWithoutMetadata.makePayload().toString()) + + assert.strictEqual(decoded.traces.length, 2) + assert.strictEqual(decoded.traces[0].spans[0].name, 'test') + assert.strictEqual(decoded.traces[1].spans[0].name, 'child') + assert.strictEqual(decoded.traces[0].hostname, undefined) + }) + it('should set _dd.compute_stats on first span of each trace', () => { encoder.encode(data) encoder.encode([childSpan]) diff --git a/packages/dd-trace/test/exporters/agent/exporter.spec.js b/packages/dd-trace/test/exporters/agent/exporter.spec.js index e9b38fa44b9..1833b17e1a8 100644 --- a/packages/dd-trace/test/exporters/agent/exporter.spec.js +++ b/packages/dd-trace/test/exporters/agent/exporter.spec.js @@ -8,6 +8,7 @@ const sinon = require('sinon') const proxyquire = require('proxyquire') require('../../setup/core') +const TelemetryDeliveryTracker = require('../../../src/serverless/telemetry-delivery-tracker') describe('Exporter', () => { let url @@ -18,6 +19,8 @@ describe('Exporter', () => { let writer let prioritySampler let span + let writerOptions + let createServerlessDeliveryTracker beforeEach(() => { url = 'http://www.example.com:8126' @@ -29,10 +32,15 @@ describe('Exporter', () => { setUrl: sinon.spy(), } prioritySampler = {} - Writer = sinon.stub().returns(writer) + Writer = sinon.stub().callsFake(options => { + writerOptions = options + return writer + }) + createServerlessDeliveryTracker = sinon.stub() Exporter = proxyquire('../../../src/exporters/agent', { './writer': Writer, + '../../serverless': { createServerlessDeliveryTracker }, }) }) @@ -112,6 +120,92 @@ describe('Exporter', () => { }) }) + describe('flush', () => { + beforeEach(() => { + createServerlessDeliveryTracker.returns(new TelemetryDeliveryTracker()) + }) + + it('waits for trace exports already in flight', () => { + const callbacks = [] + writer.flush = sinon.spy(done => { + writerOptions.deliveryTracker.track(callback => callbacks.push(callback), done) + }) + exporter = new Exporter({ url, flushInterval: 0 }, prioritySampler) + const flushed = sinon.spy() + + exporter.export([span]) + exporter.flush(flushed) + + callbacks[1]() + sinon.assert.notCalled(flushed) + callbacks[0]() + sinon.assert.calledOnce(flushed) + }) + + it('waits for an encoder-triggered writer flush already in flight', () => { + const callbacks = [] + const flushDirect = sinon.spy(done => callbacks.push(done)) + writer.flushDirect = flushDirect + writer.flush = sinon.spy(done => writerOptions.deliveryTracker.track(flushDirect, done)) + exporter = new Exporter({ url, flushInterval: 0 }, prioritySampler) + const flushed = sinon.spy() + + // This is the path the encoder uses when it crosses its soft limit. + exporter._writer.flush() + exporter.flush(flushed) + + callbacks[1]() + sinon.assert.notCalled(flushed) + callbacks[0]() + sinon.assert.calledOnce(flushed) + }) + + it('does not retain a failed writer flush', () => { + writer.flush = sinon.stub() + writer.flush.onFirstCall().throws(new Error('encode failed')) + writer.flush.onSecondCall().callsFake(done => done()) + exporter = new Exporter({ url, flushInterval: 0 }, prioritySampler) + const flushed = sinon.spy() + + assert.throws(() => exporter.export([span]), /encode failed/) + exporter.flush(flushed) + + sinon.assert.calledOnce(flushed) + }) + + it('waits for an earlier export when the boundary flush fails', () => { + let inFlightDone + writer.flush = sinon.stub() + writer.flush.onFirstCall().callsFake(done => { + writerOptions.deliveryTracker.track(callback => { inFlightDone = callback }, done) + }) + writer.flush.onSecondCall().throws(new Error('encode failed')) + exporter = new Exporter({ url, flushInterval: 0 }, prioritySampler) + const flushed = sinon.spy() + + exporter.export([span]) + exporter.flush(flushed) + + sinon.assert.notCalled(flushed) + inFlightDone() + sinon.assert.calledOnce(flushed) + }) + + it('waits for the boundary export without serverless retention', () => { + createServerlessDeliveryTracker.resetBehavior() + let complete + writer.flush = sinon.stub().callsFake(done => { complete = done }) + exporter = new Exporter({ url, flushInterval: 0 }, prioritySampler) + const flushed = sinon.spy() + + exporter.flush(flushed) + + sinon.assert.notCalled(flushed) + complete() + sinon.assert.calledOnce(flushed) + }) + }) + describe('setUrl', () => { beforeEach(() => { exporter = new Exporter({ url }) diff --git a/packages/dd-trace/test/exporters/agent/writer.spec.js b/packages/dd-trace/test/exporters/agent/writer.spec.js index 7ed70d40476..07e3eca4ed8 100644 --- a/packages/dd-trace/test/exporters/agent/writer.spec.js +++ b/packages/dd-trace/test/exporters/agent/writer.spec.js @@ -109,6 +109,20 @@ function describeWriter (protocolVersion) { writer.flush(done) }) + it('routes flushes through the configured delivery tracker', (done) => { + const deliveryTracker = { track: sinon.spy((flush, done) => flush(done)) } + writer = new Writer({ url, prioritySampler, protocolVersion, deliveryTracker }) + + writer.flush(() => { + try { + sinon.assert.calledOnce(deliveryTracker.track) + done() + } catch (error) { + done(error) + } + }) + }) + it('should flush its traces to the agent, and call callback', (done) => { const expectedData = Buffer.from('prefixed') diff --git a/packages/dd-trace/test/exporters/common/writer.spec.js b/packages/dd-trace/test/exporters/common/writer.spec.js index d5e03b77ab9..29db98e223b 100644 --- a/packages/dd-trace/test/exporters/common/writer.spec.js +++ b/packages/dd-trace/test/exporters/common/writer.spec.js @@ -75,6 +75,18 @@ describe('common Writer', () => { sinon.assert.calledOnceWithExactly(writer._sendPayload, payload, 2, done) }) + it('routes automatic flushes through the configured delivery tracker', () => { + const deliveryTracker = { track: sinon.stub().callsFake((flush, done) => flush(done)) } + writer = new Writer({ url: 'http://localhost:8126', deliveryTracker }) + writer._encoder = encoder + writer._sendPayload = sinon.stub() + + writer.flush() + + sinon.assert.calledOnce(deliveryTracker.track) + sinon.assert.calledOnce(writer._sendPayload) + }) + it('passes final flush options to the payload sender', () => { const done = sinon.stub() const options = { deadline: Date.now() + 1000 } diff --git a/packages/dd-trace/test/exporters/span-stats/exporter.spec.js b/packages/dd-trace/test/exporters/span-stats/exporter.spec.js index 30c431d7e03..2174b9f6d78 100644 --- a/packages/dd-trace/test/exporters/span-stats/exporter.spec.js +++ b/packages/dd-trace/test/exporters/span-stats/exporter.spec.js @@ -8,6 +8,7 @@ const sinon = require('sinon') const proxyquire = require('proxyquire') require('../../setup/core') +const TelemetryDeliveryTracker = require('../../../src/serverless/telemetry-delivery-tracker') describe('span-stats exporter', () => { let url @@ -15,6 +16,9 @@ describe('span-stats exporter', () => { let exporter let Writer let writer + let writerOptions + let log + let createServerlessDeliveryTracker beforeEach(() => { url = new URL('http://www.example.com:8126') @@ -22,10 +26,17 @@ describe('span-stats exporter', () => { append: sinon.spy(), flush: sinon.spy(), } - Writer = sinon.stub().returns(writer) + Writer = sinon.stub().callsFake(options => { + writerOptions = options + return writer + }) + log = { error: sinon.spy() } + createServerlessDeliveryTracker = sinon.stub().returns(new TelemetryDeliveryTracker()) Exporter = proxyquire('../../../src/exporters/span-stats', { './writer': { Writer }, + '../../log': log, + '../../serverless': { createServerlessDeliveryTracker }, }).SpanStatsExporter }) @@ -41,13 +52,108 @@ describe('span-stats exporter', () => { sinon.assert.called(writer.flush) }) + it('waits for an in-flight export during flush', () => { + exporter = new Exporter({ url }) + let inFlightDone + writer.flush = sinon.stub() + writer.flush.onFirstCall().callsFake(done => { + writerOptions.deliveryTracker.track(callback => { inFlightDone = callback }, done) + }) + writer.flush.onSecondCall().callsFake(done => done?.()) + const done = sinon.spy() + + exporter.export('in flight') + exporter.flush(done) + + sinon.assert.notCalled(done) + inFlightDone() + sinon.assert.calledOnce(done) + }) + + it('waits for an encoder-triggered export during flush', () => { + exporter = new Exporter({ url }) + let automaticDone + writerOptions.deliveryTracker.track(done => { automaticDone = done }) + writer.flush = sinon.stub().callsFake(done => done?.()) + const done = sinon.spy() + + exporter.flush(done) + + sinon.assert.notCalled(done) + automaticDone() + sinon.assert.calledOnce(done) + }) + + it('waits for an encoder-triggered export during the flush boundary', () => { + exporter = new Exporter({ url }) + let automaticDone + writer.append = sinon.stub().callsFake(() => { + writerOptions.deliveryTracker.track(done => { automaticDone = done }) + }) + writer.flush = sinon.stub().callsFake(done => done?.()) + const done = sinon.spy() + + exporter.export('boundary export', done) + + sinon.assert.notCalled(done) + automaticDone() + sinon.assert.calledOnce(done) + }) + + it('does not retain a failed writer flush', () => { + writer.flush = sinon.stub() + writer.flush.onFirstCall().throws(new Error('encode failed')) + writer.flush.onSecondCall().callsFake(done => done?.()) + exporter = new Exporter({ url }) + const done = sinon.spy() + + assert.throws(() => exporter.export('failed export'), /encode failed/) + exporter.flush(done) + + sinon.assert.calledOnce(done) + }) + + it('waits for an in-flight export when the boundary flush fails', () => { + writer.flush = sinon.stub() + let inFlightDone + writer.flush.onFirstCall().callsFake(done => { + writerOptions.deliveryTracker.track(callback => { inFlightDone = callback }, done) + }) + writer.flush.onSecondCall().throws(new Error('encode failed')) + exporter = new Exporter({ url }) + const done = sinon.spy() + + exporter.export('in flight') + exporter.export('failed boundary', done) + + sinon.assert.notCalled(done) + inFlightDone() + sinon.assert.calledOnce(done) + sinon.assert.calledOnceWithExactly(log.error, 'Failed to flush span stats: %s', 'encode failed') + }) + + it('waits for an in-flight export when boundary append fails', () => { + let inFlightDone + exporter = new Exporter({ url }) + writerOptions.deliveryTracker.track(callback => { inFlightDone = callback }) + writer.append = sinon.stub().throws(new Error('encode failed')) + const done = sinon.spy() + + exporter.export('failed boundary', done) + + sinon.assert.notCalled(done) + inFlightDone() + sinon.assert.calledOnce(done) + sinon.assert.calledOnceWithExactly(log.error, 'Failed to flush span stats: %s', 'encode failed') + }) + it('should set url from config', () => { const url = new URL('http://0.0.0.0:1234') exporter = new Exporter({ url }) assert.strictEqual(exporter._url.toString(), url.toString()) - sinon.assert.calledWith(Writer, { + sinon.assert.calledWithMatch(Writer, { url: exporter._url, }) }) 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 23919126944..9f5d25e55ba 100644 --- a/packages/dd-trace/test/exporters/span-stats/writer.spec.js +++ b/packages/dd-trace/test/exporters/span-stats/writer.spec.js @@ -77,6 +77,17 @@ describe('span-stats writer', () => { writer.flush(done) }) + it('routes encoder-triggered flushes through the configured delivery tracker', () => { + const deliveryTracker = { track: sinon.stub().callsFake((flush, done) => flush(done)) } + writer = new Writer({ url, deliveryTracker }) + encoder.count.returns(1) + encoder.encode.callsFake(() => writer.flush()) + + writer.append([span]) + + sinon.assert.calledOnce(deliveryTracker.track) + }) + it('should flush to the agent, and call callback', (done) => { const expectedData = Buffer.from('prefixed') diff --git a/packages/dd-trace/test/llmobs/anthropic-utils.spec.js b/packages/dd-trace/test/llmobs/anthropic-utils.spec.js new file mode 100644 index 00000000000..8b84396dca5 --- /dev/null +++ b/packages/dd-trace/test/llmobs/anthropic-utils.spec.js @@ -0,0 +1,35 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { appendMessage } = require('../../src/llmobs/plugins/anthropic/util') + +describe('anthropic utils', () => { + it('formats supported tool-result blocks and skips unknown blocks', () => { + const messages = [] + + appendMessage(messages, { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'tool-1', + content: [ + { type: 'text', text: 'first' }, + { type: 'image' }, + { type: 'unknown' }, + { type: 'text', text: 'second' }, + ], + }], + }) + + assert.deepStrictEqual(messages, [{ + content: '', + role: 'user', + toolResults: [{ + result: 'first,([IMAGE DETECTED]),second', + toolId: 'tool-1', + type: 'tool_result', + }], + }]) + }) +}) diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_01a05521-e815-4d74-88e4-91d73d747b5f_records_filter_version__1_get_87446eff.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_01a05521-e815-4d74-88e4-91d73d747b5f_records_filter_version__1_get_87446eff.json new file mode 100644 index 00000000000..359717ee8aa --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_01a05521-e815-4d74-88e4-91d73d747b5f_records_filter_version__1_get_87446eff.json @@ -0,0 +1,31 @@ +{ + "request": { + "method": "GET", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets/01a05521-e815-4d74-88e4-91d73d747b5f/records?filter[version]=1", + "headers": { + "Connection": "keep-alive", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate" + }, + "body": "" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "900", + "date": "Fri, 31 Jul 2026 21:35:35 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"custom-b\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"canonical_id\":\"a4f472e3-90ce-42dd-9fe0-fe88dc503822\",\"created_at\":\"2026-07-31T21:35:30.142299Z\",\"dataset_id\":\"01a05521-e815-4d74-88e4-91d73d747b5f\",\"expected_output\":{\"value\":3},\"input\":{\"value\":2},\"metadata\":{\"source\":\"client-custom-records-test\"},\"ttl\":\"2029-07-30T21:35:30.152264Z\",\"updated_at\":\"2026-07-31T21:35:30.142299Z\"}},{\"id\":\"custom-a\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"canonical_id\":\"7e4ff9f4-188a-44b0-b2e7-f55cf340e0cb\",\"created_at\":\"2026-07-31T21:35:30.142299Z\",\"dataset_id\":\"01a05521-e815-4d74-88e4-91d73d747b5f\",\"expected_output\":{\"value\":2},\"input\":{\"value\":1},\"metadata\":{\"source\":\"client-custom-records-test\"},\"ttl\":\"2029-07-30T21:35:30.152254Z\",\"updated_at\":\"2026-07-31T21:35:30.142299Z\"}}],\"meta\":{\"after\":\"\"}}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_01a05521-e815-4d74-88e4-91d73d747b5f_records_post_9217d564.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_01a05521-e815-4d74-88e4-91d73d747b5f_records_post_9217d564.json new file mode 100644 index 00000000000..8468643d0b1 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_01a05521-e815-4d74-88e4-91d73d747b5f_records_post_9217d564.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets/01a05521-e815-4d74-88e4-91d73d747b5f/records", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "293" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"records\":[{\"id\":\"custom-a\",\"input\":{\"value\":1},\"expected_output\":{\"value\":2},\"metadata\":{\"source\":\"client-custom-records-test\"}},{\"id\":\"custom-b\",\"input\":{\"value\":2},\"expected_output\":{\"value\":3},\"metadata\":{\"source\":\"client-custom-records-test\"}}]}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "942", + "date": "Fri, 31 Jul 2026 21:35:30 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"custom-a\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"canonical_id\":\"7e4ff9f4-188a-44b0-b2e7-f55cf340e0cb\",\"created_at\":\"2026-07-31T21:35:30.142298637Z\",\"dataset_id\":\"01a05521-e815-4d74-88e4-91d73d747b5f\",\"expected_output\":{\"value\":2},\"input\":{\"value\":1},\"metadata\":{\"source\":\"client-custom-records-test\"},\"tags\":[],\"ttl\":\"2029-07-30T21:35:30.152253983Z\",\"updated_at\":\"2026-07-31T21:35:30.142298637Z\",\"version\":1}},{\"id\":\"custom-b\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"canonical_id\":\"a4f472e3-90ce-42dd-9fe0-fe88dc503822\",\"created_at\":\"2026-07-31T21:35:30.142298637Z\",\"dataset_id\":\"01a05521-e815-4d74-88e4-91d73d747b5f\",\"expected_output\":{\"value\":3},\"input\":{\"value\":2},\"metadata\":{\"source\":\"client-custom-records-test\"},\"tags\":[],\"ttl\":\"2029-07-30T21:35:30.152263591Z\",\"updated_at\":\"2026-07-31T21:35:30.142298637Z\",\"version\":1}}]}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_23fd65b0-d25f-45d7-b15f-cb75447cd6eb_records_post_23816346.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_23fd65b0-d25f-45d7-b15f-cb75447cd6eb_records_post_23816346.json new file mode 100644 index 00000000000..ad511dba45d --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_23fd65b0-d25f-45d7-b15f-cb75447cd6eb_records_post_23816346.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets/23fd65b0-d25f-45d7-b15f-cb75447cd6eb/records", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "143" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"records\":[{\"input\":{\"value\":1},\"expected_output\":{\"value\":2},\"metadata\":{\"source\":\"client-test\"}}]}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "489", + "date": "Fri, 31 Jul 2026 20:41:47 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"0f80c0b4-01a2-431b-8524-1beed38f22b0\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"canonical_id\":\"7693ba1a-36b4-4197-9978-779be9f37a23\",\"created_at\":\"2026-07-31T20:41:47.048820483Z\",\"dataset_id\":\"23fd65b0-d25f-45d7-b15f-cb75447cd6eb\",\"expected_output\":{\"value\":2},\"input\":{\"value\":1},\"metadata\":{\"source\":\"client-test\"},\"tags\":[],\"ttl\":\"2029-07-30T20:41:47.058642386Z\",\"updated_at\":\"2026-07-31T20:41:47.048820483Z\",\"version\":1}}]}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_53408af7-c8f3-4358-bdba-71b999ce1df8_records_filter_version__1_get_060955e7.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_53408af7-c8f3-4358-bdba-71b999ce1df8_records_filter_version__1_get_060955e7.json new file mode 100644 index 00000000000..8077751725c --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_53408af7-c8f3-4358-bdba-71b999ce1df8_records_filter_version__1_get_060955e7.json @@ -0,0 +1,31 @@ +{ + "request": { + "method": "GET", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets/53408af7-c8f3-4358-bdba-71b999ce1df8/records?filter[version]=1", + "headers": { + "Connection": "keep-alive", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate" + }, + "body": "" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "926", + "date": "Fri, 31 Jul 2026 20:41:46 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"78bedfd0-9515-4b21-92cf-60ccedf82174\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"canonical_id\":\"69eb8f30-9924-446c-ab56-865b31c19179\",\"created_at\":\"2026-07-31T20:41:41.333877Z\",\"dataset_id\":\"53408af7-c8f3-4358-bdba-71b999ce1df8\",\"expected_output\":{\"value\":2},\"input\":{\"value\":1},\"metadata\":{\"source\":\"client-test\"},\"ttl\":\"2029-07-30T20:41:41.342647Z\",\"updated_at\":\"2026-07-31T20:41:41.333877Z\"}},{\"id\":\"59c93cb2-df9f-4a5e-8294-153992593bb6\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"canonical_id\":\"79eebbe5-4f64-4b7f-8965-5f108c57ff57\",\"created_at\":\"2026-07-31T20:41:41.333877Z\",\"dataset_id\":\"53408af7-c8f3-4358-bdba-71b999ce1df8\",\"expected_output\":{\"value\":3},\"input\":{\"value\":2},\"metadata\":{\"source\":\"client-test\"},\"ttl\":\"2029-07-30T20:41:41.342655Z\",\"updated_at\":\"2026-07-31T20:41:41.333877Z\"}}],\"meta\":{\"after\":\"\"}}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_53408af7-c8f3-4358-bdba-71b999ce1df8_records_post_3a04924f.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_53408af7-c8f3-4358-bdba-71b999ce1df8_records_post_3a04924f.json new file mode 100644 index 00000000000..0e9ba5929e7 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_53408af7-c8f3-4358-bdba-71b999ce1df8_records_post_3a04924f.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets/53408af7-c8f3-4358-bdba-71b999ce1df8/records", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "231" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"records\":[{\"input\":{\"value\":1},\"expected_output\":{\"value\":2},\"metadata\":{\"source\":\"client-test\"}},{\"input\":{\"value\":2},\"expected_output\":{\"value\":3},\"metadata\":{\"source\":\"client-test\"}}]}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "968", + "date": "Fri, 31 Jul 2026 20:41:41 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"78bedfd0-9515-4b21-92cf-60ccedf82174\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"canonical_id\":\"69eb8f30-9924-446c-ab56-865b31c19179\",\"created_at\":\"2026-07-31T20:41:41.333876726Z\",\"dataset_id\":\"53408af7-c8f3-4358-bdba-71b999ce1df8\",\"expected_output\":{\"value\":2},\"input\":{\"value\":1},\"metadata\":{\"source\":\"client-test\"},\"tags\":[],\"ttl\":\"2029-07-30T20:41:41.342647153Z\",\"updated_at\":\"2026-07-31T20:41:41.333876726Z\",\"version\":1}},{\"id\":\"59c93cb2-df9f-4a5e-8294-153992593bb6\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"canonical_id\":\"79eebbe5-4f64-4b7f-8965-5f108c57ff57\",\"created_at\":\"2026-07-31T20:41:41.333876726Z\",\"dataset_id\":\"53408af7-c8f3-4358-bdba-71b999ce1df8\",\"expected_output\":{\"value\":3},\"input\":{\"value\":2},\"metadata\":{\"source\":\"client-test\"},\"tags\":[],\"ttl\":\"2029-07-30T20:41:41.342654767Z\",\"updated_at\":\"2026-07-31T20:41:41.333876726Z\",\"version\":1}}]}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_78a7925a-6924-4bd4-9b6e-0e2fcc61a747_records_post_a7e39814.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_78a7925a-6924-4bd4-9b6e-0e2fcc61a747_records_post_a7e39814.json new file mode 100644 index 00000000000..f047e7a9fb9 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_78a7925a-6924-4bd4-9b6e-0e2fcc61a747_records_post_a7e39814.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets/78a7925a-6924-4bd4-9b6e-0e2fcc61a747/records", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "193" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"records\":[{\"input\":{\"q\":\"apple\"},\"expected_output\":\"APPLE\",\"metadata\":{\"row\":0}},{\"input\":{\"q\":\"car\"},\"expected_output\":\"CAR\",\"metadata\":{\"row\":1}}]}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "930", + "date": "Fri, 31 Jul 2026 21:35:35 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"72c42c47-1949-4b6c-8d5f-dc89d1116b53\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"canonical_id\":\"51c31587-1a18-4223-a97d-3848243f5188\",\"created_at\":\"2026-07-31T21:35:35.677877925Z\",\"dataset_id\":\"78a7925a-6924-4bd4-9b6e-0e2fcc61a747\",\"expected_output\":\"APPLE\",\"input\":{\"q\":\"apple\"},\"metadata\":{\"row\":0},\"tags\":[],\"ttl\":\"2029-07-30T21:35:35.690708723Z\",\"updated_at\":\"2026-07-31T21:35:35.677877925Z\",\"version\":1}},{\"id\":\"8139a365-5aa4-41c0-aa39-7b13a49f5301\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"canonical_id\":\"5d7b6520-054b-4e28-9105-15698bed0789\",\"created_at\":\"2026-07-31T21:35:35.677877925Z\",\"dataset_id\":\"78a7925a-6924-4bd4-9b6e-0e2fcc61a747\",\"expected_output\":\"CAR\",\"input\":{\"q\":\"car\"},\"metadata\":{\"row\":1},\"tags\":[],\"ttl\":\"2029-07-30T21:35:35.690715599Z\",\"updated_at\":\"2026-07-31T21:35:35.677877925Z\",\"version\":1}}]}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_0072c1e0.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_0072c1e0.json new file mode 100644 index 00000000000..b3f11ea22f1 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_0072c1e0.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets/delete", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "112" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"type\":\"soft\",\"dataset_ids\":[\"23fd65b0-d25f-45d7-b15f-cb75447cd6eb\"]}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "499", + "date": "Fri, 31 Jul 2026 20:41:47 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"23fd65b0-d25f-45d7-b15f-cb75447cd6eb\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"created_at\":\"2026-07-31T20:41:46.874166Z\",\"current_version\":1,\"dataset_type\":\"user\",\"deleted_at\":\"2026-07-31T20:41:47.537936Z\",\"description\":\"created by a dd-trace-js experiments client VCR test\",\"name\":\"dd-trace-js-experiments-vcr-client-experiment-dataset\",\"project_id\":\"5645ffb8-c97e-4ce4-89f2-c41931318fd9\",\"updated_at\":\"2026-07-31T20:41:47.061183Z\"}}]}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_0a2ff917.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_0a2ff917.json new file mode 100644 index 00000000000..509e5a7ee50 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_0a2ff917.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets/delete", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "112" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"type\":\"soft\",\"dataset_ids\":[\"ee6dc95a-ca76-456d-991f-4449cd50b098\"]}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "484", + "date": "Fri, 31 Jul 2026 20:41:48 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"ee6dc95a-ca76-456d-991f-4449cd50b098\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"created_at\":\"2026-07-31T20:41:47.64414Z\",\"current_version\":1,\"dataset_type\":\"user\",\"deleted_at\":\"2026-07-31T20:41:48.180209Z\",\"description\":\"created by a dd-trace-js experiments VCR test\",\"name\":\"dd-trace-js-experiments-vcr-experiment-dataset\",\"project_id\":\"5645ffb8-c97e-4ce4-89f2-c41931318fd9\",\"updated_at\":\"2026-07-31T20:41:47.735487Z\"}}]}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_0a979404.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_0a979404.json new file mode 100644 index 00000000000..556ffb81d34 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_0a979404.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets/delete", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "112" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"type\":\"soft\",\"dataset_ids\":[\"53408af7-c8f3-4358-bdba-71b999ce1df8\"]}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "487", + "date": "Fri, 31 Jul 2026 20:41:46 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"53408af7-c8f3-4358-bdba-71b999ce1df8\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"created_at\":\"2026-07-31T20:41:41.139848Z\",\"current_version\":1,\"dataset_type\":\"user\",\"deleted_at\":\"2026-07-31T20:41:46.783282Z\",\"description\":\"created by a dd-trace-js experiments client VCR test\",\"name\":\"dd-trace-js-experiments-vcr-client-dataset\",\"project_id\":\"5645ffb8-c97e-4ce4-89f2-c41931318fd9\",\"updated_at\":\"2026-07-31T20:41:41.34567Z\"}}]}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_69fb24ab.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_69fb24ab.json new file mode 100644 index 00000000000..c463f4962c4 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_69fb24ab.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets/delete", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "112" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"type\":\"soft\",\"dataset_ids\":[\"01a05521-e815-4d74-88e4-91d73d747b5f\"]}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "511", + "date": "Fri, 31 Jul 2026 21:35:35 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"01a05521-e815-4d74-88e4-91d73d747b5f\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"created_at\":\"2026-07-31T21:35:30.026814Z\",\"current_version\":1,\"dataset_type\":\"user\",\"deleted_at\":\"2026-07-31T21:35:35.418141Z\",\"description\":\"created by a dd-trace-js experiments custom records VCR test\",\"name\":\"dd-trace-js-experiments-vcr-client-custom-records-dataset\",\"project_id\":\"5645ffb8-c97e-4ce4-89f2-c41931318fd9\",\"updated_at\":\"2026-07-31T21:35:30.156173Z\"}}]}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_ce1d83fa.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_ce1d83fa.json new file mode 100644 index 00000000000..d6773923bd3 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_delete_post_ce1d83fa.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets/delete", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "112" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"type\":\"soft\",\"dataset_ids\":[\"78a7925a-6924-4bd4-9b6e-0e2fcc61a747\"]}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "495", + "date": "Fri, 31 Jul 2026 21:35:36 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"78a7925a-6924-4bd4-9b6e-0e2fcc61a747\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"created_at\":\"2026-07-31T21:35:35.589615Z\",\"current_version\":1,\"dataset_type\":\"user\",\"deleted_at\":\"2026-07-31T21:35:36.178588Z\",\"description\":\"created by a dd-trace-js experiments rich VCR test\",\"name\":\"dd-trace-js-experiments-vcr-rich-experiment-dataset\",\"project_id\":\"5645ffb8-c97e-4ce4-89f2-c41931318fd9\",\"updated_at\":\"2026-07-31T21:35:35.695021Z\"}}]}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_ee6dc95a-ca76-456d-991f-4449cd50b098_records_post_e95d21f8.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_ee6dc95a-ca76-456d-991f-4449cd50b098_records_post_e95d21f8.json new file mode 100644 index 00000000000..045c1e622cb --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_ee6dc95a-ca76-456d-991f-4449cd50b098_records_post_e95d21f8.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets/ee6dc95a-ca76-456d-991f-4449cd50b098/records", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "144" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"records\":[{\"input\":{\"value\":1},\"expected_output\":{\"value\":2},\"metadata\":{\"source\":\"backend-test\"}}]}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "490", + "date": "Fri, 31 Jul 2026 20:41:47 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"f1a85430-c609-49e8-bb84-3f6bcc6e32cf\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"canonical_id\":\"c6846b83-5d71-4101-8d4c-a1593c2abe54\",\"created_at\":\"2026-07-31T20:41:47.721750573Z\",\"dataset_id\":\"ee6dc95a-ca76-456d-991f-4449cd50b098\",\"expected_output\":{\"value\":2},\"input\":{\"value\":1},\"metadata\":{\"source\":\"backend-test\"},\"tags\":[],\"ttl\":\"2029-07-30T20:41:47.730074255Z\",\"updated_at\":\"2026-07-31T20:41:47.721750573Z\",\"version\":1}}]}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_filter_name__dd-trace-js-experiments-vcr-client-dataset_get_77a6a7c8.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_filter_name__dd-trace-js-experiments-vcr-client-dataset_get_77a6a7c8.json new file mode 100644 index 00000000000..3403a9b38db --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_filter_name__dd-trace-js-experiments-vcr-client-dataset_get_77a6a7c8.json @@ -0,0 +1,31 @@ +{ + "request": { + "method": "GET", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets?filter[name]=dd-trace-js-experiments-vcr-client-dataset", + "headers": { + "Connection": "keep-alive", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate" + }, + "body": "" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "480", + "date": "Fri, 31 Jul 2026 20:41:46 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":[{\"id\":\"53408af7-c8f3-4358-bdba-71b999ce1df8\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"created_at\":\"2026-07-31T20:41:41.139848Z\",\"current_version\":1,\"dataset_type\":\"user\",\"description\":\"created by a dd-trace-js experiments client VCR test\",\"metadata\":null,\"name\":\"dd-trace-js-experiments-vcr-client-dataset\",\"project_id\":\"5645ffb8-c97e-4ce4-89f2-c41931318fd9\",\"updated_at\":\"2026-07-31T20:41:41.34567Z\"}}],\"meta\":{\"after\":\"\"}}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_10a9a03c.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_10a9a03c.json new file mode 100644 index 00000000000..40db9bda4d5 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_10a9a03c.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "187" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"name\":\"dd-trace-js-experiments-vcr-client-custom-records-dataset\",\"description\":\"created by a dd-trace-js experiments custom records VCR test\"}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "450", + "date": "Fri, 31 Jul 2026 21:35:30 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":{\"id\":\"01a05521-e815-4d74-88e4-91d73d747b5f\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"created_at\":\"2026-07-31T21:35:30.026814273Z\",\"current_version\":0,\"description\":\"created by a dd-trace-js experiments custom records VCR test\",\"name\":\"dd-trace-js-experiments-vcr-client-custom-records-dataset\",\"project_id\":\"5645ffb8-c97e-4ce4-89f2-c41931318fd9\",\"updated_at\":\"2026-07-31T21:35:30.026814273Z\"}}}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_89bda741.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_89bda741.json new file mode 100644 index 00000000000..133f740a926 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_89bda741.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "164" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"name\":\"dd-trace-js-experiments-vcr-client-dataset\",\"description\":\"created by a dd-trace-js experiments client VCR test\"}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "427", + "date": "Fri, 31 Jul 2026 20:41:41 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":{\"id\":\"53408af7-c8f3-4358-bdba-71b999ce1df8\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"created_at\":\"2026-07-31T20:41:41.139848691Z\",\"current_version\":0,\"description\":\"created by a dd-trace-js experiments client VCR test\",\"name\":\"dd-trace-js-experiments-vcr-client-dataset\",\"project_id\":\"5645ffb8-c97e-4ce4-89f2-c41931318fd9\",\"updated_at\":\"2026-07-31T20:41:41.139848691Z\"}}}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_bb7e5103.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_bb7e5103.json new file mode 100644 index 00000000000..179c6534286 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_bb7e5103.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "161" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"name\":\"dd-trace-js-experiments-vcr-experiment-dataset\",\"description\":\"created by a dd-trace-js experiments VCR test\"}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "424", + "date": "Fri, 31 Jul 2026 20:41:47 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":{\"id\":\"ee6dc95a-ca76-456d-991f-4449cd50b098\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"created_at\":\"2026-07-31T20:41:47.644140754Z\",\"current_version\":0,\"description\":\"created by a dd-trace-js experiments VCR test\",\"name\":\"dd-trace-js-experiments-vcr-experiment-dataset\",\"project_id\":\"5645ffb8-c97e-4ce4-89f2-c41931318fd9\",\"updated_at\":\"2026-07-31T20:41:47.644140754Z\"}}}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_c7de9de4.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_c7de9de4.json new file mode 100644 index 00000000000..f5d434e8db4 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_c7de9de4.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "175" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"name\":\"dd-trace-js-experiments-vcr-client-experiment-dataset\",\"description\":\"created by a dd-trace-js experiments client VCR test\"}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "438", + "date": "Fri, 31 Jul 2026 20:41:46 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":{\"id\":\"23fd65b0-d25f-45d7-b15f-cb75447cd6eb\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"created_at\":\"2026-07-31T20:41:46.874166342Z\",\"current_version\":0,\"description\":\"created by a dd-trace-js experiments client VCR test\",\"name\":\"dd-trace-js-experiments-vcr-client-experiment-dataset\",\"project_id\":\"5645ffb8-c97e-4ce4-89f2-c41931318fd9\",\"updated_at\":\"2026-07-31T20:41:46.874166342Z\"}}}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_d0652a5c.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_d0652a5c.json new file mode 100644 index 00000000000..6df7046c267 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_5645ffb8-c97e-4ce4-89f2-c41931318fd9_datasets_post_d0652a5c.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/5645ffb8-c97e-4ce4-89f2-c41931318fd9/datasets", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "171" + }, + "body": "{\"data\":{\"type\":\"datasets\",\"attributes\":{\"name\":\"dd-trace-js-experiments-vcr-rich-experiment-dataset\",\"description\":\"created by a dd-trace-js experiments rich VCR test\"}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "434", + "date": "Fri, 31 Jul 2026 21:35:35 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":{\"id\":\"78a7925a-6924-4bd4-9b6e-0e2fcc61a747\",\"type\":\"datasets\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"created_at\":\"2026-07-31T21:35:35.589615805Z\",\"current_version\":0,\"description\":\"created by a dd-trace-js experiments rich VCR test\",\"name\":\"dd-trace-js-experiments-vcr-rich-experiment-dataset\",\"project_id\":\"5645ffb8-c97e-4ce4-89f2-c41931318fd9\",\"updated_at\":\"2026-07-31T21:35:35.589615805Z\"}}}" + } +} diff --git a/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_projects_post_9c6b4ac6.json b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_projects_post_9c6b4ac6.json new file mode 100644 index 00000000000..f8f6ff58b17 --- /dev/null +++ b/packages/dd-trace/test/llmobs/cassettes/datadog-experiments/datadog-experiments_api_v2_llm-obs_v1_projects_post_9c6b4ac6.json @@ -0,0 +1,33 @@ +{ + "request": { + "method": "POST", + "url": "https://api.datadoghq.com/api/v2/llm-obs/v1/projects", + "headers": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "User-Agent": "node", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "80" + }, + "body": "{\"data\":{\"type\":\"projects\",\"attributes\":{\"name\":\"dd-trace-js-experiments-vcr\"}}}" + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": { + "content-type": "application/vnd.api+json", + "vary": "Accept-Encoding", + "x-frame-options": "SAMEORIGIN", + "content-length": "263", + "date": "Fri, 31 Jul 2026 20:41:41 GMT", + "x-content-type-options": "nosniff", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload" + }, + "body": "{\"data\":{\"id\":\"5645ffb8-c97e-4ce4-89f2-c41931318fd9\",\"type\":\"projects\",\"attributes\":{\"author\":{\"id\":\"cc589400-1367-11ed-aee5-da7ad0900002\"},\"created_at\":\"2026-07-31T19:29:12.35331Z\",\"name\":\"dd-trace-js-experiments-vcr\",\"updated_at\":\"2026-07-31T19:29:12.35331Z\"}}}" + } +} diff --git a/packages/dd-trace/test/llmobs/experiments/example.js b/packages/dd-trace/test/llmobs/experiments/example.js index 914848e7240..2393864b8d6 100644 --- a/packages/dd-trace/test/llmobs/experiments/example.js +++ b/packages/dd-trace/test/llmobs/experiments/example.js @@ -8,6 +8,7 @@ // It exercises everything added in this PR against a real Datadog org: // - create a dataset + add records, run an experiment with boolean / numeric / // categorical evaluators, and print the dataset + experiment URLs +// - a throwOnErrors fast-fail experiment that stops after the first evaluator error // - a dataset create -> push -> pull round-trip // // Run: @@ -15,6 +16,7 @@ // node packages/dd-trace/test/llmobs/experiments/example.js const tracer = require('../../../../..') +const experimentsProjectName = 'node-tracer-experiments-demo' function requireEnv (name) { const value = process.env[name] @@ -38,7 +40,10 @@ function keywordOverlap (prompt, topics) { async function runExperiment (experiments) { console.log('\n=== Experiment: topic relevance ===') - const dataset = experiments.createDataset('node-tracer-topic-relevance', 'demo dataset') + const dataset = experiments.createDataset('node-tracer-topic-relevance', { + description: 'demo dataset', + projectName: experimentsProjectName, + }) .addRecord({ prompt: 'I love hiking in the mountains on weekends.', topics: 'outdoor, travel' }, 'true', { source: 'synthetic', difficulty: 'easy' }, ['split:train']) .addRecord({ prompt: 'Explain quantum entanglement in two sentences.', topics: 'outdoor, travel' }, 'false', @@ -48,6 +53,7 @@ async function runExperiment (experiments) { const result = await experiments.experiment({ name: 'topic-relevance-demo', + projectName: experimentsProjectName, dataset, task: (input) => { const overlap = keywordOverlap(input.prompt, input.topics) @@ -71,10 +77,58 @@ async function runExperiment (experiments) { } } +async function runThrowOnErrorsFastFail (experiments) { + console.log('\n=== Experiment: throwOnErrors fast fail ===') + const dataset = experiments.createDataset(`node-tracer-fast-fail-${Date.now()}`, { + description: 'manual end-to-end fast-fail demo', + projectName: experimentsProjectName, + }) + .addRecord('first') + .addRecord('later-1') + .addRecord('later-2') + + let taskCalls = 0 + let evaluatorCalls = 0 + const experiment = experiments.experiment({ + name: `throw-on-errors-fast-fail-${Date.now()}`, + projectName: experimentsProjectName, + dataset, + task: (input) => { + taskCalls++ + return input + }, + evaluators: { + failing: (input) => { + evaluatorCalls++ + if (input === 'first') throw new Error('intentional evaluator failure') + return true + }, + }, + }) + + try { + await experiment.run({ concurrency: 1, throwOnErrors: true }) + throw new Error('Expected the experiment to fail') + } catch (err) { + if (!/intentional evaluator failure/.test(err.message)) throw err + } + + if (taskCalls !== 1 || evaluatorCalls !== 1) { + throw new Error(`Expected fast fail after one row; taskCalls=${taskCalls}, evaluatorCalls=${evaluatorCalls}`) + } + + console.log(`Experiment URL : ${experiment.url()}`) + console.log(`Experiment ID : ${experiment.experimentId()}`) + console.log(`Fast fail verified after taskCalls=${taskCalls}, evaluatorCalls=${evaluatorCalls}`) +} + async function runDatasetOps (experiments) { console.log('\n=== Dataset operations: create / push / pull ===') const name = `node-tracer-capitals-${Date.now()}` - const dataset = experiments.createDataset(name, 'country -> capital') + const dataset = experiments.createDataset(name, { + description: 'country -> capital', + projectName: experimentsProjectName, + }) .addRecord({ country: 'France' }, 'Paris', { continent: 'Europe' }, ['continent:europe']) .addRecord({ country: 'Japan' }, 'Tokyo', { continent: 'Asia' }, ['continent:asia']) await dataset.push() @@ -86,7 +140,11 @@ async function runDatasetOps (experiments) { dataset.replaceTags(1, ['continent:asia', 'split:train']) await dataset.push() - const pulled = await experiments.pullDataset(name, { expectedRecordCount: 1, tags: ['split:eval'] }) + const pulled = await experiments.pullDataset(name, { + projectName: experimentsProjectName, + expectedRecordCount: 1, + tags: ['split:eval'], + }) console.log(`Pulled dataset id : ${pulled.id()}`) console.log(`Pulled records : ${pulled.records().length}`) for (const [i, record] of pulled.records().entries()) { @@ -100,11 +158,15 @@ async function main () { requireEnv('DD_APP_KEY') tracer.init({ - llmobs: { mlApp: 'node-tracer-experiments-demo' }, + llmobs: { + mlApp: 'node-tracer-experiments-demo', + projectName: experimentsProjectName, + }, }) const { experiments } = tracer.llmobs await runExperiment(experiments) + await runThrowOnErrorsFastFail(experiments) await runDatasetOps(experiments) } diff --git a/packages/dd-trace/test/llmobs/experiments/experiment.spec.js b/packages/dd-trace/test/llmobs/experiments/experiment.spec.js index 6c9063855d7..be7c3abf67a 100644 --- a/packages/dd-trace/test/llmobs/experiments/experiment.spec.js +++ b/packages/dd-trace/test/llmobs/experiments/experiment.spec.js @@ -51,6 +51,14 @@ function clientWithMockBackend ({ createDatasetError } = {}) { return { client: c, requests } } +async function waitFor (condition) { + for (let i = 0; i < 20; i++) { + if (condition()) return + await Promise.resolve() + } + assert.equal(condition(), true) +} + describe('LLMObs Experiments — dataset + experiment run', () => { it('runs task inside an LLMObs experiment span', async () => { const { client: c, requests } = clientWithMockBackend() @@ -58,7 +66,7 @@ describe('LLMObs Experiments — dataset + experiment run', () => { { q: 'apple' }, 'apple', { row: 0 }, - ['topic:math', 'topic:logic'] + ['topic:math', 'topic:logic', 'project_name:record-project'] ) const callsToLlmobs = [] const llmobs = { @@ -74,6 +82,7 @@ describe('LLMObs Experiments — dataset + experiment run', () => { const result = await new Experiment(c, { name: 'exp-demo', + projectName: 'demo-project', dataset, task: (input) => input.q, evaluators: { ok: () => true }, @@ -85,7 +94,9 @@ describe('LLMObs Experiments — dataset + experiment run', () => { assert.equal(callsToLlmobs[0][1].name, 'task') assert.equal(callsToLlmobs[1][1].tags.experiment_id, 'exp') assert.equal(callsToLlmobs[1][1].tags.dataset_record_id, dataset.records()[0].id) + assert.equal(callsToLlmobs[1][1].tags.project_name, 'demo-project') assert.deepEqual(callsToLlmobs[1][1].tags.topic, ['math', 'logic']) + assert.equal(callsToLlmobs[1][1].tags.run_iteration, 1) assert.equal(result.rows[0].spanId, '000000000000abcd') assert.equal(result.rows[0].traceId, '0000000000000000000000000000abcd') assert.deepEqual(requests.find(request => request.method === 'createExperiment').attributes.config, { @@ -141,6 +152,29 @@ describe('LLMObs Experiments — dataset + experiment run', () => { assert.deepEqual(spans[0].tags.filter(tag => tag.startsWith('experiment_name:')), ['experiment_name:exp-demo']) }) + it('keeps the project name authoritative in external metric tags', async () => { + const { client: c, requests } = clientWithMockBackend() + const experiment = await new Experiment(c, { + name: 'external-exp', + projectName: 'demo-project', + external: true, + }).start() + + const span = await experiment.submitSpan({ input: 'input' }) + await experiment.submitEvaluationMetrics(span, [{ + label: 'score', + value: 1, + tags: { project_name: 'other-project' }, + }]) + + const metricRequest = requests.find(request => request.method === 'postExperimentEvents' && + request.attributes.metrics.length > 0) + assert.deepEqual( + metricRequest.attributes.metrics[0].tags.filter(tag => tag.startsWith('project_name:')), + ['project_name:demo-project'] + ) + }) + it('surfaces backend failures', async () => { const createDatasetError = new Error(`POST ${API_BASE_PATH}/proj/datasets failed: HTTP 500 boom`) const { client: c } = clientWithMockBackend({ createDatasetError }) @@ -456,6 +490,120 @@ describe('LLMObs Experiments — dataset + experiment run', () => { }]) }) + it('preserves in-place mutations to an inserted record while its push is in flight', async () => { + const { client: c, requests } = clientWithMockBackend() + let resolvePush + let blockPush = true + c.batchUpdateDatasetRecords = async (projectId, datasetId, attributes) => { + requests.push({ method: 'batchUpdateDatasetRecords', projectId, datasetId, attributes }) + if (blockPush) { + blockPush = false + await new Promise(resolve => { resolvePush = resolve }) + } + return { records: [], version: 2 } + } + const dataset = new Dataset(c, 'demo').addRecord({ values: ['before'] }) + const push = dataset.push() + await new Promise(resolve => setImmediate(resolve)) + dataset.records()[0].input.values.push('during') + resolvePush() + await push + + const batchRequests = requests.filter(request => request.method === 'batchUpdateDatasetRecords') + assert.deepEqual(batchRequests[0].attributes.insert_records, [{ + id: dataset.recordIds()[0], + input: { values: ['before'] }, + expected_output: null, + metadata: {}, + }]) + + await dataset.push() + const updatedBatchRequests = requests.filter(request => request.method === 'batchUpdateDatasetRecords') + assert.deepEqual(updatedBatchRequests[1].attributes.update_records, [{ + id: dataset.recordIds()[0], + input: { values: ['before', 'during'] }, + }]) + }) + + it('preserves in-place mutations to an updated record while its push is in flight', async () => { + const { client: c, requests } = clientWithMockBackend() + let resolvePush + let blockPush = true + c.batchUpdateDatasetRecords = async (projectId, datasetId, attributes) => { + requests.push({ method: 'batchUpdateDatasetRecords', projectId, datasetId, attributes }) + if (blockPush) { + blockPush = false + await new Promise(resolve => { resolvePush = resolve }) + } + return { records: [], version: 2 } + } + const metadata = { values: ['before'] } + const dataset = Dataset.fromExisting( + c, + 'demo', + '', + 'ds', + 'proj', + [new DatasetRecord('input', null, metadata, 'record-0')], + 1, + 1 + ) + dataset.update(0, { metadata }) + const push = dataset.push() + await new Promise(resolve => setImmediate(resolve)) + dataset.records()[0].metadata.values.push('during') + resolvePush() + await push + + const batchRequests = requests.filter(request => request.method === 'batchUpdateDatasetRecords') + assert.deepEqual(batchRequests[0].attributes.update_records, [{ + id: 'record-0', + metadata: { values: ['before'] }, + }]) + + await dataset.push() + const updatedBatchRequests = requests.filter(request => request.method === 'batchUpdateDatasetRecords') + assert.deepEqual(updatedBatchRequests[1].attributes.update_records, [{ + id: 'record-0', + metadata: { values: ['before', 'during'] }, + }]) + }) + + it('preserves direct tag mutations made while an insert is in flight', async () => { + const { client: c, requests } = clientWithMockBackend() + let resolvePush + let blockPush = true + c.batchUpdateDatasetRecords = async (projectId, datasetId, attributes) => { + requests.push({ method: 'batchUpdateDatasetRecords', projectId, datasetId, attributes }) + if (blockPush) { + blockPush = false + await new Promise(resolve => { resolvePush = resolve }) + } + return { records: [], version: 2 } + } + const dataset = new Dataset(c, 'demo').addRecord('input') + const push = dataset.push() + await new Promise(resolve => setImmediate(resolve)) + dataset.records()[0].tags.push('topic:new') + resolvePush() + await push + + const batchRequests = requests.filter(request => request.method === 'batchUpdateDatasetRecords') + assert.deepEqual(batchRequests[0].attributes.insert_records, [{ + id: dataset.recordIds()[0], + input: 'input', + expected_output: null, + metadata: {}, + }]) + + await dataset.push() + const updatedBatchRequests = requests.filter(request => request.method === 'batchUpdateDatasetRecords') + assert.deepEqual(updatedBatchRequests[1].attributes.update_records, [{ + id: dataset.recordIds()[0], + tag_operations: { set: ['topic:new'] }, + }]) + }) + it('keeps deletes made while an insert is in flight', async () => { const { client: c, requests } = clientWithMockBackend() let resolvePush @@ -644,6 +792,257 @@ describe('LLMObs Experiments — dataset + experiment run', () => { assert.deepEqual(summaryOutputs, ['good', 'eval-bad', null]) assert.deepEqual(summaryEvaluatorResults.exactMatch, [true, null, null]) assert.equal(result.summaryEvaluations.passRate.value, 1 / 3) + assert.equal(result.runs[0].hasError, true) + }) + + it('marks an empty summary evaluator error as a run error', async () => { + const { client: c, requests } = clientWithMockBackend() + const result = await new Experiment(c, { + name: 'exp-demo', + dataset: new Dataset(c, 'demo').addRecord('good'), + task: input => input, + summaryEvaluators: { + emptyError: () => { throw new Error() }, + }, + }).run() + + assert.deepEqual(result.summaryEvaluations.emptyError, { value: null, error: '' }) + assert.equal(result.runs[0].hasError, true) + assert.deepEqual( + requests.find(request => request.method === 'updateExperiment').attributes, + { status: 'failed', error: 'one or more rows failed' } + ) + const metrics = requests.find(request => request.method === 'postExperimentEvents').attributes.metrics + assert.deepEqual(metrics.find(metric => metric.label === 'emptyError').error, { message: '' }) + }) + + it('runs multiple iterations and aliases top-level results to the first run', async () => { + const { client: c, requests } = clientWithMockBackend() + const dataset = new Dataset(c, 'demo') + .addRecord('a', 'a') + .addRecord('b', 'b') + let taskCalls = 0 + let summaryCalls = 0 + + const result = await new Experiment(c, { + name: 'exp-demo', + dataset, + runs: 2, + task: (input) => { + taskCalls++ + return input + }, + evaluators: { exactMatch: (_input, output, expectedOutput) => output === expectedOutput }, + summaryEvaluators: { + rowCount: (inputs) => { + summaryCalls++ + return inputs.length + }, + }, + tags: { suite: 'multi-run' }, + }).run() + + const createExperiment = requests.find(request => request.method === 'createExperiment') + assert.equal(createExperiment.attributes.run_count, 2) + assert.equal(taskCalls, 4) + assert.equal(summaryCalls, 2) + assert.equal(result.runs.length, 2) + assert.equal(result.rows, result.runs[0].rows) + assert.equal(result.summaryEvaluations, result.runs[0].summaryEvaluations) + assert.deepEqual(result.runs.map(run => run.runIteration), [1, 2]) + assert.deepEqual(result.runs.map(run => run.hasError), [false, false]) + assert.notEqual(result.runs[0].runId, result.runs[1].runId) + assert.deepEqual(result.runs.map(run => run.rows.map(row => row.output)), [['a', 'b'], ['a', 'b']]) + assert.deepEqual(result.runs.map(run => run.summaryEvaluations.rowCount.value), [2, 2]) + + const events = requests.filter(request => request.method === 'postExperimentEvents') + assert.equal(events.length, 2) + assert.equal(events[0].attributes.spans.length, 2) + assert.equal(events[0].attributes.metrics.length, 3) + assert.equal(events[1].attributes.spans.length, 2) + assert.equal(events[1].attributes.metrics.length, 3) + assert.equal(events[0].attributes.spans[0].tags.includes('run_iteration:1'), true) + assert.equal(events[1].attributes.spans[0].tags.includes('run_iteration:2'), true) + assert.equal(events[0].attributes.metrics[0].tags.includes('run_iteration:1'), true) + assert.equal(events[1].attributes.metrics[0].tags.includes('run_iteration:2'), true) + assert.equal(events[0].attributes.metrics[0].tags.includes(`run_id:${result.runs[0].runId}`), true) + assert.equal(events[1].attributes.metrics[0].tags.includes(`run_id:${result.runs[1].runId}`), true) + }) + + it('uploads each run before starting the next iteration', async () => { + const { client: c, requests } = clientWithMockBackend() + let releaseFirstUpload + const firstUpload = new Promise(resolve => { releaseFirstUpload = resolve }) + let uploadCalls = 0 + let taskCalls = 0 + c.postExperimentEvents = async (experimentId, attributes) => { + requests.push({ method: 'postExperimentEvents', experimentId, attributes }) + uploadCalls++ + if (uploadCalls === 1) await firstUpload + } + + const pendingResult = new Experiment(c, { + name: 'exp-demo', + dataset: new Dataset(c, 'demo').addRecord('a').addRecord('b'), + runs: 2, + task: input => { + taskCalls++ + return input + }, + }).run() + + await waitFor(() => uploadCalls === 1) + assert.equal(taskCalls, 2) + assert.equal(uploadCalls, 1) + + releaseFirstUpload() + const result = await pendingResult + assert.equal(result.runs.length, 2) + assert.equal(uploadCalls, 2) + }) + + it('records errors on the individual run that failed', async () => { + const { client: c, requests } = clientWithMockBackend() + let taskCalls = 0 + const result = await new Experiment(c, { + name: 'exp-demo', + dataset: new Dataset(c, 'demo').addRecord('a'), + runs: 2, + task: input => { + taskCalls++ + if (taskCalls === 2) throw new Error('second run failed') + return input + }, + }).run() + + assert.deepEqual(result.runs.map(run => run.hasError), [false, true]) + assert.equal(requests.filter(request => request.method === 'postExperimentEvents').length, 2) + assert.deepEqual( + requests.find(request => request.method === 'updateExperiment').attributes, + { status: 'failed', error: 'one or more rows failed' } + ) + }) + + it('processes records concurrently while preserving row order', async () => { + const { client: c } = clientWithMockBackend() + const dataset = new Dataset(c, 'demo') + .addRecord('a') + .addRecord('b') + .addRecord('c') + .addRecord('d') + const releases = [] + let active = 0 + let maxActive = 0 + + const pendingResult = new Experiment(c, { + name: 'exp-demo', + dataset, + task: async (input) => { + active++ + maxActive = Math.max(maxActive, active) + await new Promise(resolve => releases.push(resolve)) + active-- + return `out-${input}` + }, + }).run({ concurrency: 2 }) + + await waitFor(() => releases.length === 2) + assert.equal(maxActive, 2) + releases[0]() + await waitFor(() => releases.length === 3) + assert.equal(maxActive, 2) + releases[1]() + releases[2]() + await waitFor(() => releases.length === 4) + releases[3]() + + const result = await pendingResult + assert.equal(maxActive, 2) + assert.equal(active, 0) + assert.deepEqual(result.rows.map(row => row.output), ['out-a', 'out-b', 'out-c', 'out-d']) + assert.deepEqual(result.rows.map(row => row.index), [0, 1, 2, 3]) + }) + + it('processes evaluators concurrently while preserving labels', async () => { + const { client: c } = clientWithMockBackend() + const dataset = new Dataset(c, 'demo').addRecord('a') + const releases = [] + let active = 0 + let maxActive = 0 + + function evaluator (value) { + return async () => { + active++ + maxActive = Math.max(maxActive, active) + await new Promise(resolve => releases.push(resolve)) + active-- + return value + } + } + + const pendingResult = new Experiment(c, { + name: 'exp-demo', + dataset, + task: (input) => `out-${input}`, + evaluators: { + first: evaluator('first'), + second: evaluator('second'), + third: evaluator('third'), + }, + }).run({ concurrency: 2 }) + + await waitFor(() => releases.length === 2) + assert.equal(maxActive, 2) + releases[0]() + await waitFor(() => releases.length === 3) + assert.equal(maxActive, 2) + releases[1]() + releases[2]() + + const result = await pendingResult + assert.equal(maxActive, 2) + assert.equal(active, 0) + assert.deepEqual(result.rows[0].evaluations, { + first: 'first', + second: 'second', + third: 'third', + }) + }) + + it('defaults concurrency to ten task or evaluator executions', async () => { + const { client: c } = clientWithMockBackend() + const dataset = new Dataset(c, 'demo').addRecord('a') + const evaluators = {} + const releases = [] + let active = 0 + let maxActive = 0 + + for (let i = 0; i < 11; i++) { + evaluators[`eval${i}`] = async () => { + active++ + maxActive = Math.max(maxActive, active) + await new Promise(resolve => releases.push(resolve)) + active-- + return i + } + } + + const pendingResult = new Experiment(c, { + name: 'exp-demo', + dataset, + task: (input) => `out-${input}`, + evaluators, + }).run() + + await waitFor(() => releases.length === 10) + assert.equal(maxActive, 10) + releases[0]() + await waitFor(() => releases.length === 11) + for (let i = 1; i < releases.length; i++) releases[i]() + + const result = await pendingResult + assert.equal(maxActive, 10) + assert.equal(Object.keys(result.rows[0].evaluations).length, 11) }) it('throws task and evaluator errors when throwOnErrors is true', async () => { @@ -669,6 +1068,117 @@ describe('LLMObs Experiments — dataset + experiment run', () => { ) }) + it('does not start queued task work after a throw-on-error failure', async () => { + const { client: c } = clientWithMockBackend() + const inputs = ['bad', 'later-1', 'later-2'] + let taskCalls = 0 + + await assert.rejects( + () => new Experiment(c, { + name: 'exp-demo', + dataset: new Dataset(c, 'demo').addRecord(inputs[0]).addRecord(inputs[1]).addRecord(inputs[2]), + task: (input) => { + taskCalls++ + if (input === 'bad') throw new Error('task-fail') + return input + }, + }).run({ concurrency: 1, throwOnErrors: true }), + /task-fail/ + ) + assert.equal(taskCalls, 1) + }) + + it('does not start queued evaluator work after a throw-on-error failure', async () => { + const { client: c } = clientWithMockBackend() + const evaluatorInputs = [] + const taskInputs = [] + + await assert.rejects( + () => new Experiment(c, { + name: 'exp-demo', + dataset: new Dataset(c, 'demo').addRecord('bad').addRecord('later'), + task: input => { + taskInputs.push(input) + return input + }, + evaluators: { + failing: (input) => { + evaluatorInputs.push(input) + if (input === 'bad') throw new Error('eval-fail') + return true + }, + }, + }).run({ concurrency: 1, throwOnErrors: true }), + /eval-fail/ + ) + assert.deepEqual(taskInputs, ['bad']) + assert.deepEqual(evaluatorInputs, ['bad']) + }) + + it('rejects before waiting for other active records after a throw-on-error failure', async () => { + const { client: c } = clientWithMockBackend() + const taskInputs = [] + let releaseBad + let releaseSlow + let resolveBadStarted + let resolveSlowStarted + let rejection + const badStarted = new Promise(resolve => { resolveBadStarted = resolve }) + const slowStarted = new Promise(resolve => { resolveSlowStarted = resolve }) + const badGate = new Promise(resolve => { releaseBad = resolve }) + const slowGate = new Promise(resolve => { releaseSlow = resolve }) + const pendingResult = new Experiment(c, { + name: 'exp-demo', + dataset: new Dataset(c, 'demo').addRecord('bad').addRecord('slow').addRecord('later'), + task: async (input) => { + taskInputs.push(input) + if (input === 'bad') { + resolveBadStarted() + await badGate + throw new Error('task-fail') + } + if (input === 'slow') { + resolveSlowStarted() + await slowGate + } + return input + }, + }).run({ concurrency: 2, throwOnErrors: true }).then( + () => { rejection = new Error('experiment unexpectedly resolved') }, + error => { rejection = error } + ) + + try { + await Promise.all([badStarted, slowStarted]) + releaseBad() + await waitFor(() => rejection !== undefined) + assert.match(rejection.message, /task-fail/) + assert.deepEqual(taskInputs, ['bad', 'slow']) + } finally { + releaseBad() + releaseSlow() + await pendingResult + } + }) + + it('continues processing task errors when throwOnErrors is false', async () => { + const { client: c } = clientWithMockBackend() + const taskInputs = [] + const result = await new Experiment(c, { + name: 'exp-demo', + dataset: new Dataset(c, 'demo').addRecord('bad').addRecord('later'), + task: (input) => { + taskInputs.push(input) + if (input === 'bad') throw new Error('task-fail') + return input + }, + }).run({ concurrency: 1 }) + + assert.deepEqual(taskInputs, ['bad', 'later']) + assert.equal(result.rows[0].isError, true) + assert.equal(result.rows[1].isError, false) + }) + it('normalizes fallback JSON evaluator metrics', async () => { const { client: c, requests } = clientWithMockBackend() const dataset = new Dataset(c, 'demo').addRecord('x') @@ -720,6 +1230,29 @@ describe('LLMObs Experiments — dataset + experiment run', () => { () => new Experiment(c, { name: 'n', dataset, task: (input) => input, summaryEvaluators: [true] }), /summary evaluator must be a function/ ) + assert.throws( + () => new Experiment(c, { name: 'n', dataset, task: (input) => input, runs: 0 }), + /runs must be a positive integer/ + ) + assert.throws( + () => new Experiment(c, { name: 'n', dataset, task: (input) => input, runs: 1.5 }), + /runs must be a positive integer/ + ) + }) + + it('validates run options', async () => { + const c = client() + const dataset = new Dataset(c, 'demo').addRecord('a') + const experiment = new Experiment(c, { name: 'n', dataset, task: (input) => input }) + + await assert.rejects( + () => experiment.run({ concurrency: 0 }), + /concurrency must be a positive integer/ + ) + await assert.rejects( + () => experiment.run({ concurrency: 1.5 }), + /concurrency must be a positive integer/ + ) }) it('preserves records from an existing dataset and rejects duplicates or missing ids', () => { @@ -765,4 +1298,76 @@ describe('LLMObs Experiments — dataset + experiment run', () => { assert.equal(dataset.records()[1].expectedOutput, 'expected') assert.deepEqual(dataset.records()[1].metadata, { explicit: true }) }) + + it('adds multiple records with custom and generated ids', async () => { + const { client: c, requests } = clientWithMockBackend() + const dataset = new Dataset(c, 'demo') + const returned = dataset.addRecords([ + { + id: 'custom-record', + inputData: 'first', + expectedOutput: 'one', + metadata: { row: 0 }, + tags: ['tag:first'], + }, + { inputData: { value: 2 } }, + ]) + + assert.equal(returned, dataset) + const records = dataset.records() + assert.equal(records.length, 2) + assert.equal(records[0].id, 'custom-record') + assert.ok(records[1].id) + + await dataset.push() + + const attributes = requests.find(request => request.method === 'batchUpdateDatasetRecords').attributes + assert.deepEqual(attributes.insert_records, [ + { + id: 'custom-record', + input: 'first', + expected_output: 'one', + metadata: { row: 0 }, + tags: ['tag:first'], + }, + { + id: records[1].id, + input: { value: 2 }, + expected_output: null, + metadata: {}, + }, + ]) + }) + + it('validates the entire addRecords batch before mutating the dataset', () => { + const dataset = new Dataset(client(), 'demo') + .addRecord(new DatasetRecord('existing', null, {}, 'existing')) + + assert.throws( + () => dataset.addRecords([ + { id: 'new', inputData: 'first' }, + { id: 'existing', inputData: 'duplicate' }, + ]), + /Duplicate record id 'existing'/ + ) + assert.deepEqual(dataset.recordIds(), ['existing']) + + assert.throws( + () => dataset.addRecords([ + { id: 'same', inputData: 'first' }, + { id: 'same', inputData: 'duplicate' }, + ]), + /Duplicate record id 'same'/ + ) + assert.deepEqual(dataset.recordIds(), ['existing']) + + assert.throws( + () => dataset.addRecords([ + { id: 'new', inputData: 'first' }, + { id: 'bad', inputData: 'invalid', tags: ['malformed'] }, + ]), + /Tag 'malformed' is malformed/ + ) + assert.deepEqual(dataset.recordIds(), ['existing']) + }) }) diff --git a/packages/dd-trace/test/llmobs/experiments/index.spec.js b/packages/dd-trace/test/llmobs/experiments/index.spec.js index ffdb848fc49..3b9386ee185 100644 --- a/packages/dd-trace/test/llmobs/experiments/index.spec.js +++ b/packages/dd-trace/test/llmobs/experiments/index.spec.js @@ -11,12 +11,20 @@ const { ExperimentsClient } = require('../../../src/llmobs/experiments/client') const NoopExperiments = require('../../../src/llmobs/experiments/noop') const EXPERIMENTS_VCR_API_BASE = 'http://127.0.0.1:9126/vcr/datadog-experiments' +const VCR_PROJECT_NAME = process.env.DD_LLMOBS_EXPERIMENTS_PROJECT_NAME ?? + `dd-trace-js-experiments-${process.env.DD_LLMOBS_EXPERIMENTS_TEST_ID ?? 'vcr-facade'}` class VcrExperimentsClient extends ExperimentsClient { constructor (options) { super(options) this.apiBase = EXPERIMENTS_VCR_API_BASE } + + ensureProjectId () { + // Keep VCR data isolated while the logical SDK project remains default-project. + const projectName = this.projectName === 'default-project' ? VCR_PROJECT_NAME : this.projectName + return this.getOrCreateProject(projectName) + } } const { createExperiments: createVcrExperiments } = proxyquire('../../../src/llmobs/experiments', { @@ -46,9 +54,7 @@ describe('LLMObs Experiments facade', () => { sinon.restore() }) - const backendTestId = process.env.DD_LLMOBS_EXPERIMENTS_TEST_ID ?? 'vcr-facade' - const backendProjectName = process.env.DD_LLMOBS_EXPERIMENTS_PROJECT_NAME ?? - `dd-trace-js-experiments-${backendTestId}` + const backendProjectName = VCR_PROJECT_NAME const backendExperimentDatasetName = `${backendProjectName}-experiment-dataset` const backendExperimentName = `${backendProjectName}-experiment` const backendRichExperimentDatasetName = `${backendProjectName}-rich-experiment-dataset` @@ -77,7 +83,6 @@ describe('LLMObs Experiments facade', () => { DD_APP_KEY: options.appKey, llmobs: { DD_LLMOBS_ENABLED: true, - mlApp: options.projectName, }, })) } @@ -135,15 +140,86 @@ describe('LLMObs Experiments facade', () => { records: [{ inputData: 'in', expectedOutput: 'out', metadata: { source: 'test' } }], }) assert.equal(typeof dataset.addRecord, 'function') + assert.equal(typeof dataset.addRecords, 'function') assert.equal(dataset.records()[0].input, 'in') const experiment = exp.experiment({ name: 'n', dataset, task: (i) => i }) assert.equal(typeof experiment.run, 'function') }) - it('returns a working facade when service is used as the project name fallback', () => { - const exp = createExperiments(enabledConfig({ service: 'my-service', llmobs: { DD_LLMOBS_ENABLED: true } })) + it('uses the configured project name and supports per-operation overrides', () => { + const constructedProjects = [] + class CapturingExperimentsClient extends ExperimentsClient { + constructor (options) { + super(options) + constructedProjects.push(options.projectName) + } + } + const { createExperiments: createWithProjectCapture } = proxyquire('../../../src/llmobs/experiments', { + './client': { ExperimentsClient: CapturingExperimentsClient }, + }) + + const exp = createWithProjectCapture(enabledConfig({ + llmobs: { DD_LLMOBS_ENABLED: true, mlApp: 'ml-app', projectName: 'configured-project' }, + })) + exp.createDataset('default') + exp.createDataset('override', { projectName: 'override-project' }) + + assert.deepEqual(constructedProjects, ['configured-project', 'override-project']) + }) + + it('preserves a dataset project and rejects mismatched experiment overrides', () => { + const constructedProjects = [] + class CapturingExperimentsClient extends ExperimentsClient { + constructor (options) { + super(options) + constructedProjects.push(options.projectName) + } + } + const { createExperiments: createWithProjectCapture } = proxyquire('../../../src/llmobs/experiments', { + './client': { ExperimentsClient: CapturingExperimentsClient }, + }) + + const exp = createWithProjectCapture(enabledConfig({ + llmobs: { DD_LLMOBS_ENABLED: true, projectName: 'default-project' }, + })) + const dataset = exp.createDataset('dataset', { projectName: 'dataset-project' }) + exp.experiment({ name: 'dataset-exp', dataset, task: input => input }) + + assert.deepEqual(constructedProjects, ['default-project', 'dataset-project', 'dataset-project']) + assert.throws( + () => exp.experiment({ + name: 'mismatched-exp', + projectName: 'other-project', + dataset, + task: input => input, + }), + /does not match dataset project 'dataset-project'/ + ) + }) + + it('uses default-project when no project name is configured', () => { + const exp = createExperiments(enabledConfig({ + service: undefined, + llmobs: { DD_LLMOBS_ENABLED: true }, + })) + assert.ok(!(exp instanceof NoopExperiments)) + const dataset = exp.createDataset('d') - assert.equal(typeof dataset.push, 'function') + assert.equal(dataset.projectName(), 'default-project') + }) + + it('does not use mlApp or service as the experiment project fallback', () => { + const withMlApp = createExperiments(enabledConfig({ + service: 'my-service', + llmobs: { DD_LLMOBS_ENABLED: true, mlApp: 'my-app' }, + })) + assert.equal(withMlApp.createDataset('with-ml-app').projectName(), 'default-project') + + const withService = createExperiments(enabledConfig({ + service: 'my-service', + llmobs: { DD_LLMOBS_ENABLED: true }, + })) + assert.equal(withService.createDataset('with-service').projectName(), 'default-project') }) it('rejects duplicate custom record ids', () => { @@ -163,20 +239,6 @@ describe('LLMObs Experiments facade', () => { /record id must be a non-empty string/ ) }) - - it('returns a no-op with actionable steps when neither mlApp nor service is set', () => { - const warn = sinon.spy(log, 'warn') - const exp = createExperiments(enabledConfig({ service: undefined, llmobs: { DD_LLMOBS_ENABLED: true } })) - assert.ok(exp instanceof NoopExperiments) - - exp.createDataset('d') - - sinon.assert.calledWith( - warn, - 'LLMObs experiments unavailable: %s', - sinon.match(/DD_LLMOBS_ML_APP.*DD_SERVICE/) - ) - }) }) describe('no-op (disabled / missing keys)', () => { @@ -194,7 +256,13 @@ describe('LLMObs Experiments facade', () => { const experiment = exp.experiment({ name: 'exp' }) assert.equal(experiment.name(), 'exp') - assert.deepEqual(await experiment.run(), { experimentId: null, rows: [], url: null }) + assert.deepEqual(await experiment.run(), { + experimentId: null, + rows: [], + summaryEvaluations: {}, + runs: [], + url: null, + }) sinon.assert.calledThrice(warn) }) @@ -231,6 +299,7 @@ describe('LLMObs Experiments facade', () => { assert.equal(dataset.description(), 'desc') assert.equal(dataset.id(), null) assert.equal(dataset.projectId(), null) + assert.equal(dataset.projectName(), null) assert.equal(dataset.version(), null) assert.equal(dataset.latestVersion(), null) assert.deepEqual(dataset.filterTags(), []) @@ -255,7 +324,13 @@ describe('LLMObs Experiments facade', () => { assert.equal(experiment.name(), '') assert.equal(experiment.experimentId(), null) assert.equal(experiment.url(), null) - assert.deepEqual(await experiment.run(), { experimentId: null, rows: [], url: null }) + assert.deepEqual(await experiment.run(), { + experimentId: null, + rows: [], + summaryEvaluations: {}, + runs: [], + url: null, + }) sinon.assert.callCount(warn, 4) }) @@ -269,6 +344,38 @@ describe('LLMObs Experiments facade', () => { dataset.replaceTags(0) assert.deepEqual(dataset.records()[0].tags, []) }) + + it('adds multiple records to a no-op dataset', () => { + const dataset = new NoopExperiments().createDataset('d') + const returned = dataset.addRecords([ + { + id: 'custom-record', + inputData: 'first', + expectedOutput: 'one', + metadata: { row: 0 }, + tags: ['tag:first'], + }, + { inputData: 'second' }, + ]) + + assert.equal(returned, dataset) + assert.deepEqual(dataset.records(), [ + { + id: 'custom-record', + input: 'first', + expectedOutput: 'one', + metadata: { row: 0 }, + tags: ['tag:first'], + }, + { + id: null, + input: 'second', + expectedOutput: null, + metadata: {}, + tags: [], + }, + ]) + }) }) describe('pullDataset', () => { @@ -348,7 +455,7 @@ describe('LLMObs Experiments facade', () => { await assert.rejects( () => createExperiments(enabledConfig()).pullDataset('missing-dataset', { maxWaitMs: 0 }), - /Failed to list datasets in project 'my-app': list failed/ + /Failed to list datasets in project 'default-project': list failed/ ) }) @@ -357,7 +464,7 @@ describe('LLMObs Experiments facade', () => { await assert.rejects( () => createExperiments(enabledConfig()).pullDataset('missing-dataset', { maxWaitMs: 0 }), - /Dataset 'missing-dataset' not found in project 'my-app'/ + /Dataset 'missing-dataset' not found in project 'default-project'/ ) }) @@ -367,7 +474,7 @@ describe('LLMObs Experiments facade', () => { await assert.rejects( () => createExperiments(enabledConfig()).pullDataset('remote-dataset', { maxWaitMs: 0 }), - /Failed to fetch records for dataset 'remote-dataset' in project 'my-app': records failed/ + /Failed to fetch records for dataset 'remote-dataset' in project 'default-project': records failed/ ) }) @@ -408,7 +515,16 @@ describe('LLMObs Experiments facade', () => { }) describe('experiment run', () => { + function stubDynamicExperimentEvents () { + // Event payloads include generated span/trace ids; experiment.spec.js covers their shape. + // Keep these facade tests focused on control-plane VCR calls and returned result plumbing. + return sinon.stub(ExperimentsClient.prototype, 'postExperimentEvents').resolves() + } + it('runs a multi-row experiment and returns rows, ids, metric values, and dashboard URLs', async function () { + this.timeout(60_000) + + const postExperimentEvents = stubDynamicExperimentEvents() const exp = backendExperiments() const dataset = trackBackendDataset(exp.createDataset(backendRichExperimentDatasetName, { description: 'created by a dd-trace-js experiments rich VCR test', @@ -448,6 +564,7 @@ describe('LLMObs Experiments facade', () => { assert.equal(result.rows.length, 2) assert.equal(result.runs.length, 1) assert.equal(result.runs[0].rows, result.rows) + sinon.assert.calledOnce(postExperimentEvents) assert.match(dataset.id(), /\S+/) assert.match(dataset.url(), /^https:\/\//) assert.equal(dataset.recordIds().length, 2) @@ -471,6 +588,9 @@ describe('LLMObs Experiments facade', () => { }) it('creates an experiment, submits row events, and marks the experiment completed', async function () { + this.timeout(60_000) + + const postExperimentEvents = stubDynamicExperimentEvents() const exp = backendExperiments() const dataset = trackBackendDataset(exp.createDataset(backendExperimentDatasetName, { description: 'created by a dd-trace-js experiments VCR test', @@ -495,6 +615,9 @@ describe('LLMObs Experiments facade', () => { assert.match(result.url, /^https:\/\//) assert.equal(result.rows.length, 1) assert.deepEqual(result.rows[0].evaluations, { exact: true }) + sinon.assert.calledOnce(postExperimentEvents) + // eslint-disable-next-line no-console + console.log(`Datadog experiment URL: ${result.url}`) }) }) @@ -514,7 +637,7 @@ describe('LLMObs Experiments facade', () => { sinon.stub(ExperimentsClient.prototype, 'updateExperiment').resolves() } - it('honors projectName override when no global project is configured', async () => { + it('honors a projectName override when no project is configured globally', async () => { stubExperimentRecorderClient() const warn = sinon.spy(log, 'warn') @@ -691,7 +814,7 @@ describe('LLMObs Experiments facade', () => { ExperimentsClient.prototype.postExperimentEvents.resetHistory() await recorder.submitEvaluationMetrics(span, [{ label: 'score' }]) sinon.assert.notCalled(ExperimentsClient.prototype.postExperimentEvents) - sinon.assert.calledThrice(warn) + sinon.assert.callCount(warn, 3) sinon.assert.calledWith( warn, 'LLMObs experiments: skipping external metric %s because it has neither value nor error', diff --git a/packages/dd-trace/test/llmobs/experiments/util.spec.js b/packages/dd-trace/test/llmobs/experiments/util.spec.js index 4d277ee8022..f2b944f9ec1 100644 --- a/packages/dd-trace/test/llmobs/experiments/util.spec.js +++ b/packages/dd-trace/test/llmobs/experiments/util.spec.js @@ -9,6 +9,7 @@ const log = require('../../../src/log') const { buildTags, durationNs, + generateRunId, inferMetricType, mergeTags, normalizeEvaluators, @@ -24,6 +25,15 @@ describe('LLMObs Experiments util', () => { sinon.restore() }) + it('generates UUID run ids', () => { + const first = generateRunId() + const second = generateRunId() + + assert.match(first, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + assert.match(second, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + assert.notEqual(first, second) + }) + it('validates evaluator names against the backend contract', () => { validateEvaluatorName('ok_Name-1') diff --git a/packages/dd-trace/test/llmobs/index.spec.js b/packages/dd-trace/test/llmobs/index.spec.js index da3020bfa61..43ab52c1cc7 100644 --- a/packages/dd-trace/test/llmobs/index.spec.js +++ b/packages/dd-trace/test/llmobs/index.spec.js @@ -33,30 +33,39 @@ describe('module', () => { let LLMObsSpanWriterSpy let LLMObsEvalMetricsWriterSpy let fetchAgentInfoStub + let registerTelemetryFlusher + let unregisterTelemetryFlusher + let originalVercel /** @type {import('sinon').SinonStub} */ let startupLogStub beforeEach(() => { + originalVercel = process.env.VERCEL store = {} logger = { debug: sinon.stub() } LLMObsSpanWriterSpy = sinon.stub().returns({ destroy: sinon.stub(), + flush: sinon.stub(), setAgentless: sinon.stub(), }) LLMObsEvalMetricsWriterSpy = sinon.stub().returns({ destroy: sinon.stub(), append: sinon.stub(), + flush: sinon.stub(), setAgentless: sinon.stub(), }) fetchAgentInfoStub = sinon.stub() + unregisterTelemetryFlusher = sinon.stub() + registerTelemetryFlusher = sinon.stub().returns(unregisterTelemetryFlusher) const llmobsModuleProxyRequireMeta = { './writers/spans': LLMObsSpanWriterSpy, './writers/evaluations': LLMObsEvalMetricsWriterSpy, + '../flush': { registerTelemetryFlusher }, '../log': logger, './storage': { storage: { @@ -88,6 +97,8 @@ describe('module', () => { }) afterEach(() => { + if (originalVercel === undefined) delete process.env.VERCEL + else process.env.VERCEL = originalVercel sinon.restore() llmobsModule.disable() }) @@ -455,6 +466,42 @@ describe('module', () => { sinon.assert.calledWith(LLMObsEvalMetricsWriterSpy().append, payload, undefined) }) + it('registers both LLMObs writers for lifecycle flushing', () => { + process.env.VERCEL = '1' + llmobsModule.enable({ llmobs: { mlApp: 'test', agentlessEnabled: false } }) + const done = sinon.spy() + const spanWriter = LLMObsSpanWriterSpy.firstCall.returnValue + const evalWriter = LLMObsEvalMetricsWriterSpy.firstCall.returnValue + let flushSpan + let flushEvaluation + spanWriter.flush.callsFake(callback => { flushSpan = callback }) + evalWriter.flush.callsFake(callback => { flushEvaluation = callback }) + + registerTelemetryFlusher.firstCall.args[0](done) + + sinon.assert.calledOnce(spanWriter.flush) + sinon.assert.calledOnce(evalWriter.flush) + flushSpan() + sinon.assert.notCalled(done) + flushEvaluation() + sinon.assert.calledOnce(done) + }) + + it('continues flushing when one LLMObs writer throws', () => { + process.env.VERCEL = '1' + llmobsModule.enable({ llmobs: { mlApp: 'test', agentlessEnabled: false } }) + const done = sinon.spy() + const spanWriter = LLMObsSpanWriterSpy.firstCall.returnValue + const evalWriter = LLMObsEvalMetricsWriterSpy.firstCall.returnValue + spanWriter.flush.throws(new Error('bad payload')) + evalWriter.flush.callsFake(callback => callback()) + + registerTelemetryFlusher.firstCall.args[0](done) + + sinon.assert.calledOnce(evalWriter.flush) + sinon.assert.calledOnce(done) + }) + it('removes all subscribers when disabling', () => { llmobsModule.enable({ llmobs: { mlApp: 'test', agentlessEnabled: false } }) @@ -464,5 +511,75 @@ describe('module', () => { assert.strictEqual(evalMetricAppendCh.hasSubscribers, false) assert.strictEqual(spanFinishCh.hasSubscribers, false) assert.strictEqual(flushCh.hasSubscribers, false) + sinon.assert.calledOnce(unregisterTelemetryFlusher) + }) + + it('retains destroyed writers until every lifecycle flush completes', () => { + process.env.VERCEL = '1' + const retiredUnregister = sinon.stub() + registerTelemetryFlusher.onSecondCall().returns(retiredUnregister) + llmobsModule.enable({ llmobs: { mlApp: 'test', agentlessEnabled: false } }) + const spanWriter = LLMObsSpanWriterSpy.firstCall.returnValue + const evalWriter = LLMObsEvalMetricsWriterSpy.firstCall.returnValue + let completeSpan + let completeEvaluation + spanWriter.destroy.callsFake(done => { completeSpan = done }) + evalWriter.destroy.callsFake(done => { completeEvaluation = done }) + + llmobsModule.disable() + + sinon.assert.calledTwice(registerTelemetryFlusher) + sinon.assert.notCalled(retiredUnregister) + completeSpan() + sinon.assert.notCalled(retiredUnregister) + completeEvaluation() + sinon.assert.calledOnce(retiredUnregister) + }) + + it('retires reinitialized writers until their destroy callbacks complete', () => { + process.env.VERCEL = '1' + const initialUnregister = sinon.stub() + const retiredUnregister = sinon.stub() + const replacementUnregister = sinon.stub() + registerTelemetryFlusher.onCall(0).returns(initialUnregister) + registerTelemetryFlusher.onCall(1).returns(retiredUnregister) + registerTelemetryFlusher.onCall(2).returns(replacementUnregister) + llmobsModule.enable({ llmobs: { mlApp: 'test', agentlessEnabled: false } }) + const spanWriter = LLMObsSpanWriterSpy.firstCall.returnValue + const evalWriter = LLMObsEvalMetricsWriterSpy.firstCall.returnValue + let destroySpan + let destroyEvaluation + spanWriter.destroy.callsFake(done => { destroySpan = done }) + evalWriter.destroy.callsFake(done => { destroyEvaluation = done }) + + llmobsModule.enable({ llmobs: { mlApp: 'test', agentlessEnabled: false } }) + + sinon.assert.calledOnce(initialUnregister) + sinon.assert.calledThrice(registerTelemetryFlusher) + sinon.assert.notCalled(retiredUnregister) + spanWriter.flush.callsFake(done => done()) + evalWriter.flush.callsFake(done => done()) + const done = sinon.spy() + registerTelemetryFlusher.secondCall.args[0](done) + sinon.assert.calledOnce(spanWriter.flush) + sinon.assert.calledOnce(evalWriter.flush) + sinon.assert.calledOnce(done) + destroySpan() + sinon.assert.notCalled(retiredUnregister) + destroyEvaluation() + sinon.assert.calledOnce(retiredUnregister) + }) + + it('completes transport selection for writers retired during initialization', () => { + process.env.VERCEL = '1' + llmobsModule.enable({ llmobs: { mlApp: 'test' } }) + const spanWriter = LLMObsSpanWriterSpy.firstCall.returnValue + const evalWriter = LLMObsEvalMetricsWriterSpy.firstCall.returnValue + + llmobsModule.disable() + fetchAgentInfoStub.firstCall.args[1](null, { endpoints: ['/evp_proxy/v2/'] }) + + sinon.assert.calledWith(spanWriter.setAgentless, false) + sinon.assert.calledWith(evalWriter.setAgentless, false) }) }) diff --git a/packages/dd-trace/test/llmobs/noop.spec.js b/packages/dd-trace/test/llmobs/noop.spec.js index cb5bece4db9..6ee248bbaef 100644 --- a/packages/dd-trace/test/llmobs/noop.spec.js +++ b/packages/dd-trace/test/llmobs/noop.spec.js @@ -80,6 +80,10 @@ describe('noop', () => { llmobs.deregisterProcessor() }) + it('exposes the no-op experiments facade', () => { + assert.strictEqual(typeof llmobs.experiments.createDataset, 'function') + }) + it('using "annotationContext" should not throw', () => { const result = llmobs.annotationContext({}, () => { return 5 diff --git a/packages/dd-trace/test/llmobs/openai-utils.spec.js b/packages/dd-trace/test/llmobs/openai-utils.spec.js index 0df67b7ed58..da28908680b 100644 --- a/packages/dd-trace/test/llmobs/openai-utils.spec.js +++ b/packages/dd-trace/test/llmobs/openai-utils.spec.js @@ -1,10 +1,25 @@ 'use strict' const assert = require('node:assert/strict') -const { getOpenAIModelProvider } = require('../../src/llmobs/plugins/openai/utils') +const { extractContentParts, getOpenAIModelProvider } = require('../../src/llmobs/plugins/openai/utils') const OpenAiLLMObsPlugin = require('../../src/llmobs/plugins/openai') const { UNKNOWN_MODEL_PROVIDER } = require('../../src/llmobs/constants/tags') +describe('extractContentParts', () => { + it('preserves empty text and formats every multimodal fallback', () => { + assert.deepStrictEqual(extractContentParts([ + { type: 'text' }, + { type: 'image_url' }, + { type: 'input_audio' }, + { type: 'input_audio', input_audio: { data: 'aGVsbG8=', format: 'wav' } }, + null, + ]), { + content: '\n[image]\n[audio]\n[]', + audioParts: [{ content: 'aGVsbG8=', mimeType: 'audio/wav' }], + }) + }) +}) + describe('getOpenAIModelProvider', () => { it('returns openai for openai.com URLs', () => { assert.strictEqual(getOpenAIModelProvider('https://api.openai.com/v1'), 'openai') diff --git a/packages/dd-trace/test/llmobs/plugins/ai/index.spec.js b/packages/dd-trace/test/llmobs/plugins/ai/index.spec.js index 485b40a4f9b..3edca61ffc6 100644 --- a/packages/dd-trace/test/llmobs/plugins/ai/index.spec.js +++ b/packages/dd-trace/test/llmobs/plugins/ai/index.spec.js @@ -954,9 +954,10 @@ describe('Plugin', () => { }) }) - it('extracts last user message content from messages in generateText', async function () { - if (semifies(realVersion, '<5.0.0')) this.skip() + const generateTextMessageTest = semifies(realVersion, '>=5.0.0') ? it : it.skip + // Structured message content is only available from ai 5.0.0. + generateTextMessageTest('extracts last user message content from messages in generateText', async function () { const OpenAIModule = require(`../../../../../../versions/@ai-sdk/openai@${openaiVersionKey}`) const { createOpenAI } = OpenAIModule.get() const mockOpenai = createOpenAI({ @@ -995,9 +996,10 @@ describe('Plugin', () => { }) }) - it('extracts last user message content from messages in generateObject', async function () { - if (semifies(realVersion, '<5.0.0')) this.skip() + const generateObjectMessageTest = semifies(realVersion, '>=5.0.0') ? it : it.skip + // Structured message content is only available from ai 5.0.0. + generateObjectMessageTest('extracts last user message content from messages in generateObject', async function () { const OpenAIModule = require(`../../../../../../versions/@ai-sdk/openai@${openaiVersionKey}`) const { createOpenAI } = OpenAIModule.get() const mockOpenai = createOpenAI({ @@ -1037,13 +1039,10 @@ describe('Plugin', () => { }) }) - describe('ToolLoopAgent', function () { - beforeEach(function () { - if (semifies(realVersion, '<6.0.0')) { - this.skip() - } - }) + const toolLoopAgentDescribe = semifies(realVersion, '>=6.0.0') ? describe : describe.skip + // The cache-token metrics exercised here are only available from ai 6.0.0. + toolLoopAgentDescribe('ToolLoopAgent', function () { it('creates a text generation root span for ToolLoopAgent.generate', async () => { const agent = new ai.ToolLoopAgent({ model: openai('gpt-4o-mini'), @@ -1600,8 +1599,11 @@ describe('Plugin', () => { // because the SDK never exposes the attribute there. const cacheReadOnDoGenerate = semifies(realVersion, '>=6.0.184') - if (scenarios.includes('cache-read')) { - it(`surfaces cache_read_input_tokens when ${providerName} returns cache read tokens`, async () => { + { + const cacheReadTest = scenarios.includes('cache-read') ? it : it.skip + const cacheReadTitle = `surfaces cache_read_input_tokens when ${providerName} returns cache read tokens` + + cacheReadTest(cacheReadTitle, async () => { const model = buildModel(PackageModule, 'cache-read') await ai.generateText({ model, prompt: 'What does Datadog LLM Observability do?' }) @@ -1618,10 +1620,12 @@ describe('Plugin', () => { assert.equal(doGenerateSpan.metrics.cache_read_input_tokens, expected.cache_read_input_tokens) assert.equal(doGenerateSpan.metrics.cache_write_input_tokens, expected.cache_write_input_tokens) }) - } - if (scenarios.includes('cache-write')) { - it(`surfaces cache_write_input_tokens when ${providerName} returns cache write tokens`, async () => { + const cacheWriteTest = scenarios.includes('cache-write') ? it : it.skip + const cacheWriteTitle = `surfaces cache_write_input_tokens when ${providerName} ` + + 'returns cache write tokens' + + cacheWriteTest(cacheWriteTitle, async () => { const model = buildModel(PackageModule, 'cache-write') await ai.generateText({ model, prompt: 'What does Datadog LLM Observability do?' }) diff --git a/packages/dd-trace/test/llmobs/plugins/ai/index.v7.spec.js b/packages/dd-trace/test/llmobs/plugins/ai/index.v7.spec.js index dd3b400ca24..9301565041f 100644 --- a/packages/dd-trace/test/llmobs/plugins/ai/index.v7.spec.js +++ b/packages/dd-trace/test/llmobs/plugins/ai/index.v7.spec.js @@ -150,9 +150,10 @@ describe('Plugin', () => { }) }) - it('creates a span for embedMany', async function () { - if (!semifies(resolvedVersion, '>=7.0.23')) this.skip() + const embedManyTest = semifies(resolvedVersion, '>=7.0.23') ? it : it.skip + // embedMany is only available from ai 7.0.23. + embedManyTest('creates a span for embedMany', async function () { await ai.embedMany({ model: openai.embedding('text-embedding-ada-002'), values: ['hello world', 'goodbye world'], @@ -1146,8 +1147,11 @@ describe('Plugin', () => { PackageModule = require(`../../../../../../versions/${packageName}@${packageVersion}`) }) - if (scenarios.includes('cache-read')) { - it(`surfaces cache_read_input_tokens when ${providerName} returns cache read tokens`, async () => { + { + const cacheReadTest = scenarios.includes('cache-read') ? it : it.skip + const cacheReadTitle = `surfaces cache_read_input_tokens when ${providerName} returns cache read tokens` + + cacheReadTest(cacheReadTitle, async () => { const model = buildModel(PackageModule, 'cache-read') await ai.generateText({ model, prompt: 'What does Datadog LLM Observability do?' }) @@ -1159,10 +1163,12 @@ describe('Plugin', () => { assert.equal(languageModelCallSpan.metrics.cache_read_input_tokens, expected.cache_read_input_tokens) assert.equal(languageModelCallSpan.metrics.cache_write_input_tokens, expected.cache_write_input_tokens) }) - } - if (scenarios.includes('cache-write')) { - it(`surfaces cache_write_input_tokens when ${providerName} returns cache write tokens`, async () => { + const cacheWriteTest = scenarios.includes('cache-write') ? it : it.skip + const cacheWriteTitle = `surfaces cache_write_input_tokens when ${providerName} ` + + 'returns cache write tokens' + + cacheWriteTest(cacheWriteTitle, async () => { const model = buildModel(PackageModule, 'cache-write') await ai.generateText({ model, prompt: 'What does Datadog LLM Observability do?' }) diff --git a/packages/dd-trace/test/llmobs/sdk/integration.spec.js b/packages/dd-trace/test/llmobs/sdk/integration.spec.js index a7bd6bd64ef..da4c3f46028 100644 --- a/packages/dd-trace/test/llmobs/sdk/integration.spec.js +++ b/packages/dd-trace/test/llmobs/sdk/integration.spec.js @@ -128,6 +128,8 @@ describe('end to end sdk integration tests', () => { }) it('submits evaluations', async () => { + const evaluationMetricsPromise = getEvaluationMetrics() + llmobs.trace({ kind: 'agent', name: 'myAgent' }, () => { llmobs.annotate({ inputData: 'hello', outputData: 'world' }) const spanCtx = llmobs.exportSpan() @@ -141,8 +143,10 @@ describe('end to end sdk integration tests', () => { }) }) - const { apmSpans, llmobsSpans } = await getEvents() - const llmobsEvaluationMetrics = await getEvaluationMetrics() + const [{ apmSpans, llmobsSpans }, llmobsEvaluationMetrics] = await Promise.all([ + getEvents(), + evaluationMetricsPromise, + ]) assert.equal(apmSpans.length, 1) assert.equal(llmobsSpans.length, 1) diff --git a/packages/dd-trace/test/llmobs/util.js b/packages/dd-trace/test/llmobs/util.js index c14551b8753..8bf40caa486 100644 --- a/packages/dd-trace/test/llmobs/util.js +++ b/packages/dd-trace/test/llmobs/util.js @@ -498,11 +498,16 @@ function useLlmObs ({ } }, - getEvaluationMetrics: function () { - const evaluationMetricsRequests = agent.getLlmObsEvaluationMetricsRequests(true) - return evaluationMetricsRequests - .flatMap(request => request.data.attributes.metrics) - .sort((a, b) => a.timestamp_ms - b.timestamp_ms) + getEvaluationMetrics: async function () { + const evaluationMetrics = [] + + while (evaluationMetrics.length === 0 && !runState.cancelled) { + await new Promise(resolve => setImmediate(resolve)) + const evaluationMetricsRequests = agent.getLlmObsEvaluationMetricsRequests(true) + evaluationMetrics.push(...evaluationMetricsRequests.flatMap(request => request.data.attributes.metrics)) + } + + return evaluationMetrics.sort((a, b) => a.timestamp_ms - b.timestamp_ms) }, } } diff --git a/packages/dd-trace/test/llmobs/writers/base.spec.js b/packages/dd-trace/test/llmobs/writers/base.spec.js index e603296bdb3..4d6349fa129 100644 --- a/packages/dd-trace/test/llmobs/writers/base.spec.js +++ b/packages/dd-trace/test/llmobs/writers/base.spec.js @@ -11,6 +11,7 @@ const { useEnv } = require('../../../../../integration-tests/helpers') const { removeDestroyHandler } = require('../util') describe('BaseLLMObsWriter', () => { + const originalVercel = process.env.VERCEL let BaseLLMObsWriter let writer let request @@ -49,6 +50,8 @@ describe('BaseLLMObsWriter', () => { }) afterEach(() => { + if (originalVercel === undefined) delete process.env.VERCEL + else process.env.VERCEL = originalVercel clock.restore() removeDestroyHandler() }) @@ -238,6 +241,123 @@ describe('BaseLLMObsWriter', () => { sinon.assert.calledOnce(request) }) + + it('flushes a lifecycle request after agent strategy selection completes', () => { + writer = new BaseLLMObsWriter(options) + writer.makePayload = (events) => ({ events }) + writer.append({ foo: 'bar' }) + const done = sinon.spy() + + writer.flush(done) + + sinon.assert.notCalled(request) + sinon.assert.notCalled(done) + writer.setAgentless(true) + + sinon.assert.calledOnce(request) + request.firstCall.args[2]() + sinon.assert.calledOnce(done) + }) + + it('continues flushing after a request throws', () => { + writer = new BaseLLMObsWriter(options) + writer.setAgentless(true) + writer.makePayload = (events) => ({ events }) + writer.append({ foo: 'default' }) + writer.append({ foo: 'tenant' }, { apiKey: 'key-a', site: 'site-a.com' }) + request.onFirstCall().throws(new Error('invalid header value')) + const done = sinon.spy() + + writer.flush(done) + + sinon.assert.calledTwice(request) + sinon.assert.calledOnce(done) + sinon.assert.calledWith( + logger.error, + 'Failed to send LLMObs %s events: %s', + undefined, + 'invalid header value' + ) + }) + + it('waits for an export already in flight', () => { + process.env.VERCEL = '1' + writer = new BaseLLMObsWriter(options) + writer.setAgentless(true) + writer.makePayload = (events) => ({ events }) + writer.append({ foo: 'bar' }) + let requestDone + const done = sinon.spy() + request.callsFake((payload, requestOptions, callback) => { requestDone = callback }) + + writer.flush() + writer.flush(done) + + sinon.assert.notCalled(done) + requestDone() + sinon.assert.calledOnce(done) + }) + + it('waits for every request drained at the flush boundary', () => { + process.env.VERCEL = '1' + writer = new BaseLLMObsWriter(options) + writer.setAgentless(true) + writer.makePayload = (events) => ({ events }) + writer.append({ foo: 'default' }) + writer.append({ foo: 'tenant' }, { apiKey: 'key-a', site: 'site-a.com' }) + const callbacks = [] + const done = sinon.spy() + request.callsFake((payload, requestOptions, callback) => { callbacks.push(callback) }) + + writer.flush(done) + + assert.strictEqual(callbacks.length, 2) + callbacks[0]() + sinon.assert.notCalled(done) + callbacks[1]() + sinon.assert.calledOnce(done) + }) + + it('continues after a boundary request throws while waiting for earlier requests', () => { + process.env.VERCEL = '1' + writer = new BaseLLMObsWriter(options) + writer.setAgentless(true) + writer.makePayload = (events) => ({ events }) + const callbacks = [] + request.onFirstCall().callsFake((payload, requestOptions, callback) => { callbacks.push(callback) }) + writer.append({ foo: 'in flight' }) + writer.flush() + writer.append({ foo: 'boundary' }) + writer.append({ foo: 'tenant' }, { apiKey: 'key-a', site: 'site-a.com' }) + request.onSecondCall().throws(new Error('invalid header value')) + request.onThirdCall().callsFake((payload, requestOptions, callback) => { callbacks.push(callback) }) + const done = sinon.spy() + + writer.flush(done) + + sinon.assert.calledThrice(request) + sinon.assert.notCalled(done) + callbacks[0]() + sinon.assert.notCalled(done) + callbacks[1]() + sinon.assert.calledOnce(done) + }) + + it('does not retain requests outside Vercel', () => { + writer = new BaseLLMObsWriter(options) + writer.setAgentless(true) + writer.makePayload = (events) => ({ events }) + writer.append({ foo: 'bar' }) + let requestDone + const done = sinon.spy() + request.callsFake((payload, requestOptions, callback) => { requestDone = callback }) + + writer.flush() + writer.flush(done) + + sinon.assert.calledOnce(done) + assert.strictEqual(typeof requestDone, 'function') + }) }) it('does not flush an empty buffer', () => { diff --git a/packages/dd-trace/test/noop.spec.js b/packages/dd-trace/test/noop.spec.js index 59b7e9e3232..df61d071c0e 100644 --- a/packages/dd-trace/test/noop.spec.js +++ b/packages/dd-trace/test/noop.spec.js @@ -30,6 +30,13 @@ describe('NoopTracer', () => { assert.strictEqual(result, 'test') }) + + it('should provide recordException as a no-op', () => { + tracer.trace('test', {}, span => { + assert.strictEqual(typeof span.recordException, 'function') + span.recordException(new Error('test')) + }) + }) }) describe('wrap', () => { diff --git a/packages/dd-trace/test/openfeature/writers/exposures.spec.js b/packages/dd-trace/test/openfeature/writers/exposures.spec.js index 922dc8b172d..b6fae2b0e66 100644 --- a/packages/dd-trace/test/openfeature/writers/exposures.spec.js +++ b/packages/dd-trace/test/openfeature/writers/exposures.spec.js @@ -268,6 +268,48 @@ describe('OpenFeature Exposures Writer', () => { }) }) + it('should include serial_id when present', () => { + const payload = writer.makePayload([{ ...exposureEvent, serial_id: 340132 }]) + + assert.deepStrictEqual(payload.exposures[0], { + timestamp: 1672531200000, + allocation: { key: 'allocation_123' }, + flag: { key: 'test_flag' }, + variant: { key: 'A' }, + serial_id: 340132, + subject: { + id: 'user_123', + type: 'user', + attributes: { plan: 'premium' }, + }, + }) + }) + + it('should include a serial_id of zero', () => { + const payload = writer.makePayload([{ ...exposureEvent, serial_id: 0 }]) + + assert.strictEqual(payload.exposures[0].serial_id, 0) + }) + + // The intake declares serial_id as an integer and rejects the whole exposure on a type + // mismatch, so anything non-numeric has to leave the encoded payload entirely. + for (const [label, serialId] of [ + ['absent', undefined], + ['null', null], + ['a string', '340132'], + ['a boolean', true], + ]) { + it(`should omit serial_id when it is ${label}`, () => { + const event = { ...exposureEvent } + if (serialId !== undefined) { + event.serial_id = serialId + } + const encoded = JSON.stringify(writer.makePayload([event])) + + assert.ok(!encoded.includes('serial_id'), `Encoded payload: ${encoded}`) + }) + } + it('should handle optional config values', () => { const writerWithoutOptionals = new ExposuresWriter({ ...config, diff --git a/packages/dd-trace/test/opentelemetry/logs.spec.js b/packages/dd-trace/test/opentelemetry/logs.spec.js index 3928372c6c9..fa24e3f5581 100644 --- a/packages/dd-trace/test/opentelemetry/logs.spec.js +++ b/packages/dd-trace/test/opentelemetry/logs.spec.js @@ -15,6 +15,7 @@ require('../setup/core') const { protoLogsService } = require('../../src/opentelemetry/otlp/protobuf_loader').getProtobufTypes() const { getConfigFresh } = require('../helpers/config') const { assertObjectContains } = require('../../../../integration-tests/helpers') +const BatchLogRecordProcessor = require('../../src/opentelemetry/logs/batch_log_processor') /** * @param {object} type protobufjs Type instance for the OTLP service message @@ -142,6 +143,127 @@ describe('OpenTelemetry Logs', () => { }) describe('Logs Export', () => { + it('waits for an in-flight export during forceFlush', () => { + process.env.VERCEL = '1' + let exportDone + let flushDone + const processor = new BatchLogRecordProcessor({ + export: (records, done) => { exportDone = done }, + flush: (done) => { flushDone = done }, + }, 60_000, 1) + const done = sinon.spy() + + processor.onEmit({ body: 'in flight' }, { name: 'test' }) + processor.forceFlush(done) + + sinon.assert.notCalled(done) + exportDone({ code: 0 }) + sinon.assert.notCalled(done) + flushDone() + sinon.assert.calledOnce(done) + }) + + it('drains queued batches and waits for earlier size-triggered exports', () => { + process.env.VERCEL = '1' + const batches = [] + const callbacks = [] + const flushCallbacks = [] + let activeExports = 0 + const completeFlushes = () => { + if (activeExports !== 0) return + while (flushCallbacks.length > 0) flushCallbacks.shift()() + } + const processor = new BatchLogRecordProcessor({ + export: (records, done) => { + batches.push(records) + activeExports++ + callbacks.push(() => { + activeExports-- + done({ code: 0 }) + completeFlushes() + }) + }, + flush: (done) => { + if (activeExports === 0) done() + else flushCallbacks.push(done) + }, + }, 60_000, 2) + const done = sinon.spy() + + for (let index = 0; index < 5; index++) { + processor.onEmit({ body: index }, { name: 'test' }) + } + processor.forceFlush(done) + + assert.deepStrictEqual(batches.map(batch => batch.map(record => record.body)), [ + [0, 1], [2, 3], [4], + ]) + callbacks.shift()() + callbacks.shift()() + callbacks.shift()() + + sinon.assert.calledOnce(done) + }) + + it('waits for an earlier export when the boundary batch throws', () => { + process.env.VERCEL = '1' + let priorFlushDone + const processor = new BatchLogRecordProcessor({ + export: sinon.stub() + .onFirstCall().callsFake(() => {}) + .onSecondCall().throws(new Error('encode failed')), + flush: done => { priorFlushDone = done }, + }, 60_000, 2) + const done = sinon.spy() + + processor.onEmit({ body: 'in flight' }, { name: 'test' }) + processor.onEmit({ body: 'in flight' }, { name: 'test' }) + processor.onEmit({ body: 'boundary' }, { name: 'test' }) + processor.forceFlush(done) + + sinon.assert.notCalled(done) + priorFlushDone() + sinon.assert.calledOnce(done) + }) + + it('does not wait for records emitted after the flush boundary', () => { + process.env.VERCEL = '1' + const exports = [] + let firstExportDone + const processor = new BatchLogRecordProcessor({ + export: (records, done) => { + exports.push(records.map(record => record.body)) + if (records[0].body === 'before') firstExportDone = done + }, + flush: done => done(), + }, 60_000, 2) + const done = sinon.spy() + + processor.onEmit({ body: 'before' }, { name: 'test' }) + processor.forceFlush(done) + processor.onEmit({ body: 'after' }, { name: 'test' }) + firstExportDone({ code: 0 }) + + assert.deepStrictEqual(exports, [['before']]) + sinon.assert.calledOnce(done) + }) + + it('does not retain log delivery outside Vercel', () => { + let exportDone + const processor = new BatchLogRecordProcessor({ + export: (records, done) => { exportDone = done }, + flush: sinon.spy(), + }, 60_000, 1) + const done = sinon.spy() + + processor.onEmit({ body: 'outside Vercel' }, { name: 'test' }) + processor.forceFlush(done) + + sinon.assert.notCalled(processor.exporter.flush) + sinon.assert.calledOnce(done) + assert.strictEqual(typeof exportDone, 'function') + }) + it('exports logs with complete OTLP structure, trace correlation, and instrumentation info', () => { mockOtlpExport((decoded, capturedHeaders) => { const { resource } = decoded.resourceLogs[0] diff --git a/packages/dd-trace/test/opentelemetry/metrics.spec.js b/packages/dd-trace/test/opentelemetry/metrics.spec.js index 0ad70fbdc51..f7d26b6ffff 100644 --- a/packages/dd-trace/test/opentelemetry/metrics.spec.js +++ b/packages/dd-trace/test/opentelemetry/metrics.spec.js @@ -13,6 +13,8 @@ require('../setup/core') const { protoMetricsService } = require('../../src/opentelemetry/otlp/protobuf_loader').getProtobufTypes() const { getConfigFresh } = require('../helpers/config') const { DEFAULT_MAX_MEASUREMENT_QUEUE_SIZE } = require('../../src/opentelemetry/metrics/constants') +const MeterProvider = require('../../src/opentelemetry/metrics/meter_provider') +const PeriodicMetricReader = require('../../src/opentelemetry/metrics/periodic_metric_reader') /** * @param {object} type protobufjs Type instance for the OTLP service message @@ -661,6 +663,51 @@ describe('OpenTelemetry Meter Provider', () => { }) describe('Lifecycle', () => { + it('waits for an in-flight export during forceFlush', () => { + const exports = [] + const flushes = [] + const reader = new PeriodicMetricReader({ + export: (metrics, done) => { exports.push(done) }, + flush: (done) => { flushes.push(done) }, + }, 60_000, 'DELTA', 1024) + const meter = new MeterProvider({ reader }).getMeter('test') + const firstDone = sinon.spy() + const done = sinon.spy() + + meter.createCounter('in-flight').add(1) + reader.forceFlush(firstDone) + flushes.shift()() + meter.createCounter('boundary').add(1) + reader.forceFlush(done) + + sinon.assert.notCalled(done) + assert.strictEqual(exports.length, 2) + exports[1]({ code: 0 }) + sinon.assert.notCalled(done) + flushes[0]() + sinon.assert.calledOnce(done) + exports[0]({ code: 0 }) + reader.shutdown() + }) + + it('waits for an earlier export when the boundary export throws', () => { + let priorDone + const reader = new PeriodicMetricReader({ + export: sinon.stub().throws(new Error('encode failed')), + flush: done => { priorDone = done }, + }, 60_000, 'DELTA', 1024) + const meter = new MeterProvider({ reader }).getMeter('test') + const done = sinon.spy() + + meter.createCounter('boundary').add(1) + reader.forceFlush(done) + + sinon.assert.notCalled(done) + priorDone() + sinon.assert.calledOnce(done) + reader.shutdown() + }) + it('handles shutdown gracefully', async () => { setupMetrics() const provider = metrics.getMeterProvider() diff --git a/packages/dd-trace/test/opentelemetry/metrics/otlp_span_stats_exporter.spec.js b/packages/dd-trace/test/opentelemetry/metrics/otlp_span_stats_exporter.spec.js index ccceb847fd6..7bbb05d922b 100644 --- a/packages/dd-trace/test/opentelemetry/metrics/otlp_span_stats_exporter.spec.js +++ b/packages/dd-trace/test/opentelemetry/metrics/otlp_span_stats_exporter.spec.js @@ -124,8 +124,10 @@ describe('OtlpStatsExporter', () => { let exporter let httpStub let mockReq + let originalVercel beforeEach(() => { + originalVercel = process.env.VERCEL mockReq = { write: sinon.stub(), end: sinon.stub(), @@ -151,6 +153,8 @@ describe('OtlpStatsExporter', () => { afterEach(() => { httpStub.restore() + if (originalVercel === undefined) delete process.env.VERCEL + else process.env.VERCEL = originalVercel }) it('sends a POST to /v1/metrics', () => { @@ -218,4 +222,58 @@ describe('OtlpStatsExporter', () => { exporter.export(drained, BUCKET_SIZE_NS) assert.ok(httpStub.calledOnce) }) + + it('flushes after an in-flight HTTP export completes', () => { + let onEnd + httpStub.callsFake((options, callback) => { + const mockRes = { + statusCode: 200, + on: sinon.stub(), + once: (event, handler) => { + if (event === 'end') onEnd = handler + return mockRes + }, + } + callback(mockRes) + return mockReq + }) + process.env.VERCEL = '1' + const serverlessExporter = new OtlpStatsExporter('http://localhost:4318/v1/metrics', 'http/json', RESOURCE_ATTRS) + const flushed = sinon.spy() + + serverlessExporter.export(makeDrained([makeSpan()]), BUCKET_SIZE_NS) + serverlessExporter.flush(flushed) + + sinon.assert.notCalled(flushed) + onEnd() + sinon.assert.calledOnce(flushed) + }) + + it('does not wait for exports started after the flush boundary', () => { + const onEnd = [] + httpStub.callsFake((options, callback) => { + const mockRes = { + statusCode: 200, + on: sinon.stub(), + once: (event, handler) => { + if (event === 'end') onEnd.push(handler) + return mockRes + }, + } + callback(mockRes) + return mockReq + }) + process.env.VERCEL = '1' + const serverlessExporter = new OtlpStatsExporter('http://localhost:4318/v1/metrics', 'http/json', RESOURCE_ATTRS) + const flushed = sinon.spy() + + serverlessExporter.export(makeDrained([makeSpan()]), BUCKET_SIZE_NS) + serverlessExporter.flush(flushed) + serverlessExporter.export(makeDrained([makeSpan()]), BUCKET_SIZE_NS) + + onEnd[0]() + sinon.assert.calledOnce(flushed) + onEnd[1]() + sinon.assert.calledOnce(flushed) + }) }) diff --git a/packages/dd-trace/test/opentelemetry/tracer.spec.js b/packages/dd-trace/test/opentelemetry/tracer.spec.js index 077971bb73c..ee36eed2bf4 100644 --- a/packages/dd-trace/test/opentelemetry/tracer.spec.js +++ b/packages/dd-trace/test/opentelemetry/tracer.spec.js @@ -8,7 +8,7 @@ const sinon = require('sinon') const api = require('@opentelemetry/api') const { hrTime, timeInputToHrTime } = require('../../../../vendor/dist/@opentelemetry/core') -const { AUTO_KEEP, AUTO_REJECT, USER_KEEP } = require('../../../../ext/priority') +const { AUTO_KEEP, AUTO_REJECT, USER_KEEP, USER_REJECT } = require('../../../../ext/priority') const { storage } = require('../../../datadog-core') require('../setup/core') require('../../').init() @@ -284,18 +284,26 @@ describe('OTel Tracer', () => { assert.strictEqual(spanContext._ddContext._trace.origin, 'foo') }) - it('falls back to AUTO_REJECT/AUTO_KEEP when tracestate has no s: field', () => { - const rejected = convert(0, 'other=bleh,dd=o:foo;t.dm:-4') - assert.strictEqual(rejected._ddContext._sampling.priority, AUTO_REJECT) - - const kept = convert(1, 'other=bleh,dd=o:foo;t.dm:-4') - assert.strictEqual(kept._ddContext._sampling.priority, AUTO_KEEP) - }) - - it('falls back to AUTO_KEEP for RUM traces without a priority', () => { - const spanContext = convert(1, 'other=bleh,dd=o:rum') - assert.strictEqual(spanContext._ddContext._sampling.priority, AUTO_KEEP) - assert.strictEqual(spanContext._ddContext._trace.origin, 'rum') + it('reconciles trace flags, Datadog priority, and RUM origin', () => { + const cases = [ + [0, null, AUTO_REJECT], + [1, null, AUTO_KEEP], + [0, 'other=bleh,dd=o:foo;t.dm:-4', AUTO_REJECT], + [1, 'other=bleh,dd=o:foo;t.dm:-4', AUTO_KEEP], + [0, 'dd=s:-1', USER_REJECT], + [1, 'dd=s:2', USER_KEEP], + [0, 'dd=s:2', AUTO_REJECT], + [1, 'dd=s:-1', AUTO_KEEP], + [0, 'dd=o:rum', AUTO_KEEP], + [1, 'dd=o:rum', AUTO_KEEP], + [0, 'dd=s:0;o:rum', AUTO_REJECT], + [1, 'dd=s:0;o:rum', AUTO_KEEP], + ] + + for (const [traceFlag, tracestate, expected] of cases) { + const spanContext = convert(traceFlag, tracestate) + assert.strictEqual(spanContext._ddContext._sampling.priority, expected) + } }) }) 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 f2718163694..bc8c781b226 100644 --- a/packages/dd-trace/test/opentracing/propagation/text_map.spec.js +++ b/packages/dd-trace/test/opentracing/propagation/text_map.spec.js @@ -19,7 +19,7 @@ const { setBaggageItem, getBaggageItem, getAllBaggageItems, removeAllBaggageItem const { AUTO_KEEP, AUTO_REJECT, USER_KEEP } = require('../../../../../ext/priority') const { SAMPLING_MECHANISM_MANUAL } = require('../../../src/constants') -// v5 spells single-header B3 propagation as `'b3 single header'`; v6 reuses `'b3'` for it. +// v5 spells single-header B3 propagation as `'b3 single header'`; v6+ reuses `'b3'` for it. const B3_SINGLE_STYLE = DD_MAJOR >= 6 ? 'b3' : 'b3 single header' const injectCh = channel('dd-trace:span:inject') @@ -78,6 +78,13 @@ describe('TextMapPropagator', () => { baggageItems = {} }) + it('should expose only the propagation boundary', () => { + assert.deepStrictEqual( + Object.getOwnPropertyNames(TextMapPropagator.prototype), + ['constructor', 'inject', 'extract'] + ) + }) + describe('inject', () => { beforeEach(() => { removeAllBaggageItems() @@ -475,7 +482,7 @@ describe('TextMapPropagator', () => { }) if (DD_MAJOR >= 6) { - it('should treat inject:["b3"] as the single-header form on v6', () => { + it('should treat inject:["b3"] as the single-header form on v6+', () => { const carrier = {} const spanContext = createContext({ traceId: id('0000000000000123'), @@ -491,7 +498,7 @@ describe('TextMapPropagator', () => { assert.ok(!('x-b3-traceid' in carrier)) }) - it('should treat inject:["b3multi"] as the multi-header form on v6', () => { + it('should treat inject:["b3multi"] as the multi-header form on v6+', () => { const carrier = {} const spanContext = createContext({ traceId: id('0000000000000123'), @@ -508,7 +515,7 @@ describe('TextMapPropagator', () => { assert.ok(!('b3' in carrier)) }) - it('should treat inject:["b3 single header"] as the single-header form on v6', () => { + it('should treat inject:["b3 single header"] as the single-header form on v6+', () => { const carrier = {} const spanContext = createContext({ traceId: id('0000000000000123'), @@ -740,6 +747,31 @@ describe('TextMapPropagator', () => { assert.strictEqual(spanContext._isRemote, true) }) + it('should extract legacy baggage for a tracecontext winner', () => { + config.tracePropagationStyle.extract = ['tracecontext'] + const carrier = { + traceparent: '00-0000000000000000000000000000007b-0000000000000456-01', + 'ot-baggage-foo': 'bar', + } + + const spanContext = propagator.extract(carrier) + + assert.deepStrictEqual(spanContext._baggageItems, { foo: 'bar' }) + }) + + it('should not extract legacy baggage for a B3 winner', () => { + config.tracePropagationStyle.extract = ['b3multi'] + const carrier = { + 'x-b3-traceid': '0000000000000123', + 'x-b3-spanid': '0000000000000456', + 'ot-baggage-foo': 'bar', + } + + const spanContext = propagator.extract(carrier) + + assert.deepStrictEqual(spanContext._baggageItems, {}) + }) + it('should extract otel baggage items with special characters', () => { config = getConfigFresh() propagator = new TextMapPropagator(config) @@ -814,6 +846,18 @@ describe('TextMapPropagator', () => { assert.deepStrictEqual(getAllBaggageItems(), { name: 'test value' }) }) + it('should skip empty members and preserve equals signs in baggage values', () => { + const carrier = { + 'x-datadog-trace-id': '123', + 'x-datadog-parent-id': '456', + baggage: ', ,;prop=1,foo=a=b;prop=1,,bar=baz,', + } + + propagator.extract(carrier) + + assert.deepStrictEqual(getAllBaggageItems(), { foo: 'a=b', bar: 'baz' }) + }) + it('should add baggage items to span tags', () => { // should add baggage with default keys let carrier = { @@ -963,6 +1007,25 @@ describe('TextMapPropagator', () => { assert.deepStrictEqual(getAllBaggageItems(), {}) }) + it('should extract baggage on either side of the first matching propagation style', () => { + const carrier = { + 'x-datadog-trace-id': '123', + 'x-datadog-parent-id': '456', + baggage: 'foo=bar', + } + config.DD_TRACE_PROPAGATION_EXTRACT_FIRST = true + + for (const extract of [['baggage', 'datadog'], ['datadog', 'baggage']]) { + removeAllBaggageItems() + config.tracePropagationStyle.extract = extract + + const spanContext = propagator.extract(carrier) + + assert.strictEqual(spanContext.toTraceId(), '123') + assert.deepStrictEqual(getAllBaggageItems(), { foo: 'bar' }) + } + }) + it('should convert signed IDs to unsigned', () => { textMap['x-datadog-trace-id'] = '-123' textMap['x-datadog-parent-id'] = '-456' @@ -1100,6 +1163,37 @@ describe('TextMapPropagator', () => { })) }) + it('should extract matching tracecontext state from an aws-sqsd header', () => { + const carrier = { + 'x-aws-sqsd-attr-_datadog': JSON.stringify({ + 'x-datadog-trace-id': '123', + 'x-datadog-parent-id': '456', + traceparent: '00-0000000000000000000000000000007b-0000000000000456-01', + tracestate: 'other=value', + }), + } + + const spanContext = propagator.extract(carrier) + + assert.strictEqual(spanContext._tracestate.get('other'), 'value') + }) + + it('should not extract tracecontext state from an aws-sqsd header when configured to extract first', () => { + const carrier = { + 'x-aws-sqsd-attr-_datadog': JSON.stringify({ + 'x-datadog-trace-id': '123', + 'x-datadog-parent-id': '456', + traceparent: '00-0000000000000000000000000000007b-0000000000000456-01', + tracestate: 'other=value', + }), + } + config.DD_TRACE_PROPAGATION_EXTRACT_FIRST = true + + const spanContext = propagator.extract(carrier) + + assert.strictEqual(spanContext._tracestate, undefined) + }) + it('should return null for an aws-sqsd header that parses to null', () => { assert.strictEqual(propagator.extract({ 'x-aws-sqsd-attr-_datadog': 'null' }), null) }) @@ -1241,7 +1335,7 @@ describe('TextMapPropagator', () => { it('should always extract tracestate from tracecontext when trace IDs match', () => { textMap.traceparent = '00-0000000000000000000000000000007B-0000000000000456-01' textMap.tracestate = 'other=bleh,dd=t.foo_bar_baz_:abc_!@#$%^&*()_+`-~;s:2;o:foo;t.dm:-4' - config.tracePropagationStyle.extract = ['datadog', 'tracecontext'] + config.tracePropagationStyle.extract = ['datadog'] const carrier = textMap const spanContext = propagator.extract(carrier) @@ -1249,6 +1343,52 @@ describe('TextMapPropagator', () => { assert.strictEqual(spanContext._tracestate.get('other'), 'bleh') }) + it('should read tracecontext once while resolving multiple propagation styles', () => { + for (const extract of [['datadog', 'tracecontext'], ['tracecontext', 'datadog']]) { + let reads = 0 + const carrier = { ...textMap } + Object.defineProperty(carrier, 'traceparent', { + get () { + reads++ + return '00-0000000000000000000000000000007B-0000000000000456-01' + }, + }) + config.tracePropagationStyle.extract = extract + + propagator.extract(carrier) + + assert.strictEqual(reads, 1) + } + }) + + it('should reuse the Datadog context while resolving tracecontext conflicts', () => { + let traceIdReads = 0 + let parentIdReads = 0 + const carrier = { + ...textMap, + traceparent: '00-0000000000000000000000000000007b-0000000000000456-01', + } + Object.defineProperty(carrier, 'x-datadog-trace-id', { + get () { + traceIdReads++ + return '123' + }, + }) + Object.defineProperty(carrier, 'x-datadog-parent-id', { + get () { + parentIdReads++ + return '456' + }, + }) + config.tracePropagationStyle.extract = ['datadog', 'tracecontext'] + + const spanContext = propagator.extract(carrier) + + assert.strictEqual(traceIdReads, 1) + assert.strictEqual(parentIdReads, 1) + assert.strictEqual(spanContext._trace.tags['_dd.parent_id'], '00000000000001c8') + }) + it('should extract the last datadog parent id from tracestate when p dd member is availible', () => { textMap.traceparent = '00-0000000000000000000000000000007B-0000000000000456-01' textMap.tracestate = 'other=bleh,dd=s:2;o:foo;p:2244eeee6666aaaa' @@ -1309,15 +1449,22 @@ describe('TextMapPropagator', () => { }) it('should not extract tracestate from tracecontext when configured to extract first', () => { - textMap.traceparent = '00-0000000000000000000000000000007B-0000000000000456-01' - textMap.tracestate = 'other=bleh,dd=t.foo_bar_baz_:abc_!@#$%^&*()_+`-~;s:2;o:foo;t.dm:-4' + let reads = 0 + const carrier = { ...textMap } + Object.defineProperty(carrier, 'traceparent', { + get () { + reads++ + return '00-0000000000000000000000000000007B-0000000000000456-01' + }, + }) + carrier.tracestate = 'other=bleh,dd=t.foo_bar_baz_:abc_!@#$%^&*()_+`-~;s:2;o:foo;t.dm:-4' config.tracePropagationStyle.extract = ['datadog', 'tracecontext'] config.DD_TRACE_PROPAGATION_EXTRACT_FIRST = true - const carrier = textMap const spanContext = propagator.extract(carrier) assert.strictEqual(spanContext._tracestate, undefined) + assert.strictEqual(reads, 0) }) it('extracts span_id from tracecontext headers and stores datadog parent-id in trace_distributed_tags', () => { @@ -1659,29 +1806,30 @@ describe('TextMapPropagator', () => { }) }) - // v6 routes `'b3'` to the single-header path regardless of source, so the v5-only - // dispatch-by-source distinction tested below has nothing left to assert on v6. - const describeOrSkip = DD_MAJOR < 6 ? describe : describe.skip - describeOrSkip('with B3 propagation from DD_TRACE_PROPAGATION_STYLE', () => { - beforeEach(() => { - config.tracePropagationStyle.extract = ['b3'] - config.getOrigin = sinon.stub().withArgs('tracePropagationStyle.extract').returns('env_var') - - delete textMap['x-datadog-trace-id'] - delete textMap['x-datadog-parent-id'] - - TextMapPropagator = proxyquire('../../../src/opentracing/propagation/text_map', { + describe('with the v5 B3 propagation style', () => { + /** @param {string} envName */ + function createV5Propagator (envName) { + const V5TextMapPropagator = proxyquire('../../../src/opentracing/propagation/text_map', { '../../config/helper': { - getConfiguredEnvName: sinon.stub().withArgs('DD_TRACE_PROPAGATION_STYLE') - .returns('DD_TRACE_PROPAGATION_STYLE'), + getConfiguredEnvName: sinon.stub().withArgs('DD_TRACE_PROPAGATION_STYLE').returns(envName), }, '../../log': log, '../../telemetry/metrics': telemetryMetrics, + '../../../../../version': { + DD_MAJOR: 5, + '@noCallThru': true, + }, }) - propagator = new TextMapPropagator(config) - }) + + config.tracePropagationStyle.extract = ['b3'] + delete textMap['x-datadog-trace-id'] + delete textMap['x-datadog-parent-id'] + + return new V5TextMapPropagator(config) + } it('should extract B3 as multiple headers', () => { + propagator = createV5Propagator('DD_TRACE_PROPAGATION_STYLE') textMap['x-b3-traceid'] = '0000000000000123' textMap['x-b3-spanid'] = '0000000000000456' textMap['x-b3-sampled'] = '1' @@ -1696,28 +1844,9 @@ describe('TextMapPropagator', () => { }, })) }) - }) - - describe('with B3 propagation from OTEL_PROPAGATORS', () => { - beforeEach(() => { - config.tracePropagationStyle.extract = ['b3'] - config.getOrigin = sinon.stub().withArgs('tracePropagationStyle.extract').returns('env_var') - - delete textMap['x-datadog-trace-id'] - delete textMap['x-datadog-parent-id'] - - TextMapPropagator = proxyquire('../../../src/opentracing/propagation/text_map', { - '../../config/helper': { - getConfiguredEnvName: sinon.stub().withArgs('DD_TRACE_PROPAGATION_STYLE') - .returns('OTEL_PROPAGATORS'), - }, - '../../log': log, - '../../telemetry/metrics': telemetryMetrics, - }) - propagator = new TextMapPropagator(config) - }) it('should extract B3 as a single header', () => { + propagator = createV5Propagator('OTEL_PROPAGATORS') textMap.b3 = '0000000000000123-0000000000000456-1' const spanContext = propagator.extract(textMap) @@ -1730,6 +1859,23 @@ describe('TextMapPropagator', () => { }, })) }) + + it('should inject the legacy B3 style as multiple headers', () => { + propagator = createV5Propagator('DD_TRACE_PROPAGATION_STYLE') + config.tracePropagationStyle.inject = ['b3'] + const carrier = {} + + propagator.inject(createContext({ + traceId: id('123', 16), + spanId: id('456', 16), + sampling: { priority: AUTO_KEEP }, + }), carrier) + + assert.strictEqual(carrier['x-b3-traceid'], '0000000000000123') + assert.strictEqual(carrier['x-b3-spanid'], '0000000000000456') + assert.strictEqual(carrier['x-b3-sampled'], '1') + assert.strictEqual(carrier.b3, undefined) + }) }) describe('with B3 propagation as a single header', () => { @@ -2277,18 +2423,18 @@ describe('TextMapPropagator', () => { testPropagator = new TextMapPropagator(config) }) - it('returns undefined without throwing when the b3 single-header carrier is empty', () => { - assert.strictEqual(testPropagator._extractB3SingleContext({}), undefined) + it('returns null without throwing when the b3 carrier is empty', () => { + assert.strictEqual(testPropagator.extract({}), null) }) - it('returns undefined when the b3 single header is present but not a string', () => { - assert.strictEqual(testPropagator._extractB3SingleContext({ b3: 123 }), undefined) - assert.strictEqual(testPropagator._extractB3SingleContext({ b3: undefined }), undefined) - assert.strictEqual(testPropagator._extractB3SingleContext({ b3: [123] }), undefined) + it('returns null when the b3 single header is present but not a string', () => { + assert.strictEqual(testPropagator.extract({ b3: 123 }), null) + assert.strictEqual(testPropagator.extract({ b3: undefined }), null) + assert.strictEqual(testPropagator.extract({ b3: [123] }), null) }) it('resolves a repeated b3 single header to the last field the sender wrote', () => { - const context = testPropagator._extractB3SingleContext({ + const context = testPropagator.extract({ b3: ['1111aaaa2222bbbb-3333cccc4444dddd-1', '5555eeee6666ffff-7777aaaa8888bbbb-1'], }) @@ -2297,7 +2443,7 @@ describe('TextMapPropagator', () => { }) it('still parses a real b3 single header', () => { - const context = testPropagator._extractB3SingleContext({ + const context = testPropagator.extract({ b3: '1111aaaa2222bbbb-3333cccc4444dddd-1', }) @@ -2305,45 +2451,46 @@ describe('TextMapPropagator', () => { assert.strictEqual(context.toSpanId(true), '3333cccc4444dddd') }) - it('returns undefined without allocating when the b3-multi carrier carries no b3 header', () => { - assert.strictEqual(testPropagator._extractB3MultipleHeaders({}), undefined) - assert.strictEqual(testPropagator._extractB3MultipleHeaders({ 'x-b3-parentspanid': 'ignored' }), undefined) + it('returns null for an all-zero b3 trace ID', () => { + assert.strictEqual(testPropagator.extract({ + b3: '0000000000000000-3333cccc4444dddd', + }), null) + }) + + it('returns null when the b3-multi carrier carries no b3 header', () => { + assert.strictEqual(testPropagator.extract({}), null) + assert.strictEqual(testPropagator.extract({ 'x-b3-parentspanid': 'ignored' }), null) }) it('still extracts when only the b3 sampled flag is present', () => { - const b3 = testPropagator._extractB3MultipleHeaders({ 'x-b3-sampled': '1' }) + const context = testPropagator.extract({ 'x-b3-sampled': '1' }) - assert.deepStrictEqual(b3, { sampled: '1' }) + assert.strictEqual(context._sampling.priority, AUTO_KEEP) }) it('resolves repeated b3-multi fields to the last ones', () => { - const b3 = testPropagator._extractB3MultipleHeaders({ + const context = testPropagator.extract({ 'x-b3-traceid': ['1111aaaa2222bbbb', '5555eeee6666ffff'], 'x-b3-spanid': ['3333cccc4444dddd', '7777aaaa8888bbbb'], 'x-b3-sampled': ['0', '1'], 'x-b3-flags': ['0', '1'], }) - assert.deepStrictEqual(b3, { - traceId: '5555eeee6666ffff', - spanId: '7777aaaa8888bbbb', - sampled: '1', - flags: '1', - }) + assert.strictEqual(context.toTraceId(true), '0000000000000000' + '5555eeee6666ffff') + assert.strictEqual(context.toSpanId(true), '7777aaaa8888bbbb') + assert.strictEqual(context._sampling.priority, USER_KEEP) }) it('still extracts a full b3-multi carrier', () => { - const b3 = testPropagator._extractB3MultipleHeaders({ + const context = testPropagator.extract({ 'x-b3-traceid': '1111aaaa2222bbbb', 'x-b3-spanid': '3333cccc4444dddd', 'x-b3-sampled': '1', }) - assert.deepStrictEqual(b3, { - traceId: '1111aaaa2222bbbb', - spanId: '3333cccc4444dddd', - sampled: '1', - }) + assert.strictEqual(context.toTraceId(true), '0000000000000000' + '1111aaaa2222bbbb') + assert.strictEqual(context.toSpanId(true), '3333cccc4444dddd') + assert.strictEqual(context._sampling.priority, AUTO_KEEP) }) }) @@ -2352,36 +2499,35 @@ describe('TextMapPropagator', () => { // `key.match(/^ot-baggage-(.+)$/)` against every header on every traced // request. The cheap `startsWith` prefilter skips the regex (and the // match-object alloc on hits) without changing observable extraction. - let baggageContext - - beforeEach(() => { - baggageContext = createContext() - }) + /** @param {Record} carrier */ + function extractLegacyBaggage (carrier) { + carrier['x-datadog-trace-id'] = '123' + carrier['x-datadog-parent-id'] = '456' + return propagator.extract(carrier)._baggageItems + } it('skips keys that do not start with ot-baggage-', () => { - propagator._extractLegacyBaggageItems({ - 'x-datadog-trace-id': '123', - 'x-datadog-parent-id': '456', + const baggageItems = extractLegacyBaggage({ 'x-some-unrelated-header': 'value', - }, baggageContext) - assert.deepStrictEqual(baggageContext._baggageItems, {}) + }) + assert.deepStrictEqual(baggageItems, {}) }) it('ignores uppercase prefixes (case-sensitive)', () => { - propagator._extractLegacyBaggageItems({ + const baggageItems = extractLegacyBaggage({ 'OT-BAGGAGE-uppercase': 'ignored', 'Ot-Baggage-Mixed': 'ignored', - }, baggageContext) - assert.deepStrictEqual(baggageContext._baggageItems, {}) + }) + assert.deepStrictEqual(baggageItems, {}) }) it('extracts every ot-baggage- prefixed key', () => { - propagator._extractLegacyBaggageItems({ + const baggageItems = extractLegacyBaggage({ 'ot-baggage-foo': 'bar', 'ot-baggage-x': 'y', 'ot-baggage-multi-dash': 'still-works', - }, baggageContext) - assert.deepStrictEqual(baggageContext._baggageItems, { + }) + assert.deepStrictEqual(baggageItems, { foo: 'bar', x: 'y', 'multi-dash': 'still-works', @@ -2389,48 +2535,57 @@ describe('TextMapPropagator', () => { }) it('resolves a repeated ot-baggage- field to the last one', () => { - propagator._extractLegacyBaggageItems({ + const baggageItems = extractLegacyBaggage({ 'ot-baggage-foo': ['stale', 'current'], - }, baggageContext) - assert.deepStrictEqual(baggageContext._baggageItems, { foo: 'current' }) + }) + assert.deepStrictEqual(baggageItems, { foo: 'current' }) }) it('skips the bare ot-baggage- prefix without a suffix', () => { - propagator._extractLegacyBaggageItems({ + const baggageItems = extractLegacyBaggage({ 'ot-baggage-': 'ignored', 'ot-baggage': 'ignored', 'ot-baggage-foo': 'bar', - }, baggageContext) - assert.deepStrictEqual(baggageContext._baggageItems, { foo: 'bar' }) + }) + assert.deepStrictEqual(baggageItems, { foo: 'bar' }) }) it('skips the entire scan when legacyBaggageEnabled is false', () => { const disabledConfig = getConfigFresh({ legacyBaggageEnabled: false }) const disabledPropagator = new TextMapPropagator(disabledConfig) - disabledPropagator._extractLegacyBaggageItems({ + const context = disabledPropagator.extract({ + 'x-datadog-trace-id': '123', + 'x-datadog-parent-id': '456', 'ot-baggage-foo': 'bar', - }, baggageContext) - assert.deepStrictEqual(baggageContext._baggageItems, {}) + }) + assert.deepStrictEqual(context._baggageItems, {}) }) }) describe('extract dispatch table', () => { it('skips the warn for the silent baggage entry', () => { - propagator._config.tracePropagationStyle.extract = ['baggage'] + config.tracePropagationStyle.extract = ['baggage'] + + assert.strictEqual(propagator.extract({}), null) + sinon.assert.notCalled(log.warn) + }) + + it('skips the warn for the none propagation style', () => { + config.tracePropagationStyle.extract = ['none'] assert.strictEqual(propagator.extract({}), null) sinon.assert.notCalled(log.warn) }) it('warns once per unknown style without crashing the extract loop', () => { - propagator._config.tracePropagationStyle.extract = ['unknown_style'] + config.tracePropagationStyle.extract = ['unknown_style'] assert.strictEqual(propagator.extract({}), null) sinon.assert.calledOnceWithExactly(log.warn, 'Unknown propagation style:', 'unknown_style') }) it('continues to the next extractor when one returns undefined', () => { - propagator._config.tracePropagationStyle.extract = ['unknown_style', 'datadog'] + config.tracePropagationStyle.extract = ['unknown_style', 'datadog'] const extracted = propagator.extract({ 'x-datadog-trace-id': '123', @@ -2444,38 +2599,26 @@ describe('TextMapPropagator', () => { }) describe('b3-multi empty extraction path', () => { - it('returns undefined when an empty b3-sampled value defeats the fast-path guard', () => { - const b3 = propagator._extractB3MultipleHeaders({ 'x-b3-sampled': '' }) + beforeEach(() => { + config.tracePropagationStyle.extract = ['b3multi'] + }) - assert.strictEqual(b3, undefined) + it('returns null when an empty b3-sampled value defeats the fast-path guard', () => { + assert.strictEqual(propagator.extract({ 'x-b3-sampled': '' }), null) }) - it('returns undefined when invalid trace/span ids pair with a falsy sampled value', () => { - const b3 = propagator._extractB3MultipleHeaders({ + it('returns null when invalid trace/span ids pair with a falsy sampled value', () => { + const context = propagator.extract({ 'x-b3-traceid': 'not-hex', 'x-b3-spanid': 'not-hex', 'x-b3-sampled': '', }) - assert.strictEqual(b3, undefined) - }) - - it('_extractB3MultiContext returns undefined when the carrier produces no usable b3 fields', () => { - const context = propagator._extractB3MultiContext({ 'x-b3-sampled': '' }) - - assert.strictEqual(context, undefined) + assert.strictEqual(context, null) }) }) describe('SQSD carrier with invalid JSON', () => { - it('returns undefined from _extractSqsdContext on malformed JSON', () => { - const context = propagator._extractSqsdContext({ - 'x-aws-sqsd-attr-_datadog': '{not valid json', - }) - - assert.strictEqual(context, undefined) - }) - it('extract() returns null when the SQSD header carries malformed JSON', () => { const extracted = propagator.extract({ 'x-aws-sqsd-attr-_datadog': '{not valid json', diff --git a/packages/dd-trace/test/opentracing/span.spec.js b/packages/dd-trace/test/opentracing/span.spec.js index 43e2aaa8dbc..f767eb6a470 100644 --- a/packages/dd-trace/test/opentracing/span.spec.js +++ b/packages/dd-trace/test/opentracing/span.spec.js @@ -27,6 +27,7 @@ describe('Span', () => { let now let id let tagger + let log beforeEach(() => { sinon.stub(Date, 'now').returns(1500000000000) @@ -51,6 +52,10 @@ describe('Span', () => { add: sinon.spy(), } + log = { + warn: sinon.spy(), + } + Span = proxyquire('../../src/opentracing/span', { perf_hooks: { performance: { @@ -58,6 +63,7 @@ describe('Span', () => { }, }, '../id': id, + '../log': log, '../tagger': tagger, }) }) @@ -403,6 +409,46 @@ describe('Span', () => { ] assert.deepStrictEqual(events, expectedEvents) }) + + it('should record exceptions as events', () => { + span = new Span(tracer, processor, prioritySampler, { operationName: 'operation' }) + const error = new TypeError('payment declined') + + span.recordException(error, { + handled: true, + 'exception.type': 'PaymentError', + 'exception.message': 'redacted', + 'exception.stacktrace': 'redacted', + }) + + assert.deepStrictEqual(span._events, [{ + name: 'exception', + attributes: { + 'exception.type': 'PaymentError', + 'exception.message': 'redacted', + 'exception.stacktrace': 'redacted', + handled: true, + }, + startTime: 1500000000000, + }]) + assert.strictEqual(span.context().getTag('error'), undefined) + assert.strictEqual(span.context().getTag('error.type'), undefined) + }) + + it('should record exception objects without optional fields', () => { + span = new Span(tracer, processor, prioritySampler, { operationName: 'operation' }) + + span.recordException({ message: 'payment declined' }) + + assert.deepStrictEqual(span._events, [{ + name: 'exception', + attributes: { + 'exception.message': 'payment declined', + }, + startTime: 1500000000000, + }]) + sinon.assert.notCalled(log.warn) + }) }) describe('empty event and link attributes (end to end)', () => { @@ -460,6 +506,34 @@ describe('Span', () => { assert.ok('attributes' in events[2], 'kept event attributes must be present') }) } + + it('encodes recorded exceptions without marking the span as errored', () => { + const error = new TypeError('payment declined') + const exceptionSpan = new RealSpan(tracer, processor, prioritySampler, { operationName: 'operation' }) + exceptionSpan.recordException(error, { handled: true }) + exceptionSpan.finish() + + const formatted = format(exceptionSpan) + assert.strictEqual(formatted.error, 0) + + const { AgentEncoder } = proxyquire('../../src/encode/0.4', { + '../config': () => ({ DD_TRACE_NATIVE_SPAN_EVENTS: false }), + }) + const encoder = new AgentEncoder({ flush () {} }) + encoder.encode([formatted]) + + const encoded = msgpack.decode(encoder.makePayload(), { useBigInt64: true })[0][0] + const events = JSON.parse(encoded.meta.events) + assert.strictEqual(events.length, 1) + assert.strictEqual(events[0].name, 'exception') + assert.strictEqual(typeof events[0].time_unix_nano, 'number') + assert.deepStrictEqual(events[0].attributes, { + 'exception.type': 'TypeError', + 'exception.message': 'payment declined', + 'exception.stacktrace': error.stack, + handled: true, + }) + }) }) describe('getBaggageItem', () => { diff --git a/packages/dd-trace/test/otel-thread-ctx.spec.js b/packages/dd-trace/test/otel-thread-ctx.spec.js index d150813f6d9..08139f4e58e 100644 --- a/packages/dd-trace/test/otel-thread-ctx.spec.js +++ b/packages/dd-trace/test/otel-thread-ctx.spec.js @@ -107,7 +107,14 @@ describe('otel-thread-ctx', () => { constructedContexts.push(this) } - appendAttributes () {} + // Applied to `attributes` with the record's last-wins handling of duplicate + // keys, so a test can assert the endpoint an out-of-process reader would + // read out of the record, not merely the calls the writer made. + appendAttributes (appended) { + for (const [index, value] of appended.entries()) { + if (value !== undefined) this.attributes[index] = value + } + } invalidate () {} @@ -596,6 +603,119 @@ describe('otel-thread-ctx', () => { assert.equal(context.appendAttributes.firstCall.args[0][1], 'GET /x') }) + it('appends the endpoint to a descendant record built before the ancestry existed', () => { + // The record was built while the span had no web-server ancestry at all, so + // it never enlisted for a request. webTagsCache announces the descendant + // too when the promotion changes its answer, which is what fills the hole — + // a span in the middle of synchronous work never re-enters storage. + const webTags = { 'span.type': 'web', 'http.method': 'GET', 'http.route': '/x' } + activeSpan = makeSpan({ spanId: '2122232425262728', parentId: SPAN_ID_HEX, tags: {} }) + enterCh.publish() + const context = constructedContexts[0] + assert.strictEqual(context.attributes[1], undefined) + + cachedWebTags.set(activeSpan, webTags) + webTagsResolvedCh.publish(activeSpan) + sinon.assert.calledOnce(context.appendAttributes) + assert.equal(context.appendAttributes.firstCall.args[0][1], 'GET /x') + assert.equal(constructedContexts.length, 1) + }) + + it('waits for the endpoint when a descendant gains an ancestry before the route', () => { + // The announcement finds an ancestor but no settled endpoint yet, so the + // record has to join the request's waiting list — the endpoint announcement + // then names the ancestor, whose tag bag is the key it is waiting under. + const { parent, child, webTags } = makeWebSpanWithChild({ 'span.type': 'web', 'http.method': 'GET' }) + activeSpan = child + enterCh.publish() + const context = constructedContexts[0] + assert.strictEqual(context.attributes[1], undefined) + + cachedWebTags.set(child, webTags) + webTagsResolvedCh.publish(child) + sinon.assert.notCalled(context.appendAttributes) + + webTags['http.route'] = '/x' + endpointResolvedCh.publish(parent) + sinon.assert.calledOnce(context.appendAttributes) + assert.equal(context.appendAttributes.firstCall.args[0][1], 'GET /x') + }) + + it('appends once when the same web-tags resolution is announced twice', () => { + const webTags = { 'span.type': 'web', 'http.method': 'GET', 'http.route': '/x' } + activeSpan = makeSpan({ tags: {} }) + enterCh.publish() + const context = constructedContexts[0] + + cachedWebTags.set(activeSpan, webTags) + webTagsResolvedCh.publish(activeSpan) + webTagsResolvedCh.publish(activeSpan) + sinon.assert.calledOnce(context.appendAttributes) + assert.equal(context.appendAttributes.firstCall.args[0][1], 'GET /x') + }) + + it('repoints a record at a nearer web-server span', () => { + // Nested request handling: the record was attributed to the outer request, + // then an intermediate span became a web-server span of its own. The inner + // endpoint is the one this span's work belongs to from now on, and the outer + // request's own announcement no longer applies to this record. + const outerTags = { 'span.type': 'web', 'http.method': 'GET' } + const outerSpan = makeSpan({ tags: outerTags }) + const innerTags = { 'span.type': 'web', 'http.method': 'GET', 'http.route': '/inner' } + activeSpan = makeSpan({ spanId: '2122232425262728', parentId: SPAN_ID_HEX, tags: {} }) + cachedWebTags.set(activeSpan, outerTags) + enterCh.publish() + const context = constructedContexts[0] + assert.strictEqual(context.attributes[1], undefined) + + cachedWebTags.set(activeSpan, innerTags) + webTagsResolvedCh.publish(activeSpan) + assert.equal(context.attributes[1], 'GET /inner') + + outerTags['http.route'] = '/outer' + endpointResolvedCh.publish(outerSpan) + sinon.assert.calledOnce(context.appendAttributes) + assert.equal(context.attributes[1], 'GET /inner') + }) + + it('keeps showing the outer endpoint until a nearer request settles its own', () => { + // The unavoidable window: the record already carries the outer request's + // settled endpoint, and the nearer web-server span that supersedes it has + // no route yet. The record buffer is append-only — there is no way to take + // an attribute back — and rebuilding the ThreadContext would strand every + // async-context frame already holding this one. So the outer endpoint, the + // request this work is still nested in, stands until the inner one settles. + const outerTags = { 'span.type': 'web', 'http.method': 'GET', 'http.route': '/outer' } + const innerTags = { 'span.type': 'web', 'http.method': 'GET' } + activeSpan = makeSpan({ spanId: '2122232425262728', parentId: SPAN_ID_HEX, tags: {} }) + cachedWebTags.set(activeSpan, outerTags) + enterCh.publish() + const context = constructedContexts[0] + assert.equal(context.attributes[1], 'GET /outer') + + cachedWebTags.set(activeSpan, innerTags) + webTagsResolvedCh.publish(activeSpan) + assert.equal(context.attributes[1], 'GET /outer') + + innerTags['http.route'] = '/inner' + endpointResolvedCh.publish(makeSpan({ tags: innerTags })) + assert.equal(context.attributes[1], 'GET /inner') + }) + + it('does not query the cache again on re-entry', () => { + // Every answer change is announced, so a record that already has its + // ancestry never asks again — re-entry is the hottest path there is. + const webTags = { 'span.type': 'web', 'http.method': 'GET', 'http.route': '/x' } + activeSpan = makeSpan({ tags: {} }) + enterCh.publish() + cachedWebTags.set(activeSpan, webTags) + webTagsResolvedCh.publish(activeSpan) + const lookups = sinon.spy(webTagsCacheStub, 'getCachedWebTags') + enterCh.publish() + enterCh.publish() + sinon.assert.notCalled(lookups) + }) + it('endpoint announcements are a no-op for a span that has not been entered', () => { const { parent, webTags } = makeWebSpanWithChild( { 'span.type': 'web', 'http.method': 'GET', 'http.route': '/x' }) diff --git a/packages/dd-trace/test/plugins/agent.js b/packages/dd-trace/test/plugins/agent.js index aea8ab6da4d..b23f24d1d87 100644 --- a/packages/dd-trace/test/plugins/agent.js +++ b/packages/dd-trace/test/plugins/agent.js @@ -156,6 +156,13 @@ function waitForExporterTransition (socket) { }) } +/** + * @param {import('node:net').Socket} socket + */ +function waitForSocketClose (socket) { + return new Promise(resolve => socket.once('close', resolve)) +} + /** * @param {string} origin */ @@ -931,7 +938,7 @@ module.exports = { await waitForExporterIdle(origin) const exporterSockets = httpAgent.freeSockets[origin] ?? [] - const exporterSocketsClosed = exporterSockets.map(socket => once(socket, 'close')) + const exporterSocketsClosed = exporterSockets.map(waitForSocketClose) await Promise.all([serverClosed, ...exporterSocketsClosed]) this.server = null diff --git a/packages/dd-trace/test/plugins/agent.spec.js b/packages/dd-trace/test/plugins/agent.spec.js index 77941fa8c80..fc020f59ed7 100644 --- a/packages/dd-trace/test/plugins/agent.spec.js +++ b/packages/dd-trace/test/plugins/agent.spec.js @@ -62,6 +62,43 @@ describe('test agent helper', () => { assert.strictEqual(origin in httpAgent.requests, false) }) + it('finishes closing when an exporter socket resets', async () => { + const tracer = await agent.load([]) + const origin = httpAgent.getName({ host: '127.0.0.1', port: agent.port }) + const traceReceived = agent.assertSomeTraces(() => {}) + + tracer.trace('test', () => {}) + await traceReceived + + while (!(origin in httpAgent.freeSockets)) { + await once(httpAgent, 'free') + } + + const [exporterSocket] = httpAgent.freeSockets[origin] + const resetError = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }) + const resetObserved = once(exporterSocket, 'error') + + /** + * @param {string} event + */ + function resetAfterCloseListener (event) { + if (event !== 'close') return + + exporterSocket.removeListener('newListener', resetAfterCloseListener) + queueMicrotask(() => { + exporterSocket.emit('error', resetError) + exporterSocket.destroy() + }) + } + exporterSocket.on('newListener', resetAfterCloseListener) + + const [, [observedReset]] = await Promise.all([agent.close(), resetObserved]) + assert.strictEqual(observedReset, resetError) + assert.strictEqual(origin in httpAgent.sockets, false) + assert.strictEqual(origin in httpAgent.freeSockets, false) + assert.strictEqual(origin in httpAgent.requests, false) + }) + it('finishes closing with active and queued exporter requests', async () => { const remoteConfigurationEnabled = process.env.DD_REMOTE_CONFIGURATION_ENABLED const telemetryEnabled = process.env.DD_INSTRUMENTATION_TELEMETRY_ENABLED diff --git a/packages/dd-trace/test/plugins/externals.js b/packages/dd-trace/test/plugins/externals.js index 6d805b1e37f..dd6772a121f 100644 --- a/packages/dd-trace/test/plugins/externals.js +++ b/packages/dd-trace/test/plugins/externals.js @@ -472,6 +472,11 @@ module.exports = { name: 'mariadb', versions: ['2.5.6', '3.0.0', '3.4.0', '3.4.5', '3.5.1', '3.5.2'], }, + { + name: 'mariadb', + versions: ['3.5.3'], + node: '>=20', + }, ], mercurius: [ { diff --git a/packages/dd-trace/test/plugins/util/status-validator.spec.js b/packages/dd-trace/test/plugins/util/status-validator.spec.js new file mode 100644 index 00000000000..299b1f8c2bf --- /dev/null +++ b/packages/dd-trace/test/plugins/util/status-validator.spec.js @@ -0,0 +1,121 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it } = require('mocha') + +require('../../setup/core') + +const { getClientStatusValidator, getServerStatusValidator } = require('../../../src/plugins/util/status-validator') + +describe('plugins/util/status-validator', () => { + describe('getClientStatusValidator', () => { + it('should mark 4xx as errors by default', () => { + const validateStatus = getClientStatusValidator({}) + + assert.strictEqual(validateStatus(399), true) + assert.strictEqual(validateStatus(400), false) + assert.strictEqual(validateStatus(499), false) + assert.strictEqual(validateStatus(500), true) + }) + + it('should mark configured HTTP client status codes as errors', () => { + const validateStatus = getClientStatusValidator({ + DD_TRACE_HTTP_CLIENT_ERROR_STATUSES: ' 200 - 201, 202, 250-249 ', + }) + + assert.strictEqual(validateStatus(199), true) + assert.strictEqual(validateStatus(200), false) + assert.strictEqual(validateStatus(201), false) + assert.strictEqual(validateStatus(202), false) + assert.strictEqual(validateStatus(203), true) + assert.strictEqual(validateStatus(248), true) + assert.strictEqual(validateStatus(249), false) + assert.strictEqual(validateStatus(250), false) + assert.strictEqual(validateStatus(251), true) + assert.strictEqual(validateStatus(400), true) + }) + + it('should combine overlapping HTTP client status code ranges', () => { + const validateStatus = getClientStatusValidator({ + DD_TRACE_HTTP_CLIENT_ERROR_STATUSES: '250-300,420,240-260', + }) + + assert.strictEqual(validateStatus(239), true) + assert.strictEqual(validateStatus(240), false) + assert.strictEqual(validateStatus(255), false) + assert.strictEqual(validateStatus(420), false) + }) + + it('should only configure valid HTTP status codes', () => { + const validateStatus = getClientStatusValidator({ DD_TRACE_HTTP_CLIENT_ERROR_STATUSES: '100,599' }) + + assert.strictEqual(validateStatus(100), false) + assert.strictEqual(validateStatus(599), false) + }) + + for (const value of ['400-499', ' 400 - 499 ']) { + it(`should use the default matcher for the explicit default range ${JSON.stringify(value)}`, () => { + const validateStatus = getClientStatusValidator({ DD_TRACE_HTTP_CLIENT_ERROR_STATUSES: value }) + + assert.strictEqual(validateStatus(399), true) + assert.strictEqual(validateStatus(400), false) + assert.strictEqual(validateStatus(499), false) + assert.strictEqual(validateStatus(500), true) + }) + } + + for (const value of ['', '99', '600', '99-100', '599-600', '600-599', '200,,202', '200-', 400]) { + it(`should use the default HTTP client error statuses for ${JSON.stringify(value)}`, () => { + const validateStatus = getClientStatusValidator({ DD_TRACE_HTTP_CLIENT_ERROR_STATUSES: value }) + + assert.strictEqual(validateStatus(399), true) + assert.strictEqual(validateStatus(400), false) + assert.strictEqual(validateStatus(499), false) + assert.strictEqual(validateStatus(500), true) + }) + } + + it('should prefer a `validateStatus` function over the configured statuses', () => { + const validateStatus = getClientStatusValidator({ + DD_TRACE_HTTP_CLIENT_ERROR_STATUSES: '200', + validateStatus: code => code === 200, + }) + + assert.strictEqual(validateStatus(200), true) + assert.strictEqual(validateStatus(400), false) + }) + + it('should apply configured HTTP client error statuses when validateStatus is invalid', () => { + const validateStatus = getClientStatusValidator({ + DD_TRACE_HTTP_CLIENT_ERROR_STATUSES: '200', + validateStatus: true, + }) + + assert.strictEqual(validateStatus(200), false) + assert.strictEqual(validateStatus(400), true) + }) + }) + + describe('getServerStatusValidator', () => { + it('should mark 5xx as errors by default', () => { + const validateStatus = getServerStatusValidator({}) + + assert.strictEqual(validateStatus(400), true) + assert.strictEqual(validateStatus(499), true) + assert.strictEqual(validateStatus(500), false) + assert.strictEqual(validateStatus(599), false) + }) + + it('should not be affected by the client error statuses', () => { + const validateStatus = getServerStatusValidator({ + DD_TRACE_HTTP_CLIENT_ERROR_STATUSES: '200', + DD_TRACE_HTTP_SERVER_ERROR_STATUSES: '201', + }) + + assert.strictEqual(validateStatus(200), true) + assert.strictEqual(validateStatus(201), false) + assert.strictEqual(validateStatus(500), true) + }) + }) +}) diff --git a/packages/dd-trace/test/plugins/util/test.spec.js b/packages/dd-trace/test/plugins/util/test.spec.js index 9a9189f9ee5..f1c60b05a88 100644 --- a/packages/dd-trace/test/plugins/util/test.spec.js +++ b/packages/dd-trace/test/plugins/util/test.spec.js @@ -25,6 +25,7 @@ const { getCoveredFilesFromCoverage, getExecutableFilesFromCoverage, getLineCoverageBitmap, + getTestCoverageLinesData, getTestCoverageLinesPercentage, applySkippedCoverageToCoverage, mergeCoverage, @@ -1517,6 +1518,24 @@ describe('coverage utils', () => { assert.strictEqual(getTestCoverageLinesPercentage(partialCoverage, skippedCoverage), 75) }) + it('calculates coverage and executable-line files together', () => { + const partialCoverage = getPartialCoverage() + const skippedCoverage = { + 'file.js': getLineCoverageBitmap({ + 2: 1, + 3: 1, + }, true).toString('base64'), + } + + assert.deepStrictEqual(getTestCoverageLinesData(partialCoverage, skippedCoverage, undefined, true), { + percentage: 75, + executableFiles: [{ + filename: 'file.js', + bitmap: Buffer.from('Hg==', 'base64'), + }], + }) + }) + it('uses rootDir to match skipped coverage to absolute coverage paths', () => { const rootDir = path.join(path.sep, 'repo') const coverage = getPartialCoverage(path.join(rootDir, 'file.js')) @@ -1530,6 +1549,25 @@ describe('coverage utils', () => { assert.strictEqual(getTestCoverageLinesPercentage(coverage, skippedCoverage, rootDir), 75) }) + it('merges coverage paths that normalize to the same file', () => { + const rootDir = path.join(path.sep, 'repo') + const filename = path.join(rootDir, 'file.js') + const alias = `${path.join(rootDir, 'sub')}${path.sep}..${path.sep}file.js` + const aliasedCoverage = getPartialCoverage(alias) + aliasedCoverage[alias].s[0] = 0 + + assert.deepStrictEqual(getTestCoverageLinesData({ + ...getPartialCoverage(filename), + ...aliasedCoverage, + }, undefined, rootDir, true), { + percentage: 25, + executableFiles: [{ + filename: 'file.js', + bitmap: Buffer.from('Hg==', 'base64'), + }], + }) + }) + it('ignores skipped coverage for files outside the executable coverage map', () => { const partialCoverage = getPartialCoverage() const skippedCoverage = { diff --git a/packages/dd-trace/test/plugins/versions/package.json b/packages/dd-trace/test/plugins/versions/package.json index 360e7c1a107..57d516cf260 100644 --- a/packages/dd-trace/test/plugins/versions/package.json +++ b/packages/dd-trace/test/plugins/versions/package.json @@ -8,22 +8,22 @@ "@ai-sdk/anthropic": "4.0.39", "@ai-sdk/google": "4.0.44", "@ai-sdk/openai": "4.0.42", - "@anthropic-ai/claude-agent-sdk": "0.3.233", + "@anthropic-ai/claude-agent-sdk": "0.3.241", "@anthropic-ai/sdk": "0.117.1", - "@apollo/gateway": "2.14.3", + "@apollo/gateway": "2.14.4", "@apollo/server": "5.5.1", - "@apollo/subgraph": "2.14.3", - "@aws/durable-execution-sdk-js": "2.2.0", + "@apollo/subgraph": "2.14.4", + "@aws/durable-execution-sdk-js": "2.3.0", "@aws/durable-execution-sdk-js-testing": "1.1.3", - "@aws-sdk/client-bedrock-runtime": "3.1111.0", - "@aws-sdk/client-dynamodb": "3.1111.0", - "@aws-sdk/client-eventbridge": "3.1111.0", - "@aws-sdk/client-kinesis": "3.1111.0", - "@aws-sdk/client-lambda": "3.1111.0", - "@aws-sdk/client-s3": "3.1111.0", - "@aws-sdk/client-sfn": "3.1111.0", - "@aws-sdk/client-sns": "3.1111.0", - "@aws-sdk/client-sqs": "3.1111.0", + "@aws-sdk/client-bedrock-runtime": "3.1116.0", + "@aws-sdk/client-dynamodb": "3.1116.0", + "@aws-sdk/client-eventbridge": "3.1116.0", + "@aws-sdk/client-kinesis": "3.1116.0", + "@aws-sdk/client-lambda": "3.1116.0", + "@aws-sdk/client-s3": "3.1116.0", + "@aws-sdk/client-sfn": "3.1116.0", + "@aws-sdk/client-sns": "3.1116.0", + "@aws-sdk/client-sqs": "3.1116.0", "@aws-sdk/node-http-handler": "3.374.0", "@aws-sdk/smithy-client": "3.374.0", "@azure/cosmos": "4.10.0", @@ -34,7 +34,7 @@ "@babel/preset-typescript": "8.0.1", "@confluentinc/kafka-javascript": "1.10.0", "@cucumber/cucumber": "13.2.1", - "@datadog/openfeature-node-server": "2.1.0", + "@datadog/openfeature-node-server": "2.2.0", "@elastic/elasticsearch": "9.5.0", "@elastic/transport": "9.4.0", "@electron/packager": "20.3.0", @@ -48,7 +48,7 @@ "@grpc/proto-loader": "0.8.1", "@hapi/boom": "10.0.1", "@hapi/hapi": "21.4.10", - "@happy-dom/jest-environment": "20.11.2", + "@happy-dom/jest-environment": "20.11.6", "@hono/node-server": "2.1.1", "@jest/core": "30.4.2", "@jest/globals": "30.4.1", @@ -86,17 +86,17 @@ "@prisma/adapter-pg": "7.9.1", "@prisma/client": "7.9.1", "@redis/client": "6.2.1", - "@smithy/core": "3.33.2", + "@smithy/core": "3.33.3", "@smithy/smithy-client": "4.15.2", "@types/node": "26.2.0", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/runner": "4.1.10", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/runner": "4.1.11", "@vscode/sqlite3": "5.1.12-vscode", - "@wdio/cli": "9.30.1", - "@wdio/jasmine-framework": "9.30.1", - "@wdio/local-runner": "9.30.1", - "@wdio/mocha-framework": "9.30.1", + "@wdio/cli": "9.31.2", + "@wdio/jasmine-framework": "9.31.2", + "@wdio/local-runner": "9.31.2", + "@wdio/mocha-framework": "9.31.2", "aerospike": "6.7.1", "ai": "7.0.66", "amqp10": "3.6.0", @@ -113,7 +113,7 @@ "body-parser": "2.3.0", "browser-bunyan": "1.8.0", "bson": "7.3.2", - "bullmq": "6.1.2", + "bullmq": "6.2.0", "bunyan": "2.0.5", "cassandra-driver": "4.9.0", "collections": "5.1.13", @@ -121,29 +121,29 @@ "cookie": "2.0.1", "cookie-parser": "1.4.7", "couchbase": "4.7.1", - "cypress": "15.20.1", + "cypress": "15.21.0", "cypress-fail-fast": "8.1.0", "dd-trace-api": "1.0.1", "durable-functions": "3.5.0", "ejs": "6.0.1", "elasticsearch": "16.7.3", - "electron": "43.4.0", + "electron": "43.4.1", "esbuild": "0.28.2", "express": "5.2.1", "express-mongo-sanitize": "2.2.0", "express-session": "1.19.0", - "fastify": "5.12.0", - "find-my-way": "9.8.0", + "fastify": "5.12.1", + "find-my-way": "9.9.0", "fs": "0.0.1-security", "generic-pool": "3.9.0", - "google-gax": "6.0.0", + "google-gax": "6.0.2", "graphql": "16.14.2", "graphql-tag": "2.12.7", "graphql-tools": "9.0.34", - "graphql-yoga": "5.21.3", + "graphql-yoga": "5.22.0", "handlebars": "4.7.9", "hapi": "18.1.0", - "hono": "4.13.2", + "hono": "4.13.3", "ioredis": "6.0.0", "iovalkey": "0.4.0", "jest": "30.4.2", @@ -179,12 +179,12 @@ "moleculer": "0.15.1", "mongodb": "7.5.0", "mongodb-core": "3.2.7", - "mongoose": "9.9.2", + "mongoose": "9.9.3", "mquery": "6.0.0", "multer": "2.2.0", "mysql": "2.18.1", - "mysql2": "3.23.3", - "next": "16.3.1", + "mysql2": "3.23.4", + "next": "16.3.2", "nock": "14.0.17", "node-18": "npm:node@18.20.8", "node-20": "npm:node@20.20.2", @@ -208,7 +208,7 @@ "pino-pretty": "13.1.3", "playwright": "1.62.1", "playwright-core": "1.62.1", - "pnpm": "11.21.0", + "pnpm": "11.22.0", "prisma": "7.9.1", "promise": "8.3.0", "promise-js": "0.0.7", @@ -227,12 +227,12 @@ "sharedb": "6.0.1", "sinon": "22.1.0", "sqlite3": "6.0.1", - "stripe": "22.4.0", + "stripe": "22.5.0", "tedious": "20.0.0", "tinypool": "2.1.0", "typescript": "7.0.2", "undici": "8.10.0", - "vitest": "4.1.10", + "vitest": "4.1.11", "when": "3.7.8", "winston": "3.19.0", "workerpool": "10.0.3", diff --git a/packages/dd-trace/test/profiling/profiler.spec.js b/packages/dd-trace/test/profiling/profiler.spec.js index 63312b9bf65..5a399ef1ee6 100644 --- a/packages/dd-trace/test/profiling/profiler.spec.js +++ b/packages/dd-trace/test/profiling/profiler.spec.js @@ -180,6 +180,35 @@ describe('profiler', function () { ) }) + it('should apply custom label keys set before start to the started profilers', async () => { + wallProfiler.setCustomLabelKeys = sinon.stub() + profiler.setCustomLabelKeys(['endpoint', 'resource']) + + await profiler.start(makeStartOptions()) + + sinon.assert.calledOnce(wallProfiler.setCustomLabelKeys) + assert.deepStrictEqual( + [...wallProfiler.setCustomLabelKeys.firstCall.args[0]], + ['endpoint', 'resource'] + ) + }) + + it('should reapply custom label keys to profilers created by a later start', async () => { + wallProfiler.setCustomLabelKeys = sinon.stub() + await profiler.start(makeStartOptions()) + profiler.setCustomLabelKeys(['endpoint', 'resource']) + profiler.stop() + + wallProfiler.setCustomLabelKeys.resetHistory() + await profiler.start(makeStartOptions()) + + sinon.assert.calledOnce(wallProfiler.setCustomLabelKeys) + assert.deepStrictEqual( + [...wallProfiler.setCustomLabelKeys.firstCall.args[0]], + ['endpoint', 'resource'] + ) + }) + it('should delegate runWithLabels to the first profiler that supports it', async () => { wallProfiler.runWithLabels = sinon.stub().callsFake((labels, fn) => fn()) await profiler.start(makeStartOptions()) diff --git a/packages/dd-trace/test/profiling/profilers/events.spec.js b/packages/dd-trace/test/profiling/profilers/events.spec.js index 3815d1b6a4f..7c45c8aa6c4 100644 --- a/packages/dd-trace/test/profiling/profilers/events.spec.js +++ b/packages/dd-trace/test/profiling/profilers/events.spec.js @@ -1,9 +1,11 @@ 'use strict' -const assert = require('node:assert') +const assert = require('node:assert/strict') +const { constants, performance } = require('node:perf_hooks') const { afterEach, describe, it } = require('mocha') const dc = require('dc-polyfill') +const proxyquire = require('proxyquire').noPreserveCache() require('../../setup/core') const { storage } = require('../../../../datadog-core') @@ -129,6 +131,50 @@ describe('profilers/events', () => { assert.equal(labels.operation, 'gzip') }) + it('joins multiple GC reasons in their reported order', () => { + let observerCallback + /** + * @param {import('node:perf_hooks').PerformanceObserverCallback} callback + */ + function PerformanceObserver (callback) { + observerCallback = callback + return { disconnect: () => {}, observe: () => {} } + } + const StubbedEventsProfiler = proxyquire('../../../src/profiling/profilers/events', { + perf_hooks: { constants, performance, PerformanceObserver }, + }) + const profiler = makeEvents(StubbedEventsProfiler, { + flushInterval: 65_000, + timelineSamplingEnabled: false, + codeHotspotsEnabled: false, + }) + const startTime = new Date() + profiler.start() + + try { + observerCallback({ + getEntries () { + return [{ + entryType: 'gc', + startTime: 0, + duration: 1, + detail: { + kind: constants.NODE_PERFORMANCE_GC_MINOR, + flags: constants.NODE_PERFORMANCE_GC_FLAGS_CONSTRUCT_RETAINED | + constants.NODE_PERFORMANCE_GC_FLAGS_FORCED, + }, + }] + }, + }) + const profile = profiler.profile(true, startTime, new Date())() + const labels = collectLabels(profile.sample[0], profile.stringTable) + + assert.equal(labels['gc reason'], 'construct_retained,forced') + } finally { + profiler.stop() + } + }) + it('captures async crypto events with per-op labels', () => { const profile = runOnceAndProfile( dc.channel('apm:crypto:operation:start'), diff --git a/packages/dd-trace/test/profiling/profilers/wall.spec.js b/packages/dd-trace/test/profiling/profilers/wall.spec.js index d33bb330cce..ce603c69609 100644 --- a/packages/dd-trace/test/profiling/profilers/wall.spec.js +++ b/packages/dd-trace/test/profiling/profilers/wall.spec.js @@ -102,7 +102,7 @@ describe('profilers/native/wall', () => { sourceMapper: undefined, withContexts: false, lineNumbers: false, - columnNumbers: 'pack', + columnNumbers: 'emit', workaroundV8Bug: false, collectCpuTime: false, useCPED: false, @@ -122,7 +122,7 @@ describe('profilers/native/wall', () => { sourceMapper: undefined, withContexts: false, lineNumbers: false, - columnNumbers: 'pack', + columnNumbers: 'emit', workaroundV8Bug: false, collectCpuTime: false, useCPED: false, @@ -252,7 +252,7 @@ describe('profilers/native/wall', () => { sourceMapper: mapper, withContexts: false, lineNumbers: false, - columnNumbers: 'pack', + columnNumbers: 'emit', workaroundV8Bug: false, collectCpuTime: false, useCPED: false, @@ -804,15 +804,19 @@ describe('profilers/native/wall', () => { function makeChildSpan (webSpanId, webSpan) { const tags = { 'span.type': 'router' } const spanId = {} + // Shares the parent's _trace, as spans of one trace chunk do: it is the + // object holding the started-spans list the parent walk reads, and what the + // shared cache scopes its per-trace bookkeeping to. + const trace = webSpan.context()._trace const ctx = { _tags: tags, _spanId: spanId, _parentId: webSpanId, - _trace: { started: [webSpan] }, + _trace: trace, getTags () { return this._tags }, } const span = { context: () => ctx } - ctx._trace.started.push(span) + trace.started.push(span) return { span, tags } } @@ -907,6 +911,63 @@ describe('profilers/native/wall', () => { profiler.stop() }) + it('should refresh a child snapshot taken before its parent became a web span (ACF path)', () => { + // The reverse order of the test below: the child is activated first, so its + // snapshot records "no web-server ancestor". It must be refreshed the moment + // the parent is promoted, without waiting for the child to be reactivated — + // a child running uninterrupted synchronous work is never reactivated, and + // that is exactly the stretch samples are taken over. + const { span: webSpan, tags: webSpanTags, spanId: webSpanId } = makeWebSpan() + const { span: childSpan } = makeChildSpan(webSpanId, webSpan) + + const profiler = makeWall(WallProfiler, { + endpointCollectionEnabled: true, + codeHotspotsEnabled: true, + asyncContextFrameEnabled: true, + }) + profiler.start() + + currentStore = { span: childSpan } + enterCh.publish() + const childCtx = localPprof.time.setContext.lastCall.args[0] + assert.strictEqual(childCtx.webTags, undefined) + + webSpanTags['span.type'] = 'web' + tagsUpdateCh.publish(webSpan) + assert.strictEqual(childCtx.webTags, webSpanTags) + + profiler.stop() + }) + + it('should repoint a child snapshot at a nearer web span promoted later (ACF path)', () => { + // Nested request handling: the child first resolves to the outer request, + // then an intermediate span becomes a web-server span of its own. From then + // on the child's samples belong to the inner request's endpoint. + const { span: outerSpan, tags: outerTags, spanId: outerSpanId } = makeWebSpan() + Object.assign(outerTags, { 'span.type': 'web', 'http.method': 'GET', 'http.route': '/outer' }) + const { span: innerSpan, tags: innerTags } = makeChildSpan(outerSpanId, outerSpan) + const innerSpanId = innerSpan.context()._spanId + const { span: childSpan } = makeChildSpan(innerSpanId, innerSpan) + + const profiler = makeWall(WallProfiler, { + endpointCollectionEnabled: true, + codeHotspotsEnabled: true, + asyncContextFrameEnabled: true, + }) + profiler.start() + + currentStore = { span: childSpan } + enterCh.publish() + const childCtx = localPprof.time.setContext.lastCall.args[0] + assert.strictEqual(childCtx.webTags, outerTags) + + Object.assign(innerTags, { 'span.type': 'web', 'http.method': 'GET', 'http.route': '/inner' }) + tagsUpdateCh.publish(innerSpan) + assert.strictEqual(childCtx.webTags, innerTags) + + profiler.stop() + }) + it('should propagate webTags to child spans after tags update resolves parent (ACF path)', () => { const { span: webSpan, tags: webSpanTags, spanId: webSpanId } = makeWebSpan() const { span: childSpan } = makeChildSpan(webSpanId, webSpan) diff --git a/packages/dd-trace/test/proxy.spec.js b/packages/dd-trace/test/proxy.spec.js index bd24e4454c0..acd82f05ccc 100644 --- a/packages/dd-trace/test/proxy.spec.js +++ b/packages/dd-trace/test/proxy.spec.js @@ -1,6 +1,8 @@ 'use strict' const assert = require('node:assert/strict') +const { spawnSync } = require('node:child_process') +const path = require('node:path') const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') @@ -29,12 +31,14 @@ describe('TracerProxy', () => { let Config let config let runtimeMetrics + let dynamicInstrumentation let log let profiler let appsec let aiguard let telemetry let iast + let rewriter let openfeature let PluginManager let pluginManager @@ -47,6 +51,10 @@ describe('TracerProxy', () => { let NoopDogStatsDClient let OpenFeatureProvider let openfeatureProvider + let registerTelemetryFlusher + let initializeServerlessTelemetry + let supportsServerlessTelemetryRetention + let flushServerlessTelemetry beforeEach(() => { process.env.DD_TRACE_MOCHA_ENABLED = 'false' @@ -173,15 +181,28 @@ describe('TracerProxy', () => { runtimeMetrics: { enabled: false, }, - setRemoteConfig: sinon.spy(), + setRemoteConfig: sinon.stub(), llmobs: {}, } Config = sinon.stub().returns(config) runtimeMetrics = { start: sinon.spy(), + flush: sinon.spy(), + } + + dynamicInstrumentation = { + configure: sinon.spy(), + isStarted: sinon.stub().returns(false), + start: sinon.spy(), + stop: sinon.spy(), } + registerTelemetryFlusher = sinon.stub().returns(() => {}) + initializeServerlessTelemetry = sinon.spy() + supportsServerlessTelemetryRetention = sinon.stub().returns(true) + flushServerlessTelemetry = sinon.spy() + profiler = { start: sinon.spy(), } @@ -205,6 +226,11 @@ describe('TracerProxy', () => { disable: sinon.spy(), } + rewriter = { + enable: sinon.spy(), + disable: sinon.spy(), + } + openfeature = { enable: sinon.spy(), disable: sinon.spy(), @@ -255,10 +281,12 @@ describe('TracerProxy', () => { './config': Config, './plugin_manager': PluginManager, './runtime_metrics': runtimeMetrics, + './debugger': dynamicInstrumentation, './log': log, './profiler': profiler, './appsec': appsec, './appsec/iast': iast, + './appsec/iast/taint-tracking/rewriter': rewriter, './aiguard': aiguard, './telemetry': telemetry, './remote_config': RemoteConfig, @@ -269,12 +297,35 @@ describe('TracerProxy', () => { './flare': flare, './openfeature': openfeature, './openfeature/flagging_provider': OpenFeatureProvider, + './serverless': { + IS_SERVERLESS: false, + initializeServerlessTelemetry, + supportsServerlessTelemetryRetention, + }, + './flush': { flushServerlessTelemetry, registerTelemetryFlusher }, }) proxy = new ProxyClass() }) describe('uninitialized', () => { + it('does not load inactive feature modules when required', () => { + const entry = require.resolve('..') + const optionalModules = [ + require.resolve('../src/debugger'), + require.resolve('../src/llmobs/experiments/noop'), + ] + const script = ` + const optionalModules = ${JSON.stringify(optionalModules)} + require(${JSON.stringify(entry)}) + process.stdout.write(JSON.stringify(optionalModules.map(path => require.cache[path] !== undefined))) + ` + const result = spawnSync(process.execPath, ['--eval', script], { encoding: 'utf8', timeout: 5_000 }) + + assert.strictEqual(result.status, 0, result.stderr) + assert.deepStrictEqual(JSON.parse(result.stdout), [false, false]) + }) + describe('init', () => { it('should return itself', () => { assert.strictEqual(proxy.init(), proxy) @@ -288,6 +339,109 @@ describe('TracerProxy', () => { sinon.assert.calledWith(Config, options) sinon.assert.calledWith(DatadogTracer, config) sinon.assert.calledOnceWithExactly(RemoteConfig, config) + sinon.assert.notCalled(rewriter.enable) + }) + + it('only loads Test Optimization startup modules through ci/init', () => { + const repoRoot = path.resolve(__dirname, '../../..') + const testOptimizationRoot = path.join(repoRoot, 'packages/dd-trace/src/ci-visibility') + path.sep + const modules = [ + require.resolve('../src/ci-visibility/test-api-manual/test-api-manual-plugin'), + require.resolve('../src/ci-visibility/log-submission/log-submission-plugin'), + require.resolve('../src/ci-visibility/dynamic-instrumentation'), + ] + const script = ` + const tracer = require(process.env.DD_TRACE_TEST_ENTRYPOINT) + if (process.env.DD_TRACE_TEST_CALL_INIT === 'true') { + tracer.init() + } + const modules = ${JSON.stringify(modules)} + const loaded = modules.map(module => require.cache[module] !== undefined) + const testOptimizationModuleCount = Object.keys(require.cache) + .filter(module => module.startsWith(${JSON.stringify(testOptimizationRoot)})) + .length + process.stdout.write(JSON.stringify({ loaded, testOptimizationModuleCount }), () => process.exit()) + ` + const cases = [ + { + entrypoint: repoRoot, + callInit: 'true', + environment: { DD_AGENTLESS_LOG_SUBMISSION_ENABLED: 'true' }, + expected: [false, false, false], + expectedTestOptimizationModuleCount: 0, + }, + { entrypoint: path.join(repoRoot, 'ci/init'), callInit: 'false', expected: [true, false, true] }, + { + entrypoint: path.join(repoRoot, 'ci/init'), + callInit: 'false', + environment: { DD_AGENTLESS_LOG_SUBMISSION_ENABLED: 'true' }, + expected: [true, true, true], + }, + { + entrypoint: path.join(repoRoot, 'ci/init'), + callInit: 'false', + environment: { + DD_CIVISIBILITY_MANUAL_API_ENABLED: 'false', + DD_TEST_FAILED_TEST_REPLAY_ENABLED: 'false', + }, + expected: [false, false, false], + }, + ] + + for (const testCase of cases) { + const result = spawnSync(process.execPath, ['--eval', script], { + encoding: 'utf8', + env: { + ...process.env, + DD_AGENTLESS_LOG_SUBMISSION_ENABLED: 'false', + DD_API_KEY: 'test-api-key', + DD_CIVISIBILITY_AGENTLESS_ENABLED: 'false', + DD_CIVISIBILITY_ENABLED: 'true', + DD_CIVISIBILITY_MANUAL_API_ENABLED: 'true', + DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'false', + DD_REMOTE_CONFIGURATION_ENABLED: 'false', + DD_TEST_FAILED_TEST_REPLAY_ENABLED: 'true', + DD_TRACE_ENABLED: 'true', + DD_TRACE_STARTUP_LOGS: 'false', + DD_TRACE_TEST_CALL_INIT: testCase.callInit, + DD_TRACE_TEST_ENTRYPOINT: testCase.entrypoint, + ...testCase.environment, + }, + }) + + assert.strictEqual(result.status, 0, result.stderr) + const { loaded, testOptimizationModuleCount } = JSON.parse(result.stdout) + assert.deepStrictEqual(loaded, testCase.expected) + if (testCase.expectedTestOptimizationModuleCount !== undefined) { + assert.strictEqual(testOptimizationModuleCount, testCase.expectedTestOptimizationModuleCount) + } + } + }) + + it('does not load Dynamic Instrumentation while disabled', () => { + proxy.init() + + sinon.assert.notCalled(dynamicInstrumentation.configure) + sinon.assert.notCalled(dynamicInstrumentation.isStarted) + sinon.assert.notCalled(dynamicInstrumentation.start) + sinon.assert.notCalled(dynamicInstrumentation.stop) + }) + + it('starts and configures Dynamic Instrumentation when enabled', () => { + config.dynamicInstrumentation.enabled = true + + proxy.init() + + sinon.assert.calledOnceWithExactly(dynamicInstrumentation.start, config, rc) + sinon.assert.calledOnceWithExactly(dynamicInstrumentation.configure, config) + }) + + it('should enable the IAST rewriter when IAST is enabled', () => { + config.iast.enabled = true + + proxy.init() + + sinon.assert.calledOnceWithExactly(rewriter.enable, config) }) it('should not initialize twice', () => { @@ -333,6 +487,38 @@ describe('TracerProxy', () => { sinon.assert.calledWith(pluginManager.configure, config) }) + it('does not load Dynamic Instrumentation for a disabled remote config update', () => { + config.setRemoteConfig.callsFake(conf => { + config.dynamicInstrumentation.enabled = conf['dynamicInstrumentation.enabled'] + }) + proxy.init() + + handlers.get('APM_TRACING')(createApmTracingTransaction('debugger-disabled', { + dynamic_instrumentation_enabled: false, + })) + + sinon.assert.notCalled(dynamicInstrumentation.configure) + sinon.assert.notCalled(dynamicInstrumentation.isStarted) + sinon.assert.notCalled(dynamicInstrumentation.start) + sinon.assert.notCalled(dynamicInstrumentation.stop) + }) + + it('loads Dynamic Instrumentation when remote config enables it', () => { + config.setRemoteConfig.callsFake(conf => { + config.dynamicInstrumentation.enabled = conf['dynamicInstrumentation.enabled'] + }) + proxy.init() + + handlers.get('APM_TRACING')(createApmTracingTransaction('debugger-enabled', { + dynamic_instrumentation_enabled: true, + })) + + sinon.assert.calledOnce(dynamicInstrumentation.isStarted) + sinon.assert.calledOnceWithExactly(dynamicInstrumentation.start, config, rc) + sinon.assert.notCalled(dynamicInstrumentation.configure) + sinon.assert.notCalled(dynamicInstrumentation.stop) + }) + it('should support enabling debug logs for tracer flares', () => { const logLevel = 'debug' @@ -586,6 +772,39 @@ describe('TracerProxy', () => { sinon.assert.called(runtimeMetrics.start) }) + it('registers the runtime metrics flush with the serverless lifecycle', () => { + config.runtimeMetrics.enabled = true + const done = sinon.spy() + + proxy.init() + registerTelemetryFlusher.firstCall.args[0](done) + + sinon.assert.calledOnceWithExactly(runtimeMetrics.flush, done) + }) + + it('registers Vercel telemetry retention when tracing is disabled', () => { + config.DD_TRACE_ENABLED = false + + proxy.init() + + sinon.assert.calledOnce(initializeServerlessTelemetry) + const telemetry = initializeServerlessTelemetry.firstCall.args[0] + assert.strictEqual(typeof telemetry.flushAll, 'function') + const done = sinon.spy() + telemetry.flushAll(done) + sinon.assert.calledOnceWithExactly(flushServerlessTelemetry, done, undefined) + }) + + it('does not create a lifecycle owner outside a retention platform', () => { + supportsServerlessTelemetryRetention.returns(false) + proxy = new ProxyClass() + + proxy.init() + + assert.strictEqual(proxy._serverlessTelemetry, undefined) + sinon.assert.calledWithExactly(initializeServerlessTelemetry, undefined) + }) + it('should expose noop metrics methods prior to initialization', () => { proxy.dogstatsd.increment('foo') }) diff --git a/packages/dd-trace/test/runtime_metrics.spec.js b/packages/dd-trace/test/runtime_metrics.spec.js index 94309d1d4e5..25317b18285 100644 --- a/packages/dd-trace/test/runtime_metrics.spec.js +++ b/packages/dd-trace/test/runtime_metrics.spec.js @@ -74,6 +74,7 @@ NATIVE_METRICS_VARIANTS.forEach((nativeMetrics) => { gauge () {}, increment () {}, decrement () {}, + flush (done) { done?.() }, }) proxy = proxyquire('../src/runtime_metrics', { @@ -152,6 +153,20 @@ NATIVE_METRICS_VARIANTS.forEach((nativeMetrics) => { sinon.assert.notCalled(runtimeMetrics.decrement) sinon.assert.calledOnce(runtimeMetrics.stop) }) + + it('flushes when enabled and is noop when disabled', () => { + const done = sinon.spy() + + proxy.start() + proxy.flush(done) + sinon.assert.notCalled(runtimeMetrics.flush) + sinon.assert.calledOnce(done) + + config.runtimeMetrics.enabled = true + proxy.start(config) + proxy.flush(done) + sinon.assert.calledOnceWithExactly(runtimeMetrics.flush, done) + }) }) describe('runtimeMetrics', () => { @@ -186,7 +201,7 @@ NATIVE_METRICS_VARIANTS.forEach((nativeMetrics) => { gauge: sinon.spy(), increment: sinon.spy(), histogram: sinon.spy(), - flush: sinon.spy(), + flush: sinon.stub().callsFake(done => done?.()), } const proxiedObject = { @@ -246,6 +261,21 @@ NATIVE_METRICS_VARIANTS.forEach((nativeMetrics) => { runtimeMetrics.stop() }) + it('captures and waits for the final runtime metrics flush', (done) => { + client.flush.resetHistory() + client.gauge.resetHistory() + + runtimeMetrics.flush(() => { + try { + sinon.assert.calledOnce(client.flush) + sinon.assert.called(client.gauge) + done() + } catch (error) { + done(error) + } + }) + }) + describe('start', () => { it('it should initialize the Dogstatsd client with the correct options', function () { runtimeMetrics.stop() diff --git a/packages/dd-trace/test/serverless.spec.js b/packages/dd-trace/test/serverless.spec.js index d33923f932b..10d0214425c 100644 --- a/packages/dd-trace/test/serverless.spec.js +++ b/packages/dd-trace/test/serverless.spec.js @@ -1,13 +1,81 @@ 'use strict' const assert = require('node:assert/strict') +const { spawnSync } = require('node:child_process') +const http = require('node:http') -const { describe, it, afterEach } = require('mocha') +const { describe, it, beforeEach, afterEach } = require('mocha') +const { logs } = require('@opentelemetry/api-logs') +const { metrics } = require('@opentelemetry/api') +const { channel } = require('dc-polyfill') +const sinon = require('sinon') require('./setup/core') -const { getServerlessPlatformTags, enableGCPPubSubPushSubscription } = require('../src/serverless') +const { + getServerlessPlatformTags, + getServerlessPlatform, + supportsServerlessTelemetryRetention, + createServerlessDeliveryTracker, + enableGCPPubSubPushSubscription, + initializeServerlessTelemetry, +} = require('../src/serverless') +const { registerVercelTelemetryRetention } = require('../src/serverless/vercel') +const { flushServerlessTelemetry, registerTelemetryFlusher } = require('../src/flush') +const Tracer = require('../src/tracer') +const { initializeOpenTelemetryLogs } = require('../src/opentelemetry/logs') +const { initializeOpenTelemetryMetrics } = require('../src/opentelemetry/metrics') +const TelemetryDeliveryTracker = require('../src/serverless/telemetry-delivery-tracker') const agent = require('./plugins/agent') +const { getConfigFresh } = require('./helpers/config') + +describe('TelemetryDeliveryTracker', () => { + it('is created only for Vercel', () => { + const originalVercel = process.env.VERCEL + try { + delete process.env.VERCEL + assert.strictEqual(createServerlessDeliveryTracker(), undefined) + assert.strictEqual(supportsServerlessTelemetryRetention(), false) + + process.env.VERCEL = '1' + assert.ok(createServerlessDeliveryTracker() instanceof TelemetryDeliveryTracker) + assert.strictEqual(supportsServerlessTelemetryRetention(), true) + } finally { + if (originalVercel === undefined) delete process.env.VERCEL + else process.env.VERCEL = originalVercel + } + }) + + it('joins deliveries that were active at the retention boundary', () => { + const tracker = new TelemetryDeliveryTracker() + const complete = [] + let done = 0 + + tracker.track(callback => complete.push(callback)) + tracker.track(callback => complete.push(callback)) + tracker.waitForIdle(() => { done++ }) + + complete.shift()() + assert.strictEqual(done, 0) + complete.shift()() + assert.strictEqual(done, 1) + }) + + it('does not wait for deliveries that begin after the retention boundary', () => { + const tracker = new TelemetryDeliveryTracker() + const complete = [] + let done = 0 + + tracker.track(callback => complete.push(callback)) + tracker.waitForIdle(() => { done++ }) + tracker.track(callback => complete.push(callback)) + + complete.shift()() + assert.strictEqual(done, 1) + complete.shift()() + assert.strictEqual(done, 1) + }) +}) describe('enableGCPPubSubPushSubscription', () => { const originalKService = process.env.K_SERVICE @@ -121,4 +189,369 @@ describe('Vercel span metadata', () => { 'vercel.environment', 'preview', ]) }) + + it('records the Vercel environment in configuration', () => { + process.env = { ...environment, VERCEL: '1' } + + assert.strictEqual(getServerlessPlatform().isVercel, true) + }) +}) + +describe('Vercel telemetry retention', () => { + const requestContext = Symbol.for('@vercel/request-context') + const originalContext = globalThis[requestContext] + const endpointVariables = [ + 'VERCEL', + 'OTEL_TRACES_EXPORTER', + 'DD_LOGS_OTEL_ENABLED', + 'DD_METRICS_OTEL_ENABLED', + 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', + 'OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', + 'OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', + ] + const originalEndpoints = Object.fromEntries(endpointVariables.map(name => [name, process.env[name]])) + + beforeEach(() => { + process.env.VERCEL = '1' + }) + + afterEach(() => { + if (originalContext === undefined) delete globalThis[requestContext] + else globalThis[requestContext] = originalContext + for (const name of endpointVariables) { + if (originalEndpoints[name] === undefined) delete process.env[name] + else process.env[name] = originalEndpoints[name] + } + logs.disable() + metrics.disable() + }) + + it('retains trace, log, and metric payloads until their intake responses complete', async () => { + process.env.OTEL_TRACES_EXPORTER = 'otlp' + process.env.DD_LOGS_OTEL_ENABLED = 'true' + process.env.DD_METRICS_OTEL_ENABLED = 'true' + const received = new Set() + const responses = [] + let intakeReceived + let metricPayloads = 0 + const intake = http.createServer((req, res) => { + req.resume() + req.once('end', () => { + if (req.url === '/v1/logs') received.add('logs') + if (req.url === '/v1/metrics') { + received.add('metrics') + metricPayloads++ + } + if (req.url === '/v1/traces') received.add('traces') + responses.push(res) + if (received.size === 3 && metricPayloads === 2) intakeReceived() + }) + }) + await new Promise(resolve => intake.listen(0, '127.0.0.1', resolve)) + const { port } = intake.address() + const endpoint = `http://127.0.0.1:${port}` + process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = `${endpoint}/v1/traces` + process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT = `${endpoint}/v1/logs` + process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT = `${endpoint}/v1/metrics` + + let retained + globalThis[requestContext] = { + get: () => ({ waitUntil: promise => { retained = promise } }), + } + const intakeRequests = new Promise(resolve => { intakeReceived = resolve }) + + let unregister + try { + const config = getConfigFresh({ service: 'serverless-flush' }) + const tracer = new Tracer(config) + initializeOpenTelemetryLogs(config) + initializeOpenTelemetryMetrics(config) + + tracer.trace('serverless.flush', {}, () => {}) + logs.getLogger('serverless-flush').emit({ body: 'flush me' }) + metrics.getMeter('serverless-flush').createCounter('flush.me').add(1) + + unregister = registerVercelTelemetryRetention(tracer) + channel('apm:http:server:request:finish').publish({}) + await intakeRequests + + let settled = false + retained.then(() => { settled = true }) + await new Promise(resolve => setImmediate(resolve)) + assert.strictEqual(settled, false) + + for (const response of responses) response.end() + await retained + assert.deepStrictEqual(received, new Set(['traces', 'logs', 'metrics'])) + assert.strictEqual(metricPayloads, 2) + } finally { + for (const response of responses) response.end() + unregister?.() + metrics.getMeterProvider()?.reader?.shutdown() + logs.getLoggerProvider()?.shutdown?.() + await new Promise(resolve => intake.close(resolve)) + } + }) + + it('waits for HTTP response completion after Next request finish', async () => { + process.env.VERCEL = '1' + let retained + globalThis[requestContext] = { + get: () => ({ waitUntil: promise => { retained = promise } }), + } + const nextFinishChannel = channel('apm:next:request:finish') + const httpFinishChannel = channel('apm:http:server:request:finish') + let finished = false + const tracer = { + flushAll (done) { + assert.ok(finished) + done() + }, + } + + const unregister = initializeServerlessTelemetry(tracer) + try { + nextFinishChannel.publish({}) + assert.strictEqual(retained, undefined) + finished = true + httpFinishChannel.publish({}) + await retained + } finally { + unregister() + } + }) + + it('retains telemetry for an ordinary HTTP Vercel request only once', async () => { + const retained = [] + let flushes = 0 + const context = { waitUntil: promise => { retained.push(promise) } } + globalThis[requestContext] = { get: () => context } + + const unregister = registerVercelTelemetryRetention({ + flushAll (done) { + flushes++ + done() + }, + }) + try { + channel('apm:http:server:request:finish').publish({}) + await Promise.all(retained) + assert.strictEqual(flushes, 1) + } finally { + unregister() + } + }) + + it('retains an instrumented HTTP request without HTTP tracing plugins', () => { + const vercelModule = require.resolve('../src/serverless/vercel') + const instrumentationRegister = require.resolve('../../datadog-instrumentations/src/helpers/register') + const script = ` + process.env.VERCEL = '1' + process.env.DD_INSTRUMENTATION_TELEMETRY_ENABLED = 'false' + const { registerVercelTelemetryRetention } = require(${JSON.stringify(vercelModule)}) + require(${JSON.stringify(instrumentationRegister)}) + const http = require('node:http') + const requestContext = Symbol.for('@vercel/request-context') + let retained + let flushes = 0 + globalThis[requestContext] = { get: () => ({ waitUntil: promise => { retained = promise } }) } + const unregister = registerVercelTelemetryRetention({ flushAll: done => { flushes++; done() } }) + const server = http.createServer((_req, res) => res.end()) + const fail = error => { + unregister() + server.close(() => { throw error }) + } + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() + http.get('http://127.0.0.1:' + port, res => { + res.resume() + res.once('end', () => { + setTimeout(() => { + if (!retained) return fail(new Error('Vercel retention was not registered')) + retained.then(() => { + if (flushes !== 1) return fail(new Error('Vercel telemetry was not flushed')) + unregister() + server.close() + }) + }, 0) + }) + }).on('error', fail) + }) + ` + const result = spawnSync(process.execPath, ['--eval', script], { encoding: 'utf8', timeout: 5_000 }) + + assert.strictEqual(result.status, 0, result.stderr) + }) + + it('retains an instrumented HTTP/2 request without HTTP tracing plugins', () => { + const vercelModule = require.resolve('../src/serverless/vercel') + const instrumentationRegister = require.resolve('../../datadog-instrumentations/src/helpers/register') + const script = ` + process.env.VERCEL = '1' + process.env.DD_INSTRUMENTATION_TELEMETRY_ENABLED = 'false' + const { registerVercelTelemetryRetention } = require(${JSON.stringify(vercelModule)}) + require(${JSON.stringify(instrumentationRegister)}) + const http2 = require('node:http2') + const requestContext = Symbol.for('@vercel/request-context') + let retained + let flushes = 0 + globalThis[requestContext] = { get: () => ({ waitUntil: promise => { retained = promise } }) } + const unregister = registerVercelTelemetryRetention({ flushAll: done => { flushes++; done() } }) + const server = http2.createServer() + const fail = error => { + unregister() + server.close(() => { throw error }) + } + server.on('stream', stream => { + stream.respond({ ':status': 200 }) + stream.end() + }) + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() + const client = http2.connect('http://127.0.0.1:' + port) + const request = client.request() + request.resume() + request.once('end', () => { + client.close() + setTimeout(() => { + if (!retained) return fail(new Error('Vercel retention was not registered')) + retained.then(() => { + if (flushes !== 1) return fail(new Error('Vercel telemetry was not flushed')) + unregister() + server.close() + }) + }, 0) + }) + request.end() + }) + ` + const result = spawnSync(process.execPath, ['--eval', script], { encoding: 'utf8', timeout: 5_000 }) + + assert.strictEqual(result.status, 0, result.stderr) + }) + + it('logs a Vercel waitUntil registration failure', () => { + const error = new Error('request context closed') + const warn = sinon.stub(require('../src/log'), 'warn') + globalThis[requestContext] = { get: () => ({ waitUntil: () => { throw error } }) } + const unregister = registerVercelTelemetryRetention({ flushAll () {} }) + + try { + channel('apm:http:server:request:finish').publish({}) + sinon.assert.calledWith(warn, 'Unable to retain Vercel telemetry:', error) + } finally { + unregister() + warn.restore() + } + }) + + it('retains a configured telemetry-only pipeline without a trace exporter', async () => { + let retained + const completeTelemetry = [] + let flushes = 0 + globalThis[requestContext] = { + get: () => ({ waitUntil: promise => { retained = promise } }), + } + const telemetryFlusher = done => { + flushes++ + completeTelemetry.push(done) + } + const unregisterTelemetry = registerTelemetryFlusher(telemetryFlusher) + const unregister = registerVercelTelemetryRetention({ + flushAll: (done, options) => flushServerlessTelemetry(done, options), + }) + try { + channel('apm:http:server:request:finish').publish({}) + await new Promise(resolve => setImmediate(resolve)) + + assert.ok(flushes >= 1) + assert.ok(completeTelemetry.every(done => typeof done === 'function')) + let settled = false + retained.then(() => { settled = true }) + await new Promise(resolve => setImmediate(resolve)) + assert.strictEqual(settled, false) + + for (const done of completeTelemetry) done() + await retained + } finally { + unregister() + unregisterTelemetry() + } + }) + + it('retains telemetry again when an outer Vercel response follows a nested request', async () => { + const retained = [] + const flushes = [] + globalThis[requestContext] = { + get: () => ({ waitUntil: promise => { retained.push(promise) } }), + } + const unregister = registerVercelTelemetryRetention({ + flushAll (done) { + flushes.push(done) + }, + }) + try { + channel('apm:http:server:request:finish').publish({ req: {} }) + await new Promise(resolve => setImmediate(resolve)) + channel('apm:http:server:request:finish').publish({ req: {} }) + await new Promise(resolve => setImmediate(resolve)) + + // Other tracers initialized by this file can share this request context; + // the callback count below isolates this test's tracer. + assert.ok(retained.length >= 2) + assert.strictEqual(flushes.length, 2) + flushes[0]() + flushes[1]() + } finally { + unregister() + } + }) + + it('passes Vercel retention timeout to the telemetry flush barrier', async () => { + let retained + let options + globalThis[requestContext] = { + get: () => ({ waitUntil: promise => { retained = promise } }), + } + + let unregister + try { + unregister = registerVercelTelemetryRetention({ + flushAll (done, flushOptions) { + options = flushOptions + done() + }, + }) + channel('apm:http:server:request:finish').publish({}) + await retained + + assert.deepStrictEqual(options, { timeout: 2_000 }) + } finally { + unregister?.() + } + }) + + it('retains telemetry at HTTP/2 response completion', async () => { + let retained + globalThis[requestContext] = { + get: () => ({ waitUntil: promise => { retained = promise } }), + } + + let flushes = 0 + const unregister = registerVercelTelemetryRetention({ + flushAll (done) { + flushes++ + done() + }, + }) + try { + channel('apm:http2:server:response:emit').publish({ eventName: 'finish' }) + assert.strictEqual(retained, undefined) + channel('apm:http2:server:response:emit').publish({ eventName: 'close' }) + await retained + assert.strictEqual(flushes, 1) + } finally { + unregister() + } + }) }) diff --git a/packages/dd-trace/test/span_stats.spec.js b/packages/dd-trace/test/span_stats.spec.js index 3652c8fe168..25c7fedec50 100644 --- a/packages/dd-trace/test/span_stats.spec.js +++ b/packages/dd-trace/test/span_stats.spec.js @@ -81,12 +81,14 @@ const syntheticSpan = { const exporter = { export: sinon.stub(), + flush: sinon.stub(), } const SpanStatsExporter = sinon.stub().returns(exporter) const otlpExporter = { export: sinon.stub(), + flush: sinon.stub(), } const { @@ -643,6 +645,101 @@ describe('SpanStatsProcessor', () => { assert.ok(otlpExporter.export.calledOnce) }) + it('force flushes pending OTLP span statistics', () => { + const exporter = { + export: sinon.stub().callsFake((_drained, _bucketSizeNs, done) => done()), + flush: sinon.stub().callsFake(done => done()), + } + const p = new SpanStatsProcessor(config, exporter) + clearTimeout(p.timer) + p.onSpanFinished(topLevelSpan) + + let flushed = false + p.forceFlush(() => { flushed = true }) + + assert.ok(exporter.export.calledOnce) + assert.ok(exporter.flush.calledOnce) + assert.ok(flushed) + assert.strictEqual(p.buckets.size, 0) + }) + + it('snapshots prior OTLP exports before starting the boundary export', () => { + let priorDone + let exportDone + const exporter = { + flush: sinon.stub().callsFake(done => { priorDone = done }), + export: sinon.stub().callsFake((_drained, _bucketSizeNs, done) => { exportDone = done }), + } + const p = new SpanStatsProcessor(config, exporter) + clearTimeout(p.timer) + p.onSpanFinished(topLevelSpan) + const done = sinon.spy() + + p.forceFlush(done) + + sinon.assert.callOrder(exporter.flush, exporter.export) + exportDone() + sinon.assert.notCalled(done) + priorDone() + sinon.assert.calledOnce(done) + }) + + it('waits for a prior OTLP export when the boundary export throws', () => { + let priorDone + const exporter = { + flush: sinon.stub().callsFake(done => { priorDone = done }), + export: sinon.stub().throws(new Error('encode failed')), + } + const p = new SpanStatsProcessor(config, exporter) + clearTimeout(p.timer) + p.onSpanFinished(topLevelSpan) + const done = sinon.spy() + + p.forceFlush(done) + + sinon.assert.notCalled(done) + priorDone() + sinon.assert.calledOnce(done) + }) + + it('force flushes pending agent span statistics', () => { + exporter.export.resetHistory() + exporter.flush.resetHistory() + exporter.export.callsFake((_payload, done) => done()) + const p = new SpanStatsProcessor(config) + clearTimeout(p.timer) + p.onSpanFinished(topLevelSpan) + + let flushed = false + p.forceFlush(() => { flushed = true }) + + assert.ok(exporter.export.calledOnce) + assert.ok(exporter.flush.notCalled) + assert.ok(flushed) + assert.strictEqual(p.buckets.size, 0) + exporter.export.resetBehavior() + }) + + it('joins an in-flight agent span statistics export during force flush', () => { + exporter.export.resetHistory() + exporter.flush.resetHistory() + const p = new SpanStatsProcessor(config) + clearTimeout(p.timer) + p.onSpanFinished(topLevelSpan) + + let flushDone + exporter.export.callsFake((_payload, done) => { flushDone = done }) + let flushed = false + p.forceFlush(() => { flushed = true }) + + assert.ok(exporter.export.calledOnce) + assert.ok(exporter.flush.notCalled) + assert.strictEqual(flushed, false) + flushDone() + assert.strictEqual(flushed, true) + exporter.export.resetBehavior() + }) + it('should record spans when only OTLP is enabled', () => { otlpExporter.export.resetHistory() const p = new SpanStatsProcessor({ diff --git a/packages/dd-trace/test/tracer.spec.js b/packages/dd-trace/test/tracer.spec.js index ee9c4d5ec05..8c7f6caf569 100644 --- a/packages/dd-trace/test/tracer.spec.js +++ b/packages/dd-trace/test/tracer.spec.js @@ -50,6 +50,119 @@ describe('Tracer', () => { }) }) + describe('flushAll', () => { + let originalVercel + + beforeEach(() => { + originalVercel = process.env.VERCEL + process.env.VERCEL = '1' + }) + + afterEach(() => { + if (originalVercel === undefined) delete process.env.VERCEL + else process.env.VERCEL = originalVercel + }) + + it('flushes registered telemetry pipelines with the configured trace exporter', () => { + const { registerTelemetryFlusher } = require('../src/flush') + tracer._exporter.flush = sinon.stub().callsFake(done => done()) + const telemetryFlusher = sinon.stub().callsFake(done => done()) + const unregister = registerTelemetryFlusher(telemetryFlusher) + let completed = false + + tracer.flushAll(() => { completed = true }) + + sinon.assert.calledOnce(tracer._exporter.flush) + sinon.assert.calledOnce(telemetryFlusher) + assert.strictEqual(completed, true) + unregister() + }) + + it('flushes post-trace telemetry after the trace exporter completes', () => { + const { registerTelemetryFlusher } = require('../src/flush') + let traceDone + tracer._exporter.flush = sinon.stub().callsFake(done => { traceDone = done }) + const runtimeMetricsFlusher = sinon.stub().callsFake(done => done()) + const unregister = registerTelemetryFlusher(runtimeMetricsFlusher, { afterTrace: true }) + const done = sinon.spy() + + tracer.flushAll(done) + + sinon.assert.notCalled(runtimeMetricsFlusher) + traceDone() + sinon.assert.calledOnce(runtimeMetricsFlusher) + sinon.assert.calledOnce(done) + unregister() + }) + + it('flushes registered telemetry pipelines without a trace exporter', () => { + const { flushServerlessTelemetry, registerTelemetryFlusher } = require('../src/flush') + const telemetryFlusher = sinon.stub().callsFake(done => done()) + const unregister = registerTelemetryFlusher(telemetryFlusher) + const done = sinon.spy() + + flushServerlessTelemetry(done) + + sinon.assert.calledOnce(telemetryFlusher) + sinon.assert.calledOnce(done) + unregister() + }) + + it('waits for callback flushers that return a synchronous status', () => { + const { flushServerlessTelemetry, registerTelemetryFlusher } = require('../src/flush') + let flushDone + const telemetryFlusher = sinon.stub().callsFake(done => { + flushDone = done + return false + }) + const unregister = registerTelemetryFlusher(telemetryFlusher) + const done = sinon.spy() + + try { + flushServerlessTelemetry(done) + + sinon.assert.notCalled(done) + flushDone() + sinon.assert.calledOnce(done) + } finally { + unregister() + } + }) + + it('bounds configured telemetry flushing', () => { + const { flushServerlessTelemetry, registerTelemetryFlusher } = require('../src/flush') + const timeout = sinon.stub(global, 'setTimeout') + const clearTimeout = sinon.stub(global, 'clearTimeout') + const done = sinon.spy() + const unregister = registerTelemetryFlusher(() => {}) + + try { + flushServerlessTelemetry(done, { timeout: 2_000 }) + + sinon.assert.calledWith(timeout, sinon.match.func, 2_000) + timeout.firstCall.args[0]() + sinon.assert.calledOnce(done) + sinon.assert.called(clearTimeout) + } finally { + unregister() + timeout.restore() + clearTimeout.restore() + } + }) + + it('does not retain telemetry flushers outside a supported platform', () => { + const { flushServerlessTelemetry, registerTelemetryFlusher } = require('../src/flush') + delete process.env.VERCEL + const telemetryFlusher = sinon.stub() + const unregister = registerTelemetryFlusher(telemetryFlusher) + + flushServerlessTelemetry(sinon.spy()) + + sinon.assert.notCalled(telemetryFlusher) + unregister() + }) + }) + describe('trace', () => { it('should run the callback with a new span', () => { tracer.trace('name', {}, span => { diff --git a/packages/dd-trace/test/web-tags-cache.spec.js b/packages/dd-trace/test/web-tags-cache.spec.js index 2edd76b3886..6988a3580a9 100644 --- a/packages/dd-trace/test/web-tags-cache.spec.js +++ b/packages/dd-trace/test/web-tags-cache.spec.js @@ -16,18 +16,16 @@ function makeTrace () { return { started: [] } } +// One context object per span, as DatadogSpan#context() returns — a spy on its +// getTags then counts how often the cache actually walks that span. function makeSpan (trace, { spanId, parentId, tags = {} } = {}) { - const span = { - context () { - return { - _spanId: spanId, - _parentId: parentId, - _trace: trace, - getTags: () => tags, - } - }, - tags, + const context = { + _spanId: spanId, + _parentId: parentId, + _trace: trace, + getTags: () => tags, } + const span = { context: () => context, tags } trace.started.push(span) return span } @@ -96,9 +94,194 @@ describe('web-tags-cache', () => { const child = makeSpan(trace, { spanId: 'b', parentId: 'a' }) const getTags = sinon.spy(parent.context(), 'getTags') assert.equal(webTagsCache.getCachedWebTags(child), tags) - const callsAfterFirst = getTags.callCount + assert.equal(getTags.callCount, 1) assert.equal(webTagsCache.getCachedWebTags(child), tags) - assert.equal(getTags.callCount, callsAfterFirst) + assert.equal(getTags.callCount, 1) + }) + + it('does not re-walk an empty answer while nothing has been promoted', () => { + const trace = makeTrace() + makeSpan(trace, { spanId: 'a' }) + const child = makeSpan(trace, { spanId: 'b', parentId: 'a' }) + const getTags = sinon.spy(child.context(), 'getTags') + assert.equal(webTagsCache.getCachedWebTags(child), undefined) + assert.equal(getTags.callCount, 1) + assert.equal(webTagsCache.getCachedWebTags(child), undefined) + assert.equal(getTags.callCount, 1) + }) + + it('leaves a span outside the promoted span\'s subtree alone', () => { + // A promotion rewrites the answers of the promoted span's descendants and + // nothing else: a sibling subtree cannot have it as an ancestor, so it is + // neither re-examined nor announced. + const trace = makeTrace() + const parent = makeSpan(trace, { spanId: 'a', tags: {} }) + const child = makeSpan(trace, { spanId: 'b', parentId: 'a' }) + assert.equal(webTagsCache.getCachedWebTags(child), undefined) + const getTags = sinon.spy(child.context(), 'getTags') + + const sibling = makeSpan(trace, { spanId: 'z', parentId: 'a', tags: {} }) + webTagsCache.getCachedWebTags(sibling) + Object.assign(sibling.tags, WEB) + tagsUpdateCh.publish(sibling) + + assert.equal(webTagsCache.getCachedWebTags(child), undefined) + assert.equal(getTags.callCount, 0) + sinon.assert.neverCalledWith(resolved, child) + + // Its own ancestor being promoted is what resolves it. + Object.assign(parent.tags, WEB) + tagsUpdateCh.publish(parent) + assert.equal(webTagsCache.getCachedWebTags(child), parent.tags) + }) + + it('does not scan the started-spans list for a parent a span cannot have', () => { + const trace = makeTrace() + const other = makeSpan(trace, { spanId: 'a', tags: {} }) + const root = makeSpan(trace, { spanId: 'b' }) + const context = sinon.spy(other, 'context') + + assert.equal(webTagsCache.getCachedWebTags(root), undefined) + assert.equal(context.callCount, 0) + }) + + it('does not look at spans created before the promoted one', () => { + // Creation order rules them out as descendants, so the sweep must not pay + // a context() call for each of them. Worth pinning: a long-lived trace can + // hold a large prefix, and every web-server span promoted in it would walk + // that prefix again. + const trace = makeTrace() + const older = makeSpan(trace, { spanId: 'a', tags: {} }) + const promoted = makeSpan(trace, { spanId: 'b', tags: {} }) + const child = makeSpan(trace, { spanId: 'c', parentId: 'b' }) + // Created after the promoted span but under the older one: visited by the + // sweep, and left alone because the promotion is not in its ancestry. + const unrelated = makeSpan(trace, { spanId: 'd', parentId: 'a' }) + webTagsCache.getCachedWebTags(older) + webTagsCache.getCachedWebTags(child) + webTagsCache.getCachedWebTags(unrelated) + + const context = sinon.spy(older, 'context') + Object.assign(promoted.tags, WEB) + tagsUpdateCh.publish(promoted) + + assert.equal(context.callCount, 0) + assert.equal(webTagsCache.getCachedWebTags(child), promoted.tags) + assert.equal(webTagsCache.getCachedWebTags(unrelated), undefined) + }) + + it('leaves another trace alone when a span is promoted', () => { + // Promotions are per trace: the walk never leaves its own _trace.started, so + // another trace's request span cannot be this span's ancestor. + const traceA = makeTrace() + makeSpan(traceA, { spanId: 'a' }) + const child = makeSpan(traceA, { spanId: 'b', parentId: 'a' }) + assert.equal(webTagsCache.getCachedWebTags(child), undefined) + const getTags = sinon.spy(child.context(), 'getTags') + + const traceB = makeTrace() + const requestSpan = makeSpan(traceB, { spanId: 'c', tags: {} }) + webTagsCache.getCachedWebTags(requestSpan) + Object.assign(requestSpan.tags, WEB) + tagsUpdateCh.publish(requestSpan) + + assert.equal(webTagsCache.getCachedWebTags(child), undefined) + assert.equal(getTags.callCount, 0) + }) + + it('resolves a descendant that cached an empty answer once an ancestor is promoted', () => { + // The window this exists for: TracingPlugin.startSpan activates a span + // before addRequestTags sets span.type, so a child created in between walks + // past an ancestor that is about to become a web-server span. + const trace = makeTrace() + const parent = makeSpan(trace, { spanId: 'a', tags: {} }) + const child = makeSpan(trace, { spanId: 'b', parentId: 'a' }) + const grandchild = makeSpan(trace, { spanId: 'c', parentId: 'b' }) + assert.equal(webTagsCache.getCachedWebTags(grandchild), undefined) + + Object.assign(parent.tags, WEB, { 'http.method': 'GET', 'http.route': '/x' }) + tagsUpdateCh.publish(parent) + + assert.equal(webTagsCache.getCachedWebTags(child), parent.tags) + assert.equal(webTagsCache.getCachedWebTags(grandchild), parent.tags) + }) + + it('repoints a descendant at a nearer web-server span promoted later', () => { + // Nested request handling: the inner request span is created under the + // outer one and only becomes a web-server span afterwards, by which time + // its descendants are attributed to the outer request. + const trace = makeTrace() + const outer = makeSpan(trace, { spanId: 'a', tags: { ...WEB } }) + const inner = makeSpan(trace, { spanId: 'b', parentId: 'a', tags: {} }) + const child = makeSpan(trace, { spanId: 'c', parentId: 'b' }) + assert.equal(webTagsCache.getCachedWebTags(child), outer.tags) + + Object.assign(inner.tags, WEB) + tagsUpdateCh.publish(inner) + + assert.equal(webTagsCache.getCachedWebTags(inner), inner.tags) + assert.equal(webTagsCache.getCachedWebTags(child), inner.tags) + }) + + it('keeps a nearer web-server span\'s subtree on that span when an outer one is promoted', () => { + // The outer promotion stops at the inner request span: everything under it + // already has a closer answer, and the outer endpoint is not theirs. + const trace = makeTrace() + const outer = makeSpan(trace, { spanId: 'a', tags: {} }) + const inner = makeSpan(trace, { spanId: 'b', parentId: 'a', tags: { ...WEB } }) + const child = makeSpan(trace, { spanId: 'c', parentId: 'b' }) + const sibling = makeSpan(trace, { spanId: 'd', parentId: 'a' }) + assert.equal(webTagsCache.getCachedWebTags(child), inner.tags) + assert.equal(webTagsCache.getCachedWebTags(sibling), undefined) + + Object.assign(outer.tags, WEB) + tagsUpdateCh.publish(outer) + + assert.equal(webTagsCache.getCachedWebTags(child), inner.tags) + assert.equal(webTagsCache.getCachedWebTags(sibling), outer.tags) + }) + + it('announces a descendant as soon as the ancestor is promoted, not on its next lookup', () => { + // A descendant that keeps running without re-entering storage never asks + // again, and is precisely the span samples are being attributed to. + const trace = makeTrace() + const parent = makeSpan(trace, { spanId: 'a', tags: {} }) + const child = makeSpan(trace, { spanId: 'b', parentId: 'a' }) + webTagsCache.getCachedWebTags(child) + + Object.assign(parent.tags, WEB) + tagsUpdateCh.publish(parent) + + sinon.assert.calledWith(resolved, child) + sinon.assert.calledTwice(resolved) + }) + + it('announces a resolved descendant only once', () => { + const trace = makeTrace() + const parent = makeSpan(trace, { spanId: 'a', tags: {} }) + const child = makeSpan(trace, { spanId: 'b', parentId: 'a' }) + webTagsCache.getCachedWebTags(child) + + Object.assign(parent.tags, WEB) + tagsUpdateCh.publish(parent) + resolved.resetHistory() + + tagsUpdateCh.publish(parent) + webTagsCache.getCachedWebTags(child) + sinon.assert.notCalled(resolved) + }) + + it('stays silent for a descendant that was never looked up', () => { + const trace = makeTrace() + const parent = makeSpan(trace, { spanId: 'a', tags: {} }) + makeSpan(trace, { spanId: 'b', parentId: 'a' }) + webTagsCache.getCachedWebTags(parent) + + Object.assign(parent.tags, WEB) + tagsUpdateCh.publish(parent) + + sinon.assert.calledOnce(resolved) + sinon.assert.calledWith(resolved, parent) }) }) diff --git a/scripts/helpers/test-file-index.js b/scripts/helpers/test-file-index.js index 16000f520f6..59ac3a0433d 100644 --- a/scripts/helpers/test-file-index.js +++ b/scripts/helpers/test-file-index.js @@ -77,17 +77,20 @@ function normalizePattern (pattern) { if (!pattern.includes('./') && !pattern.includes('//')) return pattern const segments = pattern.split('/') - const kept = [] + let normalized = '' + let hasSegment = false for (let i = 0; i < segments.length; i++) { const segment = segments[i] // A leading empty segment marks an absolute pattern and a trailing one a directory-only // pattern; both change what matches, so only interior blanks are collapsed. if (segment === '.' || (segment === '' && i !== 0 && i !== segments.length - 1)) continue - kept.push(segment) + if (hasSegment) normalized += '/' + normalized += segment + hasSegment = true } - return kept.join('/') + return normalized } /** diff --git a/scripts/pr-title.spec.mjs b/scripts/pr-title.spec.mjs new file mode 100644 index 00000000000..b444eab16a7 --- /dev/null +++ b/scripts/pr-title.spec.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import vm from 'node:vm' + +import { describe, it } from 'mocha' +import YAML from 'yaml' + +const workflow = YAML.parse(fs.readFileSync(new URL('../.github/workflows/pr-title.yml', import.meta.url), 'utf8')) +const job = workflow.jobs['conventional-commit'] +const steps = new Map() +for (const step of job.steps) steps.set(step.name, step) + +const checkout = steps.get('Checkout base revision') +const validation = steps.get('Validate PR title and release-note context') +const labelSync = steps.get('Sync labels with PR title') +const validateTitle = vm.runInNewContext( + `(async function validateTitle (context, core, github, process, require) {\n${validation.with.script}\n})` +) +const releaseHelpers = { + appendChangedPaths: Function.prototype, + isInternalOnly: () => false, +} +const loadReleaseHelpers = () => releaseHelpers + +describe('PR title workflow', () => { + it('lists changed files only for public title types', async () => { + const expectedCallsByType = new Map([ + ['feat', 1], + ['fix', 1], + ['perf', 1], + ['docs', 1], + ['style', 0], + ['refactor', 0], + ['test', 0], + ['bench', 0], + ['build', 0], + ['ci', 0], + ['chore', 0], + ['revert', 0], + ]) + const validations = [] + + for (const [type, expectedCalls] of expectedCallsByType) { + let listFilesCalls = 0 + const context = { + repo: { owner: 'DataDog', repo: 'dd-trace-js' }, + payload: { pull_request: { number: 1, title: `${type}: change` } }, + } + const core = { + info: Function.prototype, + setFailed: assert.fail, + } + const github = { + paginate: () => { + listFilesCalls++ + return [] + }, + rest: { pulls: { listFiles: Function.prototype } }, + } + const process = { env: { PR_TITLE_PATTERN: job.env.PR_TITLE_PATTERN } } + + validations.push(validateTitle(context, core, github, process, loadReleaseHelpers)) + + assert.strictEqual(listFilesCalls, expectedCalls, type) + } + + await Promise.all(validations) + }) + + it('syncs labels only for events that can require reconciliation', () => { + assert.strictEqual(labelSync.if.replaceAll(/\s+/g, ' '), + "steps.rename.outputs.renamed != 'true' && " + + "(github.event.action == 'opened' || " + + "github.event.action == 'reopened' || " + + "(github.event.action == 'edited' && github.event.changes.title != null))") + }) + + it('checks out the workflow revision', () => { + assert.strictEqual(checkout.with.ref, '$' + '{{ github.sha }}') + }) +}) diff --git a/scripts/release/changelog.js b/scripts/release/changelog.js index b08af039272..c68760759b8 100644 --- a/scripts/release/changelog.js +++ b/scripts/release/changelog.js @@ -446,25 +446,24 @@ function sentenceCase (subject) { * @param {Change[]} breakingChanges */ function renderMarkdown (sections, contributors, breakingChanges) { - const lines = [] + let markdown = '' if (breakingChanges.length > 0) { - lines.push('### Breaking Changes') + markdown = '### Breaking Changes\n' for (const change of breakingChanges.sort(compareChanges)) { - lines.push(renderChange(change)) + markdown += `${renderChange(change)}\n` } - lines.push('') } for (const category of CATEGORY_ORDER) { const changes = sections.get(category) if (!changes?.length) continue - lines.push(renderHeading(category)) + if (markdown) markdown += '\n' + markdown += `${renderHeading(category)}\n` for (const change of changes.sort(compareChanges)) { - lines.push(renderChange(change)) + markdown += `${renderChange(change)}\n` } - lines.push('') } if (contributors.size > 0) { @@ -474,13 +473,17 @@ function renderMarkdown (sections, contributors, breakingChanges) { } if (iconContributors.length > 0) { iconContributors.sort(compareContributors) - const avatars = [] - for (const contributor of iconContributors) avatars.push(renderContributor(contributor)) - lines.push('### Contributors', '', avatars.join(' '), '') + let avatars = '' + for (const contributor of iconContributors) { + if (avatars) avatars += ' ' + avatars += renderContributor(contributor) + } + if (markdown) markdown += '\n' + markdown += `### Contributors\n\n${avatars}\n` } } - return lines.join('\n') + return markdown } /** diff --git a/supported_versions_output.json b/supported_versions_output.json index ae3c51843d0..72ac9f3db54 100644 --- a/supported_versions_output.json +++ b/supported_versions_output.json @@ -528,7 +528,7 @@ "dependency": "mariadb", "integration": "mariadb", "minimum_tracer_supported": "2.0.4", - "max_tracer_supported": "3.5.2", + "max_tracer_supported": "3.5.3", "auto-instrumented": "True" }, { diff --git a/supported_versions_table.csv b/supported_versions_table.csv index c2318836a9d..5448c488751 100644 --- a/supported_versions_table.csv +++ b/supported_versions_table.csv @@ -74,7 +74,7 @@ jest-worker,jest,28.0.0,30.4.1,True kafkajs,kafkajs,1.4.0,2.2.4,True koa,koa,2.0.0,3.2.1,True koa-router,koa,7.0.0,14.0.0,True -mariadb,mariadb,2.0.4,3.5.2,True +mariadb,mariadb,2.0.4,3.5.3,True memcached,memcached,2.2.0,2.2.2,True mercurius,graphql,13.0.0,16.10.0,True microgateway-core,microgateway-core,2.1.0,3.3.7,True diff --git a/vendor/package-lock.json b/vendor/package-lock.json index 35896477227..f9cc3de263a 100644 --- a/vendor/package-lock.json +++ b/vendor/package-lock.json @@ -8,7 +8,7 @@ "license": "(Apache-2.0 OR BSD-3-Clause)", "dependencies": { "@apm-js-collab/code-transformer": "^0.18.1", - "@datadog/openfeature-node-server": "2.1.0", + "@datadog/openfeature-node-server": "2.2.0", "@datadog/sketches-js": "2.1.1", "@datadog/source-map": "npm:source-map@^0.6.0", "@isaacs/ttlcache": "^2.1.5", @@ -70,21 +70,21 @@ } }, "node_modules/@datadog/flagging-core": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@datadog/flagging-core/-/flagging-core-2.0.2.tgz", - "integrity": "sha512-2+oWyqz/EMNXtsgyW3NtueFgL0TciWInyMg/6bEUZhfr7UgPzCQ88Nag9FGoPCig19F/tHXE5hLiQccjkRUMWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@datadog/flagging-core/-/flagging-core-2.1.0.tgz", + "integrity": "sha512-d43cXWx5Uxzi8SMUgSelOr6u6akNS4Pu4EZlLXvnPkEAxSo+NY2OiStnGOh5Y6ZlROd2v741SOH97zHTML99iw==", "license": "Apache-2.0", "dependencies": { "spark-md5": "^3.0.2" } }, "node_modules/@datadog/openfeature-node-server": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@datadog/openfeature-node-server/-/openfeature-node-server-2.1.0.tgz", - "integrity": "sha512-6nzVv7d5budwJTqh/k52L7cUcl3bNsg/zYrWAniHVBlf2cyYH2dH20xwZ40kEkBVfhoaJRelQIg0qbNeoVmJlw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@datadog/openfeature-node-server/-/openfeature-node-server-2.2.0.tgz", + "integrity": "sha512-4ttAHzM80mV3NnrANzcCYsA98LGgiXineLYvCcagwFKjAEvWfs3i/wDscEDXVedYbPjRJEcqIuF/Pvi+Zx6pdg==", "license": "Apache-2.0", "dependencies": { - "@datadog/flagging-core": "2.0.2" + "@datadog/flagging-core": "2.1.0" }, "engines": { "node": ">=18.0.0" diff --git a/vendor/package.json b/vendor/package.json index b75f56d3582..736661cc0c7 100644 --- a/vendor/package.json +++ b/vendor/package.json @@ -5,7 +5,7 @@ }, "dependencies": { "@apm-js-collab/code-transformer": "^0.18.1", - "@datadog/openfeature-node-server": "2.1.0", + "@datadog/openfeature-node-server": "2.2.0", "@datadog/sketches-js": "2.1.1", "@datadog/source-map": "npm:source-map@^0.6.0", "@isaacs/ttlcache": "^2.1.5", diff --git a/yarn.lock b/yarn.lock index 16177bf48fc..05c9fddf043 100644 --- a/yarn.lock +++ b/yarn.lock @@ -245,10 +245,10 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== -"@datadog/flagging-core@2.0.2": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@datadog/flagging-core/-/flagging-core-2.0.2.tgz#333d7a5701306f7311861c1c0a3ce0be6138cc38" - integrity sha512-2+oWyqz/EMNXtsgyW3NtueFgL0TciWInyMg/6bEUZhfr7UgPzCQ88Nag9FGoPCig19F/tHXE5hLiQccjkRUMWQ== +"@datadog/flagging-core@2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@datadog/flagging-core/-/flagging-core-2.1.0.tgz#672854a2c057c90f31e1905a845bdf88cc81066f" + integrity sha512-d43cXWx5Uxzi8SMUgSelOr6u6akNS4Pu4EZlLXvnPkEAxSo+NY2OiStnGOh5Y6ZlROd2v741SOH97zHTML99iw== dependencies: spark-md5 "^3.0.2" @@ -257,34 +257,34 @@ resolved "https://registry.yarnpkg.com/@datadog/libdatadog/-/libdatadog-0.12.1.tgz#0b15c4781208a77aa08f0efb74d9deb645e800c4" integrity sha512-4cKRaO1mB9npfklJjOizzJaNBdZvw1V62EVbSD6Y32zX92bTBq/vAno/TTN9dMAvzomXYmvADpGo4798E9fMoA== -"@datadog/native-appsec@11.0.1": - version "11.0.1" - resolved "https://registry.yarnpkg.com/@datadog/native-appsec/-/native-appsec-11.0.1.tgz#8b545a9d968131d9cd7b43fd9594228dfcc18f3c" - integrity sha512-Y/XfknUmmJcw4hhQVhqzgdQvfjy+EGmXuUBgtVkI1r+/qS00egYu+wD/x7pOvjdbZNqN96znVszAnXvDQAzMDQ== +"@datadog/native-appsec@11.0.2": + version "11.0.2" + resolved "https://registry.yarnpkg.com/@datadog/native-appsec/-/native-appsec-11.0.2.tgz#98eacb74e065280953f877238e336960a770a754" + integrity sha512-Azs5fhwJx/BXHMnz+4PM2d9Is7Ik8uQPSM90nzvTCOcBYhSxuLhCY0EsDik/15CRknV9YoQrP1vmRrCVq3il5w== dependencies: node-gyp-build "^3.9.0" -"@datadog/native-iast-taint-tracking@4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@datadog/native-iast-taint-tracking/-/native-iast-taint-tracking-4.2.0.tgz#ca05a1510af130e14fad7721b539dcf151ee235f" - integrity sha512-NpZABJQoNMzF6cU521RT4GQ8/FbfFRoDepOLTcLYKyw0DY2WmSpg3iG+PoQNK4O3jPSXC++K3rg59GiQgA3Mog== +"@datadog/native-iast-taint-tracking@4.2.1": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@datadog/native-iast-taint-tracking/-/native-iast-taint-tracking-4.2.1.tgz#fe3fb18f0fdb05aa6812b41aba504acf36c6b3d3" + integrity sha512-KRLu3aTXvyAusouAZGTw2w1Xo67cRTw93yG4C9vTMpIgN9FXiqhlcvtNHvUzaA/KRrwtNwbaUELubOtk/kxOFg== dependencies: node-gyp-build "^3.9.0" -"@datadog/native-metrics@3.1.2": - version "3.1.2" - resolved "https://registry.yarnpkg.com/@datadog/native-metrics/-/native-metrics-3.1.2.tgz#9dc269bdbc6f5b5c9a30dc6d99bab44d17dd5a37" - integrity sha512-7AEWt0ZLr/ogR/9if1DmFBDTg3y67xM+gdhXUXKs+UQMxK0lnjrOHgN7fkpEmUG1uL+EkX2BDE3ENDlQ23J7OQ== +"@datadog/native-metrics@4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@datadog/native-metrics/-/native-metrics-4.0.0.tgz#74f7d6cabc13d263bf6aeb3c1c5c41bbcc31073a" + integrity sha512-rS6Qc8WAbOgbrtDYdqK15gi2xbuIEyfuquUH05g+2DOdiNAswNAGrlIuSdJZQF/qoJzjcFvxb9kCHu8BjiPfQQ== dependencies: node-addon-api "^6.1.0" node-gyp-build "^3.9.0" -"@datadog/openfeature-node-server@2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@datadog/openfeature-node-server/-/openfeature-node-server-2.1.0.tgz#28a6eba85bfda1ad4c7564812a19364c255b14cf" - integrity sha512-6nzVv7d5budwJTqh/k52L7cUcl3bNsg/zYrWAniHVBlf2cyYH2dH20xwZ40kEkBVfhoaJRelQIg0qbNeoVmJlw== +"@datadog/openfeature-node-server@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@datadog/openfeature-node-server/-/openfeature-node-server-2.2.0.tgz#e85964d06c23c7f64e39dd0e5e54a04c2048fcbc" + integrity sha512-4ttAHzM80mV3NnrANzcCYsA98LGgiXineLYvCcagwFKjAEvWfs3i/wDscEDXVedYbPjRJEcqIuF/Pvi+Zx6pdg== dependencies: - "@datadog/flagging-core" "2.0.2" + "@datadog/flagging-core" "2.1.0" "@datadog/pprof@5.18.1": version "5.18.1" @@ -295,10 +295,10 @@ pprof-format "^2.3.1" source-map "^0.8.0" -"@datadog/wasm-js-rewriter@5.0.3": - version "5.0.3" - resolved "https://registry.yarnpkg.com/@datadog/wasm-js-rewriter/-/wasm-js-rewriter-5.0.3.tgz#815b0969595628b558b1aa8235965c351edc6adb" - integrity sha512-XfumKHD5RTTvqiwMmTEWqvix472tIofPDA78+wFLMWJ6xxJBlJRbJ//sMT9bNYlSgau9JYB9S38EeMdf4xFhGw== +"@datadog/wasm-js-rewriter@5.0.4": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@datadog/wasm-js-rewriter/-/wasm-js-rewriter-5.0.4.tgz#6d0973cefd82ee5bea30c3aa4e3bcfdb35ef169f" + integrity sha512-tSjbk51dkNzFMM3P/7y82tyrBj/9CWnPzcVb1JB8TUea/PIholdXSPuGC5F975YvvGB8KDujfmJIacN+EmuXMg== dependencies: js-yaml "^4.3.1" lru-cache "^7.14.0" @@ -327,7 +327,7 @@ dependencies: tslib "^2.4.0" -"@es-joy/jsdoccomment@~0.95.0": +"@es-joy/jsdoccomment@~0.95.1": version "0.95.1" resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.95.1.tgz#1afb434ae17844b43203d0b8de477476b9aeefeb" integrity sha512-LO/RI08Fo9bhXwB7Od9G+1j3eSNq63+ZS5CQO8YLXHbDg6kx6S/DhTeY0+Fc9uZrjK1zZSyTx8Sg5gv5DIoCnA== @@ -805,85 +805,65 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== -"@oven/bun-darwin-aarch64@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.14.tgz#dfc0a5e9da4b1202bb3ca019df73e939a898c5a9" - integrity sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A== - -"@oven/bun-darwin-x64-baseline@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.14.tgz#806709148b5e6c151e840ac8c71fa1c155bb8be1" - integrity sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g== - -"@oven/bun-darwin-x64@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.14.tgz#958f721f2b369e066678181d4189f6268a89d50d" - integrity sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w== - -"@oven/bun-freebsd-aarch64@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-freebsd-aarch64/-/bun-freebsd-aarch64-1.3.14.tgz#367b80bd2b925fd788566eee94370770b5eee7e9" - integrity sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw== - -"@oven/bun-freebsd-x64@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-freebsd-x64/-/bun-freebsd-x64-1.3.14.tgz#48d3c5a947e70c3830a7f486c6a1c011d342c3ea" - integrity sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw== - -"@oven/bun-linux-aarch64-android@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-aarch64-android/-/bun-linux-aarch64-android-1.3.14.tgz#7456c75274085bda990c0eab7c58c1bc53ac22a6" - integrity sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA== - -"@oven/bun-linux-aarch64-musl@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.14.tgz#a4d4721b783f7a0ab917dcb030873b7a6312c81f" - integrity sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA== - -"@oven/bun-linux-aarch64@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.14.tgz#61b3e0df245804d2eb9dbae3fc7ef71d403fdea4" - integrity sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw== - -"@oven/bun-linux-x64-android@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64-android/-/bun-linux-x64-android-1.3.14.tgz#45959a396139e4253f5ba83860bfddbefdbe4c34" - integrity sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg== - -"@oven/bun-linux-x64-baseline@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.14.tgz#0ca34e5989721060d0dfe25644a2153c52a5640c" - integrity sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA== - -"@oven/bun-linux-x64-musl-baseline@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.14.tgz#b8e6934253822d14df15bb93a26966c23b971cd3" - integrity sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw== - -"@oven/bun-linux-x64-musl@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.14.tgz#aa26d81a5bef10cd10ce9130d02d8ccb5eee3c3c" - integrity sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg== - -"@oven/bun-linux-x64@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64/-/bun-linux-x64-1.3.14.tgz#e345c9e5fa2e9cf85899671c07b183d1704e1046" - integrity sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw== - -"@oven/bun-windows-aarch64@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.14.tgz#8e344689b665cf8b9e5fab46612edb09eec1e1fc" - integrity sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA== - -"@oven/bun-windows-x64-baseline@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.14.tgz#3df9a3f6a419fa4510e8fb1640cc0c2f723f57b1" - integrity sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw== - -"@oven/bun-windows-x64@1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@oven/bun-windows-x64/-/bun-windows-x64-1.3.14.tgz#a3d4cd9d4545b542739cfbae08949a797a63ca10" - integrity sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg== +"@oven/bun-darwin-aarch64@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.4.0.tgz#627fb1a1dc49ad800d0791ee9abf7e6c43761acf" + integrity sha512-GCpf8QuFLsyioVawP5HrMxA1ZRBlu6Hq9RNnSc3UTUWAzIxBso9trjoZczw1HdgpqSssFkszfIV2zmOzFTjhkw== + +"@oven/bun-darwin-x64@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@oven/bun-darwin-x64/-/bun-darwin-x64-1.4.0.tgz#d0c49ca7f5820607a7ee446323b4e0c96fe4bb4e" + integrity sha512-cIrhwOr0SPEraewznhC+c/k6TG8bwFn5uZ4EJuXwjiKJLcAF36q7/bGjWkeXSe48JwMcPRUR054JXF7+cRwSSA== + +"@oven/bun-freebsd-aarch64@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@oven/bun-freebsd-aarch64/-/bun-freebsd-aarch64-1.4.0.tgz#0d916a32002b84de663e1a2954c5075e8a1a2590" + integrity sha512-09x7wnjMR6M5KGBDBhVl2CpfoCIQOkVDbPX2KfIhpXv4N6grbWE7dfLPw/Ydi9gaUMGhU7UKhoz444Nu6RCycA== + +"@oven/bun-freebsd-x64@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@oven/bun-freebsd-x64/-/bun-freebsd-x64-1.4.0.tgz#c3ebd9b8c0bc639d56f4352207eb199c371ae386" + integrity sha512-dRwzti/qJqV1HWplU27iUWUqp+f2DtFSf2yqQKSb+HH2dDOC//Uqd9u/A5h1DMsLszfP5OGP9UwQIKxVwFODaA== + +"@oven/bun-linux-aarch64-android@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@oven/bun-linux-aarch64-android/-/bun-linux-aarch64-android-1.4.0.tgz#1057945f85f3f1c99f0e4e26445bce3a8f890cd8" + integrity sha512-HpPIxJfDNPBPhiBNMyZoo/dOLijARfsx5j72vNuLtaTvl0Hh7HUculxjsOQ2WSyGoCgqXMEr1Qqjab1im9u1RA== + +"@oven/bun-linux-aarch64-musl@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.4.0.tgz#8ab569cceb48bac5a2dc93d0f3fae68772441b1f" + integrity sha512-RUjAAkJ/CdNV++zVxyANWshPc73CECYsfhk0fWAkoJjtywxJ2BwXzI6nopBBDMfs0HS+fhRGn6zGwU8ccxLeJg== + +"@oven/bun-linux-aarch64@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.4.0.tgz#521aa7b6ab4a00e26b0516415dabdec6d148b24a" + integrity sha512-Y5yAtCbHK6JjprXEtkdklDQFPADgs+CkfcliyY5g4JJ8baGHyQSrfpSkX3XVJ2C+aBLsdwNDdW+oczMsAwx6uA== + +"@oven/bun-linux-x64-android@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64-android/-/bun-linux-x64-android-1.4.0.tgz#1fbd9724667115fa26512c8b1c78902900cc95e0" + integrity sha512-u++KyLlfMn36yWz+AgJs+fZtS46UFDNpSSZhrcitkytONtNwq0X6Q9BDVEFXxYl/+Eec0xme1rb6MgW+U35WeA== + +"@oven/bun-linux-x64-musl@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.4.0.tgz#c910c6387b47a1ca3c40221f5aed65c8c1c8920b" + integrity sha512-C1Dv+ISL8YKEKM9jAHzNifOcRUoziy6UMxh+yVXjUCP6QnbRhENDHLaIWWkQZJyBLTn0I3xozflorAlHiGzGqA== + +"@oven/bun-linux-x64@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64/-/bun-linux-x64-1.4.0.tgz#a0a0c9dba8ffa5cafb0cf44efafd59a261462020" + integrity sha512-Du44zebtPXJujvMLmtIxEQ6ykOhYt7L/Q+YIGVm+Yy+Pj/fpOnq60ggwIpKp/pGAFbYHNiTrA3JTjuZ9MTbZIg== + +"@oven/bun-windows-aarch64@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.4.0.tgz#7dd8b06154c810d883a227eff9569682d5e57845" + integrity sha512-FBAYaQpJBP0asgqzL6NFUfjdQqsV+kvTpJ/eWxPKj+RcDgIfPSuE8kvQuPYu5pa8u8JTujYMjmuyvHxVuQsInA== + +"@oven/bun-windows-x64@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@oven/bun-windows-x64/-/bun-windows-x64-1.4.0.tgz#d5eea2914a59aeb7a7b1d22cdf0aba617c10ad69" + integrity sha512-jRKv1NPLznMSZY5BEWciMF7zv0Tiyo2pQSxAJ3w+YWJ6y3VWNJQQQdLlV5Jx8lbOFDrJdrc9dD3GV17k3BP41A== "@oxc-parser/binding-android-arm-eabi@0.132.0": version "0.132.0" @@ -1222,10 +1202,10 @@ resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz#72da0da48d72b1e87831b9c0308931d3f4669027" integrity sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA== -"@vercel/nft@^1.10.2": - version "1.10.2" - resolved "https://registry.yarnpkg.com/@vercel/nft/-/nft-1.10.2.tgz#01aefaddc079a19bfe7d499872b2d6dd3cd729e1" - integrity sha512-w+WyX5Ulmj4dtTZrxaulqrjaLZHSbnPzx75SJsTNYmotKsqn1JlLnDJa+lz5hn90HJofhl/2MAtw0mCrgM3qYw== +"@vercel/nft@^1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@vercel/nft/-/nft-1.11.0.tgz#ed2db36a90f54087f36c2d8aff9e91e289cdb3c0" + integrity sha512-m1QFg+U+3yPOnP1xSYJ73UIRxLOXdts1JOhiOiyPYqEsALgrXFFINvgUaD6R6iNvaBFAjHllBCbkfx4FuOdpaA== dependencies: "@mapbox/node-pre-gyp" "^2.0.0" "@rollup/pluginutils" "^5.1.3" @@ -1237,7 +1217,7 @@ glob "^13.0.0" graceful-fs "^4.2.9" node-gyp-build "^4.2.2" - picomatch "^4.0.2" + picomatch "^4.0.4" resolve-from "^5.0.0" "@yarnpkg/lockfile@^1.1.0": @@ -1487,27 +1467,23 @@ builtin-modules@^5.0.0: resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-5.0.0.tgz#9be95686dedad2e9eed05592b07733db87dcff1a" integrity sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg== -bun@1.3.14: - version "1.3.14" - resolved "https://registry.yarnpkg.com/bun/-/bun-1.3.14.tgz#7ed8c12d8d3a0cb4183738b73798ba3b94b4df7e" - integrity sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg== +bun@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/bun/-/bun-1.4.0.tgz#e2b68e3750d7feed6ddfdb119958a553b617f73f" + integrity sha512-iRiFkc2W7UVpCyZXO9tod45TP9QCyN19fWqbpeN/jaM/K7uzeHYx/OSPsahMJazGKBgPsnxRt+4Jc43d8BcHZw== optionalDependencies: - "@oven/bun-darwin-aarch64" "1.3.14" - "@oven/bun-darwin-x64" "1.3.14" - "@oven/bun-darwin-x64-baseline" "1.3.14" - "@oven/bun-freebsd-aarch64" "1.3.14" - "@oven/bun-freebsd-x64" "1.3.14" - "@oven/bun-linux-aarch64" "1.3.14" - "@oven/bun-linux-aarch64-android" "1.3.14" - "@oven/bun-linux-aarch64-musl" "1.3.14" - "@oven/bun-linux-x64" "1.3.14" - "@oven/bun-linux-x64-android" "1.3.14" - "@oven/bun-linux-x64-baseline" "1.3.14" - "@oven/bun-linux-x64-musl" "1.3.14" - "@oven/bun-linux-x64-musl-baseline" "1.3.14" - "@oven/bun-windows-aarch64" "1.3.14" - "@oven/bun-windows-x64" "1.3.14" - "@oven/bun-windows-x64-baseline" "1.3.14" + "@oven/bun-darwin-aarch64" "1.4.0" + "@oven/bun-darwin-x64" "1.4.0" + "@oven/bun-freebsd-aarch64" "1.4.0" + "@oven/bun-freebsd-x64" "1.4.0" + "@oven/bun-linux-aarch64" "1.4.0" + "@oven/bun-linux-aarch64-android" "1.4.0" + "@oven/bun-linux-aarch64-musl" "1.4.0" + "@oven/bun-linux-x64" "1.4.0" + "@oven/bun-linux-x64-android" "1.4.0" + "@oven/bun-linux-x64-musl" "1.4.0" + "@oven/bun-windows-aarch64" "1.4.0" + "@oven/bun-windows-x64" "1.4.0" busboy@^1.6.0: version "1.6.0" @@ -2005,12 +1981,12 @@ eslint-plugin-import-x@^4.16.2: stable-hash-x "^0.2.0" unrs-resolver "^1.9.2" -eslint-plugin-jsdoc@^64.2.0: - version "64.2.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-64.2.0.tgz#90b4ef3d444f8dbfaa7d43f20570c2da08edf81f" - integrity sha512-z3zGmJoPhOdKnxzQ3R+8MZeJjW8vrRW8r7sYp/ErBp8K9+05RIa6vWXtbEGr7D1obBHk64LdKyLHoMLSzHFINA== +eslint-plugin-jsdoc@^64.2.1: + version "64.2.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-64.2.1.tgz#05755c3cdf55d0ce901c67cd3f86220dcb55192e" + integrity sha512-6GpSYxLPcbMw38S94Cngrgs1Zv8yLinQS1O17OxJVZ6deLbrMRCERUYQKceweSqEuG2kx5Amn4l3aKPkCp4geQ== dependencies: - "@es-joy/jsdoccomment" "~0.95.0" + "@es-joy/jsdoccomment" "~0.95.1" "@es-joy/resolve.exports" "1.2.0" are-docs-informative "^0.1.1" comment-parser "1.4.8" @@ -3537,10 +3513,10 @@ picocolors@^1.1.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@^4.0.2, picomatch@^4.0.3: - version "4.0.5" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" - integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== +picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" + integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== pkg-dir@^4.1.0: version "4.2.0"